Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ let aioLogger = require('@adobe/aio-lib-core-logging')('App', config)
The config object can have one or more of the following keys.

- level (max severity logging level to be logged. can be one of error, warn, info, verbose, debug, silly)
- provider (logging provider. default is winston.)
- provider (logging provider. can be `winston` (default), `debug`, or `structured`)
- logSourceAction (boolean to control whether to include the action name in the log message)
- transports (array of custom winston transports)
- fields (key-value pairs merged into every log entry, structured provider only)

The global log level can also be overridden using the env variable AIO_LOG_LEVEL or the env variable LOG_LEVEL.

Expand Down Expand Up @@ -83,7 +84,7 @@ logger.debug('debug')
### Using custom logger

```javascript
// Winston Logger
// Winston Logger (default)
let aioLogger = require('@adobe/aio-lib-core-logging')('App', {provider:'winston'})
aioLogger.info('Hello logs')
```
Expand All @@ -95,6 +96,26 @@ or
let aioLogger = require('@adobe/aio-lib-core-logging')('App', {provider:'debug'})
```

### Structured logging

Use `provider: 'structured'` to output newline-delimited JSON — useful for ingestion by observability platforms (Grafana Loki, Elastic, Honeycomb, etc.) and for OTEL log pipelines where fields become queryable log record attributes.

```javascript
const logger = require('@adobe/aio-lib-core-logging')('App', {
provider: 'structured',
fields: { service: 'payment-svc', env: 'prod' } // merged into every log entry
})

logger.info('started')
// → {"timestamp":"...","level":"info","label":"App","message":"started","service":"payment-svc","env":"prod"}

// Pass a plain object as the second argument to add fields per statement
logger.info('payment processed', { orderId: 'ORD-001', amount: 99.99 })
// → {"timestamp":"...","level":"info","label":"App","message":"payment processed","service":"payment-svc","env":"prod","orderId":"ORD-001","amount":99.99}
```

Statement-level fields are merged with logger-level fields; statement fields win on key collision.

### Send logs to a file

```javascript
Expand Down
3 changes: 2 additions & 1 deletion doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ configuration for the log framework
| [level] | <code>string</code> | logging level for winston, defaults to info |
| [transports] | <code>string</code> | transport config for winston, defaults to undefined |
| [silent] | <code>boolean</code> | silent config for winston, defaults to false |
| [provider] | <code>string</code> | defaults to winston, can be set to either 'winston' or 'debug' |
| [provider] | <code>string</code> | defaults to winston, can be set to 'winston', 'debug', or 'structured' |
| [logSourceAction] | <code>boolean</code> | defaults to true if __OW_ACTION_NAME is set otherwise defaults to false. If running in an action set logSourceAction to false if you do not want to log the action name. |
| [fields] | <code>object</code> | key-value pairs merged into every log entry (structured provider only) |

7 changes: 5 additions & 2 deletions src/AioLogger.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const DEFAULT_LABEL = 'AIO'
* @property {string} [level] logging level for winston, defaults to info
* @property {string} [transports] transport config for winston, defaults to undefined
* @property {boolean} [silent] silent config for winston, defaults to false
* @property {string} [provider] defaults to winston, can be set to either 'winston' or 'debug'
* @property {string} [provider] defaults to winston, can be set to 'winston', 'debug', or 'structured'
* @property {boolean} [logSourceAction] defaults to true if __OW_ACTION_NAME is set otherwise defaults to false. If
* running in an action set logSourceAction to false if you do not want to log the action name.
* @property {object} [fields] key-value pairs merged into every log entry (structured provider only)
*/

/**
Expand All @@ -45,7 +46,8 @@ class AioLogger {
this.setDefaults(moduleName, config)
if (this.config.provider === 'winston') this.LogProvider = require('./WinstonLogger')
else if (this.config.provider === 'debug') this.LogProvider = require('./DebugLogger')
else throw new Error(`log provider ${this.config.provider} is not supported, use one of [winston, debug]`)
else if (this.config.provider === 'structured') this.LogProvider = require('./WinstonStructuredLogger')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but maybe something like json fits more for the provider name? Or similar. structured also sounds good but reads a bit weird to me.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No strong opinion, let's wait for the owners review.

else throw new Error(`log provider ${this.config.provider} is not supported, use one of [winston, debug, structured]`)
this.logger = new this.LogProvider(this.config)
}

Expand All @@ -59,6 +61,7 @@ class AioLogger {
this.config.label = this.generateLabel(moduleName, this.config)
this.config.silent = config.silent || false
this.config.transports = config.transports
this.config.fields = { ...config.fields }
}

generateLabel (moduleName, config) {
Expand Down
21 changes: 2 additions & 19 deletions src/WinstonLogger.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ governing permissions and limitations under the License.
const winston = require('winston')
const util = require('node:util')
const { combine, timestamp, label, splat } = winston.format
const DEFAULT_DEST = 'console'
const getWinstonTransports = require('./winstonTransports')

class WinstonLogger {
constructor (config) {
Expand All @@ -25,7 +25,7 @@ class WinstonLogger {
timestamp(),
this.getWinstonFormat()
),
transports: this.getWinstonTransports(config.transports || DEFAULT_DEST),
transports: getWinstonTransports(config.transports),
silent: config.silent
})
}
Expand All @@ -36,23 +36,6 @@ class WinstonLogger {
})
}

getWinstonTransports (transports) {
const wTransports = []
switch (transports) {
case 'console':
wTransports.push(new winston.transports.Console())
break
default:
if (typeof (transports) === 'string' && transports.toString().indexOf('.') !== -1) {
wTransports.push(new winston.transports.File({ filename: transports }))
} else {
transports.forEach((t) => wTransports.push(t))
}
break
}
return wTransports
}

close () {
this.logger.close()
}
Expand Down
53 changes: 53 additions & 0 deletions src/WinstonStructuredLogger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
const winston = require('winston')
const getWinstonTransports = require('./winstonTransports')

/**
* Builds a structured log entry object from log method arguments and fields.
* Statement-level fields (second element of data) are merged over logger-level
* fields, so per-call values take precedence on key collision.
*
* @param {Array} data - arguments passed to a log method: [message, stmtFields?]
* @param {object} fields - logger-level fields set at construction time
* @returns {object} log entry ready to pass to winston
*/
function buildEntry (data, fields) {
const [message = '', stmtFields = {}] = data
return { message, ...fields, ...stmtFields }
}

class WinstonStructuredLogger {
constructor (config) {
this.fields = config.fields || {}
this.logger = winston.createLogger({
level: config.level,
format: winston.format.combine(
winston.format.label({ label: config.label }),
winston.format.timestamp(),
winston.format.json()
),
transports: getWinstonTransports(config.transports),
silent: config.silent
})
}

close () { this.logger.close() }
error (...data) { this.logger.error(buildEntry(data, this.fields)) }
warn (...data) { this.logger.warn(buildEntry(data, this.fields)) }
info (...data) { this.logger.info(buildEntry(data, this.fields)) }
verbose (...data) { this.logger.verbose(buildEntry(data, this.fields)) }
debug (...data) { this.logger.debug(buildEntry(data, this.fields)) }
silly (...data) { this.logger.silly(buildEntry(data, this.fields)) }
}

module.exports = WinstonStructuredLogger
32 changes: 32 additions & 0 deletions src/winstonTransports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
const winston = require('winston')
const DEFAULT_DEST = 'console'

function getWinstonTransports (transports = DEFAULT_DEST) {
const wTransports = []
switch (transports) {
case 'console':
wTransports.push(new winston.transports.Console())
break
default:
if (typeof (transports) === 'string' && transports.toString().indexOf('.') !== -1) {
wTransports.push(new winston.transports.File({ filename: transports }))
} else {
transports.forEach((t) => wTransports.push(t))
}
break
}
return wTransports
}

module.exports = getWinstonTransports
118 changes: 96 additions & 22 deletions test/AioLogger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,6 @@ describe('winston logger', () => {
expect(global.console.log).toHaveBeenLastCalledWith(expect.stringContaining(`[AIO] info: ${message.join(' ')}`))
})

test('use log file path', async () => {
const aioLogger = AioLogger('App', { transports: LOG_FILE_PATH, logSourceAction: false })
const message = 'message'

aioLogger.error(message)
aioLogger.close()

expect(await getLog(LOG_FILE_PATH)).toContain(`[App] error: ${message}`)
})

test('use winston.transports.file', async () => {
const winston = require('winston')
const aioLogger = AioLogger('App', { transports: [new winston.transports.File({ filename: LOG_FILE_PATH })], logSourceAction: false })
const message = 'message'

aioLogger.error(message)
aioLogger.close()

expect(await getLog(LOG_FILE_PATH)).toContain(`[App] error: ${message}`)
})

Comment thread
obarcelonap marked this conversation as resolved.
test('with AIO_LOG_LEVEL = error', () => {
process.env.AIO_LOG_LEVEL = 'error'

Expand All @@ -161,7 +140,7 @@ describe('winston logger', () => {
expect.hasAssertions()

const provider = '__a_surely_not_supported_provider1234'
const expectedError = new Error(`log provider ${provider} is not supported, use one of [winston, debug]`)
const expectedError = new Error(`log provider ${provider} is not supported, use one of [winston, debug, structured]`)
const func = () => AioLogger('App', { provider })

expect(func).toThrow(expectedError)
Expand Down Expand Up @@ -520,3 +499,98 @@ describe('debug logger', () => {
)
})
})

describe('structured logger', () => {
function parseLastLog () {
const calls = global.console.log.mock.calls
return JSON.parse(calls[calls.length - 1][0])
}

test('config fields appear in every log entry', () => {
const aioLogger = AioLogger('App', { provider: 'structured', fields: { service: 'api', env: 'test' } })
aioLogger.info('hello')
aioLogger.close()

const log = parseLastLog()
expect(log.message).toEqual('hello')
expect(log.service).toEqual('api')
expect(log.env).toEqual('test')
expect(log.level).toEqual('info')
expect(log.label).toEqual('App')
expect(log.timestamp).toBeDefined()
})

test('statement fields are merged with config fields', () => {
const aioLogger = AioLogger('App', { provider: 'structured', fields: { service: 'api' } })
aioLogger.info('payment', { orderId: 'ORD-001', amount: 99 })
aioLogger.close()

const log = parseLastLog()
expect(log.message).toEqual('payment')
expect(log.service).toEqual('api')
expect(log.orderId).toEqual('ORD-001')
expect(log.amount).toEqual(99)
})

test('statement fields override config fields on collision', () => {
const aioLogger = AioLogger('App', { provider: 'structured', fields: { service: 'api', env: 'prod' } })
aioLogger.info('msg', { env: 'test' })
aioLogger.close()

const log = parseLastLog()
expect(log.env).toEqual('test')
expect(log.service).toEqual('api')
})

test('no fields produces valid JSON with no extra keys', () => {
const aioLogger = AioLogger('App', { provider: 'structured' })
aioLogger.warn('something')
aioLogger.close()

const log = parseLastLog()
expect(log.message).toEqual('something')
expect(log.level).toEqual('warn')
expect(log.label).toEqual('App')
expect(log.timestamp).toBeDefined()
expect(Object.keys(log)).toEqual(expect.arrayContaining(['message', 'level', 'label', 'timestamp']))
})

test('all log levels produce structured output', () => {
const aioLogger = AioLogger('App', { provider: 'structured', level: 'silly', fields: { svc: 'x' } })
aioLogger.error('e')
aioLogger.warn('w')
aioLogger.info('i')
aioLogger.log('l')
aioLogger.verbose('v')
aioLogger.debug('d')
aioLogger.silly('s')
aioLogger.close()

expect(global.console.log).toHaveBeenCalledTimes(7)
const calls = global.console.log.mock.calls
calls.forEach(([line]) => {
const log = JSON.parse(line)
expect(log.svc).toEqual('x')
expect(log.timestamp).toBeDefined()
})
})

test('config is reported correctly', () => {
const aioLogger = AioLogger('App', { provider: 'structured', fields: { k: 'v' } })
expect(aioLogger.config.provider).toEqual('structured')
expect(aioLogger.config.fields).toEqual({ k: 'v' })
})

test('AIO_LOG_LEVEL filters output', () => {
process.env.AIO_LOG_LEVEL = 'error'

const aioLogger = AioLogger('App', { provider: 'structured' })
aioLogger.error('e')
aioLogger.info('i')
aioLogger.close()

expect(global.console.log).toHaveBeenCalledTimes(1)
const log = JSON.parse(global.console.log.mock.calls[0][0])
expect(log.level).toEqual('error')
})
})
Loading
Loading