diff --git a/README.md b/README.md
index 49a09db..d6af267 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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')
```
@@ -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
diff --git a/doc/api.md b/doc/api.md
index dbe35eb..4268519 100644
--- a/doc/api.md
+++ b/doc/api.md
@@ -154,6 +154,7 @@ configuration for the log framework
| [level] | string | logging level for winston, defaults to info |
| [transports] | string | transport config for winston, defaults to undefined |
| [silent] | boolean | silent config for winston, defaults to false |
-| [provider] | string | defaults to winston, can be set to either 'winston' or 'debug' |
+| [provider] | string | defaults to winston, can be set to 'winston', 'debug', or 'structured' |
| [logSourceAction] | boolean | 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] | object | key-value pairs merged into every log entry (structured provider only) |
diff --git a/src/AioLogger.js b/src/AioLogger.js
index 3b327ab..01bc0e4 100644
--- a/src/AioLogger.js
+++ b/src/AioLogger.js
@@ -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)
*/
/**
@@ -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')
+ 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)
}
@@ -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) {
diff --git a/src/WinstonLogger.js b/src/WinstonLogger.js
index 047b145..c1d378c 100644
--- a/src/WinstonLogger.js
+++ b/src/WinstonLogger.js
@@ -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) {
@@ -25,7 +25,7 @@ class WinstonLogger {
timestamp(),
this.getWinstonFormat()
),
- transports: this.getWinstonTransports(config.transports || DEFAULT_DEST),
+ transports: getWinstonTransports(config.transports),
silent: config.silent
})
}
@@ -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()
}
diff --git a/src/WinstonStructuredLogger.js b/src/WinstonStructuredLogger.js
new file mode 100644
index 0000000..c43ba3d
--- /dev/null
+++ b/src/WinstonStructuredLogger.js
@@ -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
diff --git a/src/winstonTransports.js b/src/winstonTransports.js
new file mode 100644
index 0000000..a82a781
--- /dev/null
+++ b/src/winstonTransports.js
@@ -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
diff --git a/test/AioLogger.test.js b/test/AioLogger.test.js
index 1110cbb..33fd5b2 100644
--- a/test/AioLogger.test.js
+++ b/test/AioLogger.test.js
@@ -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}`)
- })
-
test('with AIO_LOG_LEVEL = error', () => {
process.env.AIO_LOG_LEVEL = 'error'
@@ -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)
@@ -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')
+ })
+})
diff --git a/test/winstonTransports.test.js b/test/winstonTransports.test.js
new file mode 100644
index 0000000..e79e08d
--- /dev/null
+++ b/test/winstonTransports.test.js
@@ -0,0 +1,50 @@
+/*
+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('../src/winstonTransports')
+
+describe('getWinstonTransports', () => {
+ test('defaults to Console when no argument is passed', () => {
+ const transports = getWinstonTransports()
+ expect(transports).toHaveLength(1)
+ expect(transports[0]).toBeInstanceOf(winston.transports.Console)
+ })
+
+ test("returns Console transport for 'console'", () => {
+ const transports = getWinstonTransports('console')
+ expect(transports).toHaveLength(1)
+ expect(transports[0]).toBeInstanceOf(winston.transports.Console)
+ })
+
+ test('returns File transport for a file path string', () => {
+ const transports = getWinstonTransports('./app.log')
+ expect(transports).toHaveLength(1)
+ expect(transports[0]).toBeInstanceOf(winston.transports.File)
+ })
+
+ test('returns provided transport instances when passed an array', () => {
+ const fileTransport = new winston.transports.File({ filename: './app.log' })
+ const transports = getWinstonTransports([fileTransport])
+ expect(transports).toHaveLength(1)
+ expect(transports[0]).toBe(fileTransport)
+ })
+
+ test('returns multiple transport instances from an array', () => {
+ const t1 = new winston.transports.Console()
+ const t2 = new winston.transports.File({ filename: './app.log' })
+ const transports = getWinstonTransports([t1, t2])
+ expect(transports).toHaveLength(2)
+ expect(transports[0]).toBe(t1)
+ expect(transports[1]).toBe(t2)
+ })
+})
diff --git a/types/AioLogger.d.ts b/types/AioLogger.d.ts
index 8b05052..d8ee949 100644
--- a/types/AioLogger.d.ts
+++ b/types/AioLogger.d.ts
@@ -20,7 +20,7 @@ type AioLoggerConfig = {
*/
silent?: boolean;
/**
- * defaults to winston, can be set to either 'winston' or 'debug'
+ * defaults to winston, can be set to 'winston', 'debug', or 'structured'
*/
provider?: string;
/**
@@ -28,6 +28,10 @@ type AioLoggerConfig = {
* running in an action set logSourceAction to false if you do not want to log the action name.
*/
logSourceAction?: boolean;
+ /**
+ * key-value pairs merged into every log entry (structured provider only)
+ */
+ fields?: object;
};
/**
* @module @adobe/aio-lib-core-logging
@@ -40,9 +44,10 @@ type AioLoggerConfig = {
* @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)
*/
/**
* This class provides a logging framework with pluggable logging provider.
@@ -55,8 +60,8 @@ declare class AioLogger {
* @param {AioLoggerConfig} [config={}] for the log framework.
*/
constructor(moduleName: string, config?: AioLoggerConfig);
- LogProvider: typeof import("./WinstonLogger") | typeof import("./DebugLogger");
- logger: import("./WinstonLogger") | import("./DebugLogger");
+ LogProvider: typeof import("./WinstonLogger") | typeof import("./DebugLogger") | typeof import("./WinstonStructuredLogger");
+ logger: import("./WinstonLogger") | import("./DebugLogger") | import("./WinstonStructuredLogger");
setDefaults(moduleName: any, config: any): void;
config: {};
generateLabel(moduleName: any, config: any): string;
diff --git a/types/WinstonLogger.d.ts b/types/WinstonLogger.d.ts
index 877970e..04c9255 100644
--- a/types/WinstonLogger.d.ts
+++ b/types/WinstonLogger.d.ts
@@ -4,7 +4,6 @@ declare class WinstonLogger {
config: any;
logger: winston.Logger;
getWinstonFormat(): winston.Logform.Format;
- getWinstonTransports(transports: any): (winston.transports.ConsoleTransportInstance | winston.transports.FileTransportInstance)[];
close(): void;
error(...args: any[]): void;
warn(...args: any[]): void;
diff --git a/types/WinstonStructuredLogger.d.ts b/types/WinstonStructuredLogger.d.ts
new file mode 100644
index 0000000..a617d08
--- /dev/null
+++ b/types/WinstonStructuredLogger.d.ts
@@ -0,0 +1,14 @@
+export = WinstonStructuredLogger;
+declare class WinstonStructuredLogger {
+ constructor(config: any);
+ fields: any;
+ logger: winston.Logger;
+ close(): void;
+ error(...data: any[]): void;
+ warn(...data: any[]): void;
+ info(...data: any[]): void;
+ verbose(...data: any[]): void;
+ debug(...data: any[]): void;
+ silly(...data: any[]): void;
+}
+import winston = require("winston");
diff --git a/types/winstonTransports.d.ts b/types/winstonTransports.d.ts
new file mode 100644
index 0000000..b26806d
--- /dev/null
+++ b/types/winstonTransports.d.ts
@@ -0,0 +1,3 @@
+export = getWinstonTransports;
+declare function getWinstonTransports(transports?: string): (winston.transports.ConsoleTransportInstance | winston.transports.FileTransportInstance)[];
+import winston = require("winston");