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");