diff --git a/package-lock.json b/package-lock.json index b9c6bbfc..903e5c30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "8.0.0", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.218.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", @@ -18,6 +19,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/json-stringify-safe": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.6.0", @@ -1080,9 +1084,9 @@ } }, "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==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -1100,6 +1104,99 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1152,29 +1249,6 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1190,42 +1264,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1249,14 +1287,6 @@ "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1274,47 +1304,6 @@ "undici-types": "~7.19.0" } }, - "node_modules/@types/qs": { - "version": "6.9.12", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.12.tgz", - "integrity": "sha512-bZcOkJ6uWrL0Qb2NAWKa7TBU+mJHPzhx9jjLL1KHF+XpzEcR7EXHvjbHlGtR/IsP1vyPrehuS6XqkmaePy//mg==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" - } - }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", diff --git a/package.json b/package.json index ef32dca8..5d2996c3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/json-stringify-safe": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.6.0", @@ -75,6 +78,7 @@ "README.md" ], "dependencies": { + "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.218.0", "ajv": "^8.18.0", "json-stringify-safe": "^5.0.1", diff --git a/src/lib/plugins/otelOutput.ts b/src/lib/plugins/otelOutput.ts index fc1d1f43..685ddc4c 100644 --- a/src/lib/plugins/otelOutput.ts +++ b/src/lib/plugins/otelOutput.ts @@ -1,20 +1,25 @@ - +import type { Context } from '@opentelemetry/api' 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 type OpenTelemetryLogContextResolver = (record: Record) => Context | undefined +export type OpenTelemetryLogContext = Context | OpenTelemetryLogContextResolver + export class OpenTelemetryLogsOutputPlugin implements OutputPlugin { private logger: Logger private includeFieldsAsAttributes: FieldInclusionMode + private context?: OpenTelemetryLogContext - public constructor(loggerProvider?: LoggerProvider) { + public constructor(loggerProvider?: LoggerProvider, context?: OpenTelemetryLogContext) { if (loggerProvider) { this.logger = loggerProvider.getLogger('default') } else { this.logger = logsAPI.getLoggerProvider().getLogger("default") } this.includeFieldsAsAttributes = FieldInclusionMode.CustomFieldsOnly + this.context = context } public setIncludeFieldsAsAttributes(includeFieldsAsAttributes: FieldInclusionMode) { @@ -31,15 +36,24 @@ export class OpenTelemetryLogsOutputPlugin implements OutputPlugin { this.populateAdditionalAttributes(record, attributes) const severityNumber = this.mapLevelToSeverityNumber(record.metadata.level) + const context = this.resolveContext(record) this.logger.emit({ severityNumber: severityNumber, severityText: SeverityNumber[severityNumber], body: record.metadata.message, - attributes: attributes + attributes: attributes, + ...(context && { context }) }) } + private resolveContext(record: Record): Context | undefined { + if (typeof this.context === 'function') { + return this.context(record) + } + return this.context + } + private mapLevelToSeverityNumber(level: Level): SeverityNumber { switch (level) { case Level.Error: diff --git a/src/test/unit-test/otel-output.test.js b/src/test/unit-test/otel-output.test.js new file mode 100644 index 00000000..555dde7f --- /dev/null +++ b/src/test/unit-test/otel-output.test.js @@ -0,0 +1,118 @@ +const { context, SpanKind, trace, TraceFlags } = require('@opentelemetry/api'); +const { SeverityNumber } = require('@opentelemetry/api-logs'); +const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks'); +const { + InMemoryLogRecordExporter, + LoggerProvider, + SimpleLogRecordProcessor +} = require('@opentelemetry/sdk-logs'); +const { BasicTracerProvider } = require('@opentelemetry/sdk-trace-base'); +const { expect } = require('chai'); + +const { BUILD_CJS_LIB } = require('../paths'); +const { Level } = require(`${BUILD_CJS_LIB}/logger/level.js`); +const { Record, RecordType } = require(`${BUILD_CJS_LIB}/logger/record.js`); +const { + FieldInclusionMode, + OpenTelemetryLogsOutputPlugin +} = require(`${BUILD_CJS_LIB}/plugins/otelOutput.js`); + +describe('OpenTelemetryLogsOutputPlugin', function () { + let contextManager; + let exporter; + let loggerProvider; + let tracerProvider; + + beforeEach(function () { + contextManager = new AsyncLocalStorageContextManager().enable(); + context.setGlobalContextManager(contextManager); + exporter = new InMemoryLogRecordExporter(); + loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)] + }); + }); + + afterEach(async function () { + await loggerProvider.shutdown(); + if (tracerProvider) { + await tracerProvider.shutdown(); + } + context.disable(); + contextManager.disable(); + }); + + it('uses the active context when no context is configured', function () { + const plugin = new OpenTelemetryLogsOutputPlugin(loggerProvider); + const nonRecordingSpanContext = { + traceId: '11111111111111111111111111111111', + spanId: '2222222222222222', + traceFlags: TraceFlags.NONE + }; + const activeContext = trace.setSpanContext(context.active(), nonRecordingSpanContext); + + context.with(activeContext, () => plugin.writeRecord(createRecord())); + + const [logRecord] = exporter.getFinishedLogRecords(); + expect(logRecord.spanContext).to.deep.equal(nonRecordingSpanContext); + expect(trace.getSpan(activeContext).isRecording()).to.equal(false); + }); + + it('uses an explicitly provided context', function () { + const explicitSpanContext = { + traceId: '33333333333333333333333333333333', + spanId: '4444444444444444', + traceFlags: TraceFlags.SAMPLED + }; + const explicitContext = trace.setSpanContext(context.active(), explicitSpanContext); + const plugin = new OpenTelemetryLogsOutputPlugin(loggerProvider, explicitContext); + + plugin.writeRecord(createRecord()); + + const [logRecord] = exporter.getFinishedLogRecords(); + expect(logRecord.spanContext).to.deep.equal(explicitSpanContext); + }); + + it('uses a resolved recording server context while preserving log fields', function () { + tracerProvider = new BasicTracerProvider(); + const serverSpan = tracerProvider.getTracer('test').startSpan('request', { + kind: SpanKind.SERVER + }); + const serverContext = trace.setSpan(context.active(), serverSpan); + const droppedSpanContext = { + traceId: '55555555555555555555555555555555', + spanId: '6666666666666666', + traceFlags: TraceFlags.NONE + }; + const droppedContext = trace.setSpanContext(context.active(), droppedSpanContext); + const record = createRecord(); + let resolvedRecord; + const plugin = new OpenTelemetryLogsOutputPlugin(loggerProvider, currentRecord => { + resolvedRecord = currentRecord; + return serverContext; + }); + plugin.setIncludeFieldsAsAttributes(FieldInclusionMode.CustomFieldsOnly); + + context.with(droppedContext, () => plugin.writeRecord(record)); + + const [logRecord] = exporter.getFinishedLogRecords(); + expect(resolvedRecord).to.equal(record); + expect(serverSpan.isRecording()).to.equal(true); + expect(logRecord.spanContext).to.deep.equal(serverSpan.spanContext()); + expect(logRecord.spanContext.spanId).not.to.equal(droppedSpanContext.spanId); + expect(logRecord.severityNumber).to.equal(SeverityNumber.WARN); + expect(logRecord.severityText).to.equal('WARN'); + expect(logRecord.body).to.equal('context test'); + expect(logRecord.attributes).to.deep.equal({ custom: 'value' }); + + serverSpan.end(); + }); + + function createRecord() { + const record = new Record(RecordType.Message, Level.Warn); + record.metadata.message = 'context test'; + record.metadata.customFieldNames.push('custom'); + record.payload.custom = 'value'; + record.payload.internal = 'not included'; + return record; + } +});