From 74e77a308ed8faa475630bd2954739f299e88fa3 Mon Sep 17 00:00:00 2001 From: Christian Dinse Date: Mon, 11 Mar 2024 13:39:32 +0100 Subject: [PATCH 1/2] Implement OpenTelemetry plugin --- .gitignore | 2 +- package-lock.json | 39 ++++++++- package.json | 3 +- src/index.ts | 4 + 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 | 21 ++++- src/lib/logger/sourceUtils.ts | 44 ++++++---- src/lib/middleware/middleware.ts | 10 +-- src/lib/plugins/defaultOutput.ts | 23 ++++++ src/lib/plugins/interfaces.ts | 5 ++ src/lib/plugins/otelOutput.ts | 97 +++++++++++++++++++++++ 19 files changed, 340 insertions(+), 126 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/defaultOutput.ts create mode 100644 src/lib/plugins/interfaces.ts create mode 100644 src/lib/plugins/otelOutput.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 92765e65..930ba383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "cf-nodejs-logging-support", - "version": "7.4.6", + "version": "7.5.6-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cf-nodejs-logging-support", - "version": "7.4.6", + "version": "7.5.6-beta.0", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/api-logs": "^0.51.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.3", @@ -972,6 +973,27 @@ "node": ">= 8" } }, + "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.51.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.51.1.tgz", + "integrity": "sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -9130,6 +9152,19 @@ "fastq": "^1.6.0" } }, + "@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" + }, + "@opentelemetry/api-logs": { + "version": "0.51.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.51.1.tgz", + "integrity": "sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA==", + "requires": { + "@opentelemetry/api": "^1.0.0" + } + }, "@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", diff --git a/package.json b/package.json index 2a32cc91..ba71c989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cf-nodejs-logging-support", - "version": "7.4.6", + "version": "7.5.6-beta.0", "description": "Logging tool for Cloud Foundry", "keywords": [ "logging", @@ -61,6 +61,7 @@ "README.md" ], "dependencies": { + "@opentelemetry/api-logs": "^0.51.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 4418c205..cfd2413b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,9 @@ exports = module.exports; // re-assign exports export default rootLogger; export * from "./lib/config/interfaces"; +export * from "./lib/logger/record"; export * from "./lib/logger/level"; export * from "./lib/logger/logger"; +export * from "./lib/plugins/interfaces"; +export * from "./lib/plugins/defaultOutput"; +export * from "./lib/plugins/otelOutput"; diff --git a/src/lib/config/default/config-schema.json b/src/lib/config/default/config-schema.json index de8407ff..9adc3cc9 100644 --- a/src/lib/config/default/config-schema.json +++ b/src/lib/config/default/config-schema.json @@ -114,8 +114,11 @@ }, "DetailName": { "enum": [ + "errorMessage", + "errorName", "level", "message", + "rawStacktrace", "requestReceivedAt", "responseSentAt", "responseTimeMs", diff --git a/src/lib/config/interfaces.ts b/src/lib/config/interfaces.ts index 59c6a91b..6a2fd8f7 100644 --- a/src/lib/config/interfaces.ts +++ b/src/lib/config/interfaces.ts @@ -86,6 +86,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 72984e74..00000000 --- a/src/lib/helper/levelUtils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Level } from '../logger/level'; - -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..9672ba12 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 b1085362..53cffbbc 100644 --- a/src/lib/logger/logger.ts +++ b/src/lib/logger/logger.ts @@ -1,9 +1,8 @@ -import LevelUtils from '../helper/levelUtils'; import { isValidObject } from '../middleware/utils'; -import { Level } from './level'; +import { Level, LevelUtils } from './level'; import RecordFactory from './recordFactory'; -import RecordWriter from './recordWriter'; import Context from './context'; +import PluginProvider from '../helper/pluginProvider'; 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 ff541f62..7bb34884 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'; import { isValidObject } from '../middleware/utils'; import Cache from './cache'; -import Record from './record'; +import { Record, RecordType } from './record'; import Context from './context'; import SourceUtils from './sourceUtils'; +import { Level } from './level'; 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(); - let record = new Record(levelName) - - + let 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 { - let record = new Record(levelName) + buildReqRecord(level: Level, req: any, res: any, context: Context): Record { + let 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 e2e6d095..00000000 --- a/src/lib/logger/recordWriter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import os from 'os'; - -import Record from './record'; - -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 { - let 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 ce07472f..ab894f78 100644 --- a/src/lib/logger/rootLogger.ts +++ b/src/lib/logger/rootLogger.ts @@ -6,18 +6,23 @@ import EnvService from '../helper/envService'; import Middleware from '../middleware/middleware'; import RequestAccessor from '../middleware/requestAccessor'; import ResponseAccessor from '../middleware/responseAccessor'; +import { DefaultOutput } from '../plugins/defaultOutput'; +import { OutputPlugin } from '../plugins/interfaces'; +import PluginProvider from '../helper/pluginProvider'; import createTransport from '../winston/winstonTransport'; import { Level } from './level'; import { Logger } from './logger'; -import RecordWriter from './recordWriter'; export default class RootLogger extends Logger { private static instance: RootLogger; + private defaultOutput: DefaultOutput; private config = Config.getInstance(); private constructor() { super() this.loggingLevelThreshold = Level.Info + this.defaultOutput = new DefaultOutput(); + PluginProvider.getInstance().setOutputPlugins([this.defaultOutput]); } static getInstance(): RootLogger { @@ -57,7 +62,19 @@ export default class RootLogger extends Logger { } setSinkFunction(func: (level: string, payload: string) => any) { - RecordWriter.getInstance().setSinkFunction(func); + this.defaultOutput.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 9a386a07..c119760f 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'; import RequestAccessor from '../middleware/requestAccessor'; import ResponseAccessor from '../middleware/responseAccessor'; -import Record from './record'; +import { Record, RecordFieldValue } from './record'; +import { LevelUtils } from '../logger/level'; 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 let sources = Array.isArray(field.source) ? field.source : [field.source] - let value: string | number | boolean | undefined; + let value: RecordFieldValue | undefined; let sourceIndex = 0; @@ -57,19 +58,19 @@ export default class SourceUtils { } if (value != null && field.convert != null) { - switch(field.convert) { + switch (field.convert) { case Conversion.ToString: value = value.toString ? value.toString() : undefined - break; + break; case Conversion.ParseBoolean: value = this.parseBooleanValue(value) - break; + break; case Conversion.ParseInt: value = this.parseIntValue(value) - break; + break; case Conversion.ParseFloat: value = this.parseFloatValue(value) - break; + break; } } @@ -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; @@ -121,8 +122,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; @@ -155,8 +156,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; @@ -194,7 +204,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) @@ -203,9 +213,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) @@ -214,9 +225,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 f925cc1d..9a08fbb1 100644 --- a/src/lib/middleware/middleware.ts +++ b/src/lib/middleware/middleware.ts @@ -1,13 +1,13 @@ import JWTService from '../helper/jwtService'; -import LevelUtils from '../helper/levelUtils'; +import { LevelUtils } from '../logger/level'; import { Logger } from '../logger/logger'; import RecordFactory from '../logger/recordFactory'; -import RecordWriter from '../logger/recordWriter'; import Context from '../logger/context'; import RootLogger from '../logger/rootLogger'; import RequestAccessor from './requestAccessor'; import Config from '../config/config'; import ResponseAccessor from './responseAccessor'; +import PluginRegistry from '../helper/pluginProvider'; export default class Middleware { @@ -21,7 +21,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) { @@ -40,8 +40,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/defaultOutput.ts b/src/lib/plugins/defaultOutput.ts new file mode 100644 index 00000000..db8170bb --- /dev/null +++ b/src/lib/plugins/defaultOutput.ts @@ -0,0 +1,23 @@ +import os from 'os'; +import { Record } from "../logger/record"; +import { OutputPlugin } from "./interfaces"; +import { LevelUtils } from '../logger/level'; + +export class DefaultOutput implements OutputPlugin { + + private sinkFunction: ((level: string, payload: string) => any) | undefined; + + writeRecord(record: Record): void { + let jsonStr: string = JSON.stringify(record.payload); + if (this.sinkFunction) { + let 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/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..39e1c5d2 --- /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' +import { Record, RecordType } from '../logger/record' +import { Level } from '../logger/level' + +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 + } + + let attributes = {} as LogAttributes + this.populateExceptionAttributes(record, attributes) + this.populateAdditionalAttributes(record, attributes) + + let 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 (let key in record.payload) { + attributes[key] = record.payload[key] + } + break; + case FieldInclusionMode.CustomFieldsOnly: + for (let 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" +} From e16979fd1bf5eb31482e98a08e08d1dfa4c61a81 Mon Sep 17 00:00:00 2001 From: Christian Dinse Date: Wed, 18 Feb 2026 11:25:41 +0100 Subject: [PATCH 2/2] Bump @opentelemetry/api-logs to version 0.212.0 --- package-lock.json | 20 ++++++++++---------- package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 930ba383..ac9b76a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "7.5.6-beta.0", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.51.0", + "@opentelemetry/api-logs": "^0.212.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.3", @@ -983,15 +983,15 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.51.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.51.1.tgz", - "integrity": "sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA==", + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", + "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.0.0" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=14" + "node": ">=8.0.0" } }, "node_modules/@paralleldrive/cuid2": { @@ -9158,11 +9158,11 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" }, "@opentelemetry/api-logs": { - "version": "0.51.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.51.1.tgz", - "integrity": "sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA==", + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", + "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", "requires": { - "@opentelemetry/api": "^1.0.0" + "@opentelemetry/api": "^1.3.0" } }, "@paralleldrive/cuid2": { diff --git a/package.json b/package.json index ba71c989..b6a25f50 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "README.md" ], "dependencies": { - "@opentelemetry/api-logs": "^0.51.0", + "@opentelemetry/api-logs": "^0.212.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.3",