From edcf505b6d8a5cef8dec4d863445cdf72d14eb45 Mon Sep 17 00:00:00 2001 From: Christian Dinse Date: Thu, 30 Apr 2026 17:10:10 +0200 Subject: [PATCH] Add OpenTelemetry output plugin --- .gitignore | 2 +- package-lock.json | 44 +++++++--- package.json | 1 + src/index.ts | 2 + src/lib/config/default/config-schema.json | 3 + src/lib/config/interfaces.ts | 3 + src/lib/helper/levelUtils.ts | 24 ------ src/lib/helper/pluginProvider.ts | 31 ++++++++ src/lib/logger/level.ts | 26 ++++++ src/lib/logger/logger.ts | 32 ++------ src/lib/logger/record.ts | 39 +++++++-- src/lib/logger/recordFactory.ts | 27 ++++--- src/lib/logger/recordWriter.ts | 33 -------- src/lib/logger/rootLogger.ts | 23 +++++- src/lib/logger/sourceUtils.ts | 34 +++++--- src/lib/middleware/middleware.ts | 10 +-- src/lib/plugins/interfaces.ts | 5 ++ src/lib/plugins/otelOutput.ts | 97 +++++++++++++++++++++++ src/lib/plugins/stdoutOutput.ts | 23 ++++++ src/test/unit-test/helper.test.js | 3 +- 20 files changed, 330 insertions(+), 132 deletions(-) delete mode 100644 src/lib/helper/levelUtils.ts create mode 100644 src/lib/helper/pluginProvider.ts delete mode 100644 src/lib/logger/recordWriter.ts create mode 100644 src/lib/plugins/interfaces.ts create mode 100644 src/lib/plugins/otelOutput.ts create mode 100644 src/lib/plugins/stdoutOutput.ts diff --git a/.gitignore b/.gitignore index 7c9c9604..db186aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -build/ node_modules/ coverage/ +build/ .vscode/ .nyc_output/ diff --git a/package-lock.json b/package-lock.json index 035c6aa5..b9c6bbfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "8.0.0", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.3", @@ -1078,6 +1079,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -4018,6 +4040,17 @@ "node": "20 || >=22" } }, + "node_modules/istanbul-lib-processinfo/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -6841,17 +6874,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 4e880ab0..ef32dca8 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "README.md" ], "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.3", diff --git a/src/index.ts b/src/index.ts index f2edbb2e..b5ccaebc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,5 @@ export default rootLogger; export * from "./lib/config/interfaces.js"; export * from "./lib/logger/level.js"; export * from "./lib/logger/logger.js"; +export * from "./lib/plugins/stdoutOutput.js"; +export * from "./lib/plugins/otelOutput.js"; diff --git a/src/lib/config/default/config-schema.json b/src/lib/config/default/config-schema.json index a2d65f01..aa002bdd 100644 --- a/src/lib/config/default/config-schema.json +++ b/src/lib/config/default/config-schema.json @@ -121,6 +121,9 @@ "writtenTs", "message", "stacktrace", + "rawStacktrace", + "errorName", + "errorMessage", "level" ], "type": "string" diff --git a/src/lib/config/interfaces.ts b/src/lib/config/interfaces.ts index 39e9c6b6..6db5ede6 100644 --- a/src/lib/config/interfaces.ts +++ b/src/lib/config/interfaces.ts @@ -85,6 +85,9 @@ export enum DetailName { WrittenTs = "writtenTs", Message = "message", Stacktrace = "stacktrace", + RawStacktrace = "rawStacktrace", + ErrorName = "errorName", + ErrorMessage = "errorMessage", Level = "level" } diff --git a/src/lib/helper/levelUtils.ts b/src/lib/helper/levelUtils.ts deleted file mode 100644 index 75d6c72c..00000000 --- a/src/lib/helper/levelUtils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Level } from '../logger/level.js'; - -export default class LevelUtils { - - private static readonly defaultLevel: Level = Level.Info - - static getLevel(name: string): Level { - const key = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); - const level: Level = Level[key as keyof typeof Level] - if (level === undefined) { - return LevelUtils.defaultLevel; - } - return level - } - - static getName(level: Level): string { - return Level[level].toLowerCase() - } - - static isLevelEnabled(threshold: Level, level: Level) { - if (level <= Level.Off) return false; - return level <= threshold - } -} diff --git a/src/lib/helper/pluginProvider.ts b/src/lib/helper/pluginProvider.ts new file mode 100644 index 00000000..8535ffd2 --- /dev/null +++ b/src/lib/helper/pluginProvider.ts @@ -0,0 +1,31 @@ +import { OutputPlugin } from "../plugins/interfaces"; + +export default class PluginProvider { + private static instance: PluginProvider; + private outputPlugins: OutputPlugin[]; + + private constructor() { + this.outputPlugins = []; + } + + static getInstance(): PluginProvider { + if (!PluginProvider.instance) { + PluginProvider.instance = new PluginProvider(); + } + return PluginProvider.instance; + } + + addOutputPlugin(outputPlugin: OutputPlugin) { + this.outputPlugins.push(outputPlugin); + } + + setOutputPlugins(outputPlugins: OutputPlugin[]) { + this.outputPlugins = outputPlugins; + } + + getOutputPlugins(): OutputPlugin[] { + return this.outputPlugins; + } +} + + diff --git a/src/lib/logger/level.ts b/src/lib/logger/level.ts index d417c874..4bdac7fe 100644 --- a/src/lib/logger/level.ts +++ b/src/lib/logger/level.ts @@ -8,3 +8,29 @@ export enum Level { Debug = 4, Silly = 5 } + +export class LevelUtils { + private static readonly defaultLevel: Level = Level.Info + + static getLevel(level: string | Level): Level { + if (typeof level === 'string') { + const key = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); + const lvl: Level = Level[key as keyof typeof Level] + if (lvl !== undefined) { + return lvl; + } + } else { + return level as Level + } + return LevelUtils.defaultLevel; + } + + static getName(level: Level): string { + return Level[level].toLowerCase() + } + + static isLevelEnabled(threshold: Level, level: Level) { + if (level <= Level.Off) return false; + return level <= threshold + } +} diff --git a/src/lib/logger/logger.ts b/src/lib/logger/logger.ts index c39dfa61..e3acc00e 100644 --- a/src/lib/logger/logger.ts +++ b/src/lib/logger/logger.ts @@ -1,9 +1,8 @@ -import LevelUtils from '../helper/levelUtils.js'; import { isValidObject } from '../middleware/utils.js'; -import { Level } from './level.js'; +import { Level, LevelUtils } from './level.js'; import RecordFactory from './recordFactory.js'; -import RecordWriter from './recordWriter.js'; import Context from './context.js'; +import PluginProvider from '../helper/pluginProvider.js'; export class Logger { private parent?: Logger = undefined @@ -11,7 +10,7 @@ export class Logger { private registeredCustomFields: Array = []; private customFields: Map = new Map() private recordFactory: RecordFactory; - private recordWriter: RecordWriter; + protected loggingLevelThreshold: Level = Level.Inherit constructor(parent?: Logger, context?: Context) { @@ -23,7 +22,6 @@ export class Logger { this.context = context; } this.recordFactory = RecordFactory.getInstance(); - this.recordWriter = RecordWriter.getInstance(); } createLogger(customFields?: Map | object, createNewContext?: boolean): Logger { @@ -37,11 +35,7 @@ export class Logger { } setLoggingLevel(level: string | Level) { - if (typeof level === 'string') { - this.loggingLevelThreshold = LevelUtils.getLevel(level) - } else { - this.loggingLevelThreshold = level - } + this.loggingLevelThreshold = LevelUtils.getLevel(level) } getLoggingLevel(): string { @@ -55,26 +49,14 @@ export class Logger { if (this.loggingLevelThreshold == Level.Inherit) { return this.parent!.isLoggingLevel(level) } - if (typeof level === 'string') { - return LevelUtils.isLevelEnabled(this.loggingLevelThreshold, LevelUtils.getLevel(level)) - } else { - return LevelUtils.isLevelEnabled(this.loggingLevelThreshold, level) - } + return LevelUtils.isLevelEnabled(this.loggingLevelThreshold, LevelUtils.getLevel(level)) } logMessage(level: string | Level, ...args: any) { if (!this.isLoggingLevel(level)) return; const loggerCustomFields = this.getCustomFieldsFromLogger(this); - - let levelName: string; - if (typeof level === 'string') { - levelName = level; - } else { - levelName = LevelUtils.getName(level); - } - - const record = this.recordFactory.buildMsgRecord(this.registeredCustomFields, loggerCustomFields, levelName, args, this.context); - this.recordWriter.writeLog(record); + const record = this.recordFactory.buildMsgRecord(this.registeredCustomFields, loggerCustomFields, LevelUtils.getLevel(level), args, this.context); + PluginProvider.getInstance().getOutputPlugins().forEach(output => { output.writeRecord(record) }) } error(...args: any) { diff --git a/src/lib/logger/record.ts b/src/lib/logger/record.ts index 1d9fd579..a4d0a75e 100644 --- a/src/lib/logger/record.ts +++ b/src/lib/logger/record.ts @@ -1,11 +1,34 @@ -export default class Record { - payload: any - metadata: any +import { Level } from "./level" - constructor(level: string) { +export class Record { + payload: { [key: string]: any } + metadata: RecordMetadata + + constructor(type: RecordType, level: Level) { this.payload = {} - this.metadata = { - level: level - } + this.metadata = new RecordMetadata(type, level) + } +} + +export class RecordMetadata { + type: RecordType + level: Level + message?: string + rawStacktrace?: string + stacktrace?: string[] + errorName?: string + errorMessage?: string + customFieldNames: string[] + + constructor(type: RecordType, level: Level) { + this.type = type + this.level = level + this.customFieldNames = new Array() } -} \ No newline at end of file +} + +export type RecordFieldValue = string | string[] | number | boolean + +export enum RecordType { + Request, Message +} diff --git a/src/lib/logger/recordFactory.ts b/src/lib/logger/recordFactory.ts index e1529ddd..b00be29d 100644 --- a/src/lib/logger/recordFactory.ts +++ b/src/lib/logger/recordFactory.ts @@ -6,9 +6,10 @@ import { CustomFieldsFormat, CustomFieldsTypeConversion, Output } from '../confi import StacktraceUtils from '../helper/stacktraceUtils.js'; import { isValidObject } from '../middleware/utils.js'; import Cache from './cache.js'; -import Record from './record.js'; +import { Record, RecordType } from './record.js'; import Context from './context.js'; import SourceUtils from './sourceUtils.js'; +import { Level } from './level.js'; export default class RecordFactory { @@ -34,19 +35,25 @@ export default class RecordFactory { } // init a new record and assign fields with output "msg-log" - buildMsgRecord(registeredCustomFields: Array, loggerCustomFields: Map, levelName: string, args: Array, context?: Context): Record { + buildMsgRecord(registeredCustomFields: Array, loggerCustomFields: Map, level: Level, args: Array, context?: Context): Record { const lastArg = args[args.length - 1]; let customFieldsFromArgs = new Map(); - const record = new Record(levelName) - - + const record = new Record(RecordType.Message, level) + + if (typeof lastArg === "object") { if (this.stacktraceUtils.isErrorWithStacktrace(lastArg)) { record.metadata.stacktrace = this.stacktraceUtils.prepareStacktrace(lastArg.stack); + record.metadata.rawStacktrace = lastArg.stack + record.metadata.errorMessage = lastArg.message + record.metadata.errorName = lastArg.name } else if (isValidObject(lastArg)) { customFieldsFromArgs = new Map(Object.entries(lastArg)); if (this.stacktraceUtils.isErrorWithStacktrace(lastArg._error)) { record.metadata.stacktrace = this.stacktraceUtils.prepareStacktrace(lastArg._error.stack); + record.metadata.rawStacktrace = lastArg._error.stack + record.metadata.errorMessage = lastArg._error.message + record.metadata.errorName = lastArg._error.name customFieldsFromArgs.delete("_error"); } } else if (lastArg instanceof Map) { @@ -77,8 +84,8 @@ export default class RecordFactory { } // init a new record and assign fields with output "req-log" - buildReqRecord(levelName: string, req: any, res: any, context: Context): Record { - const record = new Record(levelName) + buildReqRecord(level: Level, req: any, res: any, context: Context): Record { + const record = new Record(RecordType.Request, level) // assign static fields from cache const cacheFields = this.config.getCacheReqFields(); @@ -88,7 +95,7 @@ export default class RecordFactory { // assign dynamic fields this.addDynamicFields(record, Output.ReqLog, req, res); - // assign values request context + // assign values request context this.addContext(record, context); // assign custom fields @@ -98,7 +105,7 @@ export default class RecordFactory { return record; } - private addCustomFields(record: Record, registeredCustomFields: Array, loggerCustomFields: Map, + private addCustomFields(record: Record, registeredCustomFields: Array, loggerCustomFields: Map, customFieldsFromArgs: Map = new Map()) { const providedFields = new Map([...loggerCustomFields, ...customFieldsFromArgs]); const customFieldsFormat = this.config.getConfig().customFieldsFormat!; @@ -129,6 +136,8 @@ export default class RecordFactory { indexedCustomFields[key] = value; } } + + record.metadata.customFieldNames.push(key) }); // Write custom fields in the correct order and correlates i to the place in registeredCustomFields diff --git a/src/lib/logger/recordWriter.ts b/src/lib/logger/recordWriter.ts deleted file mode 100644 index a7746ad7..00000000 --- a/src/lib/logger/recordWriter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import os from 'os'; - -import Record from './record.js'; - -export default class RecordWriter { - - private static instance: RecordWriter; - private customSinkFunction: ((level: string, payload: string) => any) | undefined; - - private constructor() {} - - static getInstance(): RecordWriter { - if (!RecordWriter.instance) { - RecordWriter.instance = new RecordWriter(); - } - - return RecordWriter.instance; - } - - writeLog(record: Record): void { - const level = record.metadata.level; - if (this.customSinkFunction) { - this.customSinkFunction(level, JSON.stringify(record.payload)); - } else { - // default to stdout - process.stdout.write(JSON.stringify(record.payload) + os.EOL); - } - } - - setSinkFunction(f: (level: string, payload: string) => any) { - this.customSinkFunction = f; - } -} diff --git a/src/lib/logger/rootLogger.ts b/src/lib/logger/rootLogger.ts index cc21e170..3185e4d7 100644 --- a/src/lib/logger/rootLogger.ts +++ b/src/lib/logger/rootLogger.ts @@ -6,18 +6,23 @@ import EnvService from '../helper/envService.js'; import Middleware from '../middleware/middleware.js'; import RequestAccessor from '../middleware/requestAccessor.js'; import ResponseAccessor from '../middleware/responseAccessor.js'; +import { StdoutOutputPlugin } from '../plugins/stdoutOutput.js'; +import { OutputPlugin } from '../plugins/interfaces.js'; +import PluginProvider from '../helper/pluginProvider.js'; import createTransport from '../winston/winstonTransport.js'; import { Level } from './level.js'; import { Logger } from './logger.js'; -import RecordWriter from './recordWriter.js'; export default class RootLogger extends Logger { private static instance: RootLogger; + private stdoutOutput: StdoutOutputPlugin; private config = Config.getInstance(); private constructor() { super() this.loggingLevelThreshold = Level.Info + this.stdoutOutput = new StdoutOutputPlugin(); + PluginProvider.getInstance().setOutputPlugins([this.stdoutOutput]); } static getInstance(): RootLogger { @@ -56,8 +61,20 @@ export default class RootLogger extends Logger { return this.config.setStartupMessageEnabled(enabled); } - setSinkFunction(func: (level: string, payload: string) => void) { - RecordWriter.getInstance().setSinkFunction(func); + setSinkFunction(func: (level: string, payload: string) => any) { + this.stdoutOutput.setSinkFunction(func); + } + + addOutputPlugin(outputPlugin: OutputPlugin) { + PluginProvider.getInstance().addOutputPlugin(outputPlugin); + } + + setOutputPlugins(...outputPlugin: OutputPlugin[]) { + PluginProvider.getInstance().setOutputPlugins(outputPlugin); + } + + getOutputPlugins(): OutputPlugin[] { + return PluginProvider.getInstance().getOutputPlugins(); } enableTracing(input: string | string[]) { diff --git a/src/lib/logger/sourceUtils.ts b/src/lib/logger/sourceUtils.ts index 034e4293..9486bc90 100644 --- a/src/lib/logger/sourceUtils.ts +++ b/src/lib/logger/sourceUtils.ts @@ -5,7 +5,8 @@ import { ConfigField, Conversion, DetailName, Output, Source, SourceType } from import EnvVarHelper from '../helper/envVarHelper.js'; import RequestAccessor from '../middleware/requestAccessor.js'; import ResponseAccessor from '../middleware/responseAccessor.js'; -import Record from './record.js'; +import { Record, RecordFieldValue } from './record.js'; +import { LevelUtils } from '../logger/level.js'; const NS_PER_MS = 1e6; const REDACTED_PLACEHOLDER = "redacted"; @@ -31,11 +32,11 @@ export default class SourceUtils { return SourceUtils.instance; } - getValue(field: ConfigField, record: Record, output: Output, req?: any, res?: any): string | number | boolean | undefined { + getValue(field: ConfigField, record: Record, output: Output, req?: any, res?: any): RecordFieldValue | undefined { if (!field.source) return undefined const sources = Array.isArray(field.source) ? field.source : [field.source] - let value: string | number | boolean | undefined; + let value: RecordFieldValue | undefined; let sourceIndex = 0; @@ -81,8 +82,8 @@ export default class SourceUtils { return value } - private getValueFromSource(source: Source, record: Record, output: Output, req?: any, res?: any): string | number | boolean | undefined { - let value: string | number | boolean | undefined; + private getValueFromSource(source: Source, record: Record, output: Output, req?: any, res?: any): RecordFieldValue | undefined { + let value: RecordFieldValue | undefined; switch (source.type) { case SourceType.ReqHeader: value = req ? this.requestAccessor.getHeaderField(req, source.fieldName!) : undefined; @@ -122,8 +123,8 @@ export default class SourceUtils { return value } - private getDetail(detailName: DetailName, record: Record, req?: any, res?: any): string | number | undefined { - let value: string | number | undefined; + private getDetail(detailName: DetailName, record: Record, req?: any, res?: any): RecordFieldValue | undefined { + let value: RecordFieldValue | undefined; switch (detailName as DetailName) { case DetailName.RequestReceivedAt: value = req ? new Date(req._receivedAt).toJSON() : undefined; @@ -157,8 +158,17 @@ export default class SourceUtils { case DetailName.Stacktrace: value = record.metadata.stacktrace break; + case DetailName.RawStacktrace: + value = record.metadata.rawStacktrace + break + case DetailName.ErrorMessage: + value = record.metadata.errorMessage + break; + case DetailName.ErrorName: + value = record.metadata.errorName + break; case DetailName.Level: - value = record.metadata.level + value = LevelUtils.getName(record.metadata.level) break; } return value; @@ -196,7 +206,7 @@ export default class SourceUtils { return undefined; } - private parseIntValue(value: string | number | boolean): number { + private parseIntValue(value: RecordFieldValue): number { switch (typeof value) { case 'string': return parseInt(value, 0) @@ -205,9 +215,10 @@ export default class SourceUtils { case 'boolean': return value ? 1 : 0 } + return 0 } - private parseFloatValue(value: string | number | boolean): number { + private parseFloatValue(value: RecordFieldValue): number { switch (typeof value) { case 'string': return parseFloat(value) @@ -216,9 +227,10 @@ export default class SourceUtils { case 'boolean': return value ? 1 : 0 } + return 0 } - private parseBooleanValue(value: string | number | boolean): boolean { + private parseBooleanValue(value: RecordFieldValue): boolean { return value === 'true' || value === 'TRUE' || value === 'True' || value === 1 || value === true } } diff --git a/src/lib/middleware/middleware.ts b/src/lib/middleware/middleware.ts index 6b84a54f..aa0e941b 100644 --- a/src/lib/middleware/middleware.ts +++ b/src/lib/middleware/middleware.ts @@ -1,13 +1,13 @@ import JWTService from '../helper/jwtService.js'; -import LevelUtils from '../helper/levelUtils.js'; +import { LevelUtils } from '../logger/level.js'; import { Logger } from '../logger/logger.js'; import RecordFactory from '../logger/recordFactory.js'; -import RecordWriter from '../logger/recordWriter.js'; import Context from '../logger/context.js'; import RootLogger from '../logger/rootLogger.js'; import RequestAccessor from './requestAccessor.js'; import Config from '../config/config.js'; import ResponseAccessor from './responseAccessor.js'; +import PluginRegistry from '../helper/pluginProvider.js'; export default class Middleware { static logNetwork(req: any, res: any, next?: any) { @@ -20,7 +20,7 @@ export default class Middleware { // initialize Logger with parent to set registered fields const networkLogger = new Logger(parentLogger, context); const jwtService = JWTService.getInstance(); - + const dynLogLevelHeader = jwtService.getDynLogLevelHeaderName(); const token = RequestAccessor.getInstance().getHeaderField(req, dynLogLevelHeader); if (token) { @@ -39,8 +39,8 @@ export default class Middleware { const level = LevelUtils.getLevel(levelName); const threshold = LevelUtils.getLevel(req.logger.getLoggingLevel()); if (LevelUtils.isLevelEnabled(threshold, level)) { - const record = RecordFactory.getInstance().buildReqRecord(levelName, req, res, context); - RecordWriter.getInstance().writeLog(record); + const record = RecordFactory.getInstance().buildReqRecord(level, req, res, context); + PluginRegistry.getInstance().getOutputPlugins().forEach(output => { output.writeRecord(record) }) } logSent = true; } diff --git a/src/lib/plugins/interfaces.ts b/src/lib/plugins/interfaces.ts new file mode 100644 index 00000000..ac098a3b --- /dev/null +++ b/src/lib/plugins/interfaces.ts @@ -0,0 +1,5 @@ +import { Record } from "../logger/record" + +export interface OutputPlugin { + writeRecord(record: Record): void +} diff --git a/src/lib/plugins/otelOutput.ts b/src/lib/plugins/otelOutput.ts new file mode 100644 index 00000000..fc1d1f43 --- /dev/null +++ b/src/lib/plugins/otelOutput.ts @@ -0,0 +1,97 @@ + +import { logs as logsAPI, Logger, LoggerProvider, SeverityNumber, LogAttributes} from '@opentelemetry/api-logs' +import { OutputPlugin } from './interfaces.js' +import { Record, RecordType } from '../logger/record.js' +import { Level } from '../logger/level.js' + +export class OpenTelemetryLogsOutputPlugin implements OutputPlugin { + private logger: Logger + private includeFieldsAsAttributes: FieldInclusionMode + + public constructor(loggerProvider?: LoggerProvider) { + if (loggerProvider) { + this.logger = loggerProvider.getLogger('default') + } else { + this.logger = logsAPI.getLoggerProvider().getLogger("default") + } + this.includeFieldsAsAttributes = FieldInclusionMode.CustomFieldsOnly + } + + public setIncludeFieldsAsAttributes(includeFieldsAsAttributes: FieldInclusionMode) { + this.includeFieldsAsAttributes = includeFieldsAsAttributes + } + + public writeRecord(record: Record): void { + if (record.metadata.type == RecordType.Request) { + return // ignore request logs + } + + const attributes = {} as LogAttributes + this.populateExceptionAttributes(record, attributes) + this.populateAdditionalAttributes(record, attributes) + + const severityNumber = this.mapLevelToSeverityNumber(record.metadata.level) + + this.logger.emit({ + severityNumber: severityNumber, + severityText: SeverityNumber[severityNumber], + body: record.metadata.message, + attributes: attributes + }) + } + + private mapLevelToSeverityNumber(level: Level): SeverityNumber { + switch (level) { + case Level.Error: + return SeverityNumber.ERROR + case Level.Warn: + return SeverityNumber.WARN + case Level.Info: + return SeverityNumber.INFO + case Level.Verbose: + case Level.Debug: + return SeverityNumber.DEBUG + case Level.Silly: + return SeverityNumber.TRACE + } + return SeverityNumber.UNSPECIFIED + } + + private populateExceptionAttributes(record: Record, attributes: LogAttributes) { + if (record.metadata.errorName) { + attributes["exception.type"] = record.metadata.errorName + } + if (record.metadata.errorMessage) { + attributes["exception.message"] = record.metadata.errorMessage + } + if (record.metadata.rawStacktrace) { + attributes["exception.stacktrace"] = record.metadata.rawStacktrace + } + } + + private populateAdditionalAttributes(record: Record, attributes: LogAttributes) { + switch(this.includeFieldsAsAttributes) { + case FieldInclusionMode.AllFields: + for (const key in record.payload) { + attributes[key] = record.payload[key] + } + break; + case FieldInclusionMode.CustomFieldsOnly: + for (const key of record.metadata.customFieldNames) { + if (record.payload[key] !== undefined) { + attributes[key] = record.payload[key] + } + } + break; + case FieldInclusionMode.None: + default: + return; + } + } +} + +export enum FieldInclusionMode { + AllFields = "all", + CustomFieldsOnly = "custom-fields", + None = "none" +} diff --git a/src/lib/plugins/stdoutOutput.ts b/src/lib/plugins/stdoutOutput.ts new file mode 100644 index 00000000..a642a63f --- /dev/null +++ b/src/lib/plugins/stdoutOutput.ts @@ -0,0 +1,23 @@ +import os from 'os'; +import { Record } from "../logger/record.js"; +import { OutputPlugin } from "./interfaces.js"; +import { LevelUtils } from '../logger/level.js'; + +export class StdoutOutputPlugin implements OutputPlugin { + + private sinkFunction: ((level: string, payload: string) => any) | undefined; + + writeRecord(record: Record): void { + const jsonStr: string = JSON.stringify(record.payload); + if (this.sinkFunction) { + const level: string = LevelUtils.getName(record.metadata.level); + this.sinkFunction(level, jsonStr); + } else { + process.stdout.write(jsonStr + os.EOL); + } + } + + setSinkFunction(callback: (level: string, payload: string) => any | undefined) { + this.sinkFunction = callback; + } +} diff --git a/src/test/unit-test/helper.test.js b/src/test/unit-test/helper.test.js index 98c473b3..f3ad3d1e 100644 --- a/src/test/unit-test/helper.test.js +++ b/src/test/unit-test/helper.test.js @@ -1,8 +1,7 @@ const { BUILD_CJS_LIB } = require('../paths'); const expect = require('chai').expect; const JSONHelper = require(`${BUILD_CJS_LIB}/helper/jsonHelper.js`).default; -const LevelUtils = require(`${BUILD_CJS_LIB}/helper/levelUtils.js`).default; -const { Level } = require(`${BUILD_CJS_LIB}/logger/level.js`); +const { Level, LevelUtils } = require(`${BUILD_CJS_LIB}/logger/level.js`); const EnvVarHelper = require(`${BUILD_CJS_LIB}/helper/envVarHelper.js`).default; const StacktraceUtils = require(`${BUILD_CJS_LIB}/helper/stacktraceUtils.js`).default;