diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index acfe42cca35..b342c0e3297 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -34,6 +34,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 * feat(sampler-composite): add ComposableAnnotatingSampler and ComposableRuleBasedSampler [#6305](https://github.com/open-telemetry/opentelemetry-js/pull/6305) @trentm * feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telemetry/opentelemetry-js/pull/6304) @maryliag * feat(instrumentation): use the `internals: true` option with import-in-the-middle hook, allowing instrumentations to hook internal files in ES modules [#6344](https://github.com/open-telemetry/opentelemetry-js/pull/6344) @trentm +* feat(api-logs,sdk-logs): add log exception support and mapping [#6379](https://github.com/open-telemetry/opentelemetry-js/issues/6379) @iblancasa ### :bug: Bug Fixes diff --git a/experimental/packages/api-logs/src/types/LogRecord.ts b/experimental/packages/api-logs/src/types/LogRecord.ts index 75855aadaa8..6002b8e0559 100644 --- a/experimental/packages/api-logs/src/types/LogRecord.ts +++ b/experimental/packages/api-logs/src/types/LogRecord.ts @@ -84,6 +84,13 @@ export interface LogRecord { */ attributes?: LogAttributes; + /** + * An exception (or error) associated with the log record. + * + * @experimental + */ + exception?: unknown; + /** * The Context associated with the LogRecord. */ diff --git a/experimental/packages/sdk-logs/package.json b/experimental/packages/sdk-logs/package.json index 45c00a14a0e..dac8041a329 100644 --- a/experimental/packages/sdk-logs/package.json +++ b/experimental/packages/sdk-logs/package.json @@ -94,6 +94,7 @@ "dependencies": { "@opentelemetry/api-logs": "0.212.0", "@opentelemetry/core": "2.5.1", - "@opentelemetry/resources": "2.5.1" + "@opentelemetry/resources": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" } } diff --git a/experimental/packages/sdk-logs/src/LogRecordImpl.ts b/experimental/packages/sdk-logs/src/LogRecordImpl.ts index 1c8cf60d75d..1e34780a02d 100644 --- a/experimental/packages/sdk-logs/src/LogRecordImpl.ts +++ b/experimental/packages/sdk-logs/src/LogRecordImpl.ts @@ -24,6 +24,11 @@ import type { import * as api from '@opentelemetry/api'; import { timeInputToHrTime, InstrumentationScope } from '@opentelemetry/core'; import type { Resource } from '@opentelemetry/resources'; +import { + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + ATTR_EXCEPTION_TYPE, +} from '@opentelemetry/semantic-conventions'; import type { ReadableLogRecord } from './export/ReadableLogRecord'; import type { LogRecordLimits } from './types'; import { isLogAttributeValue } from './utils/validation'; @@ -102,6 +107,7 @@ export class LogRecordImpl implements ReadableLogRecord { severityText, body, attributes = {}, + exception, context, } = logRecord; @@ -123,6 +129,9 @@ export class LogRecordImpl implements ReadableLogRecord { this._logRecordLimits = _sharedState.logRecordLimits; this._eventName = eventName; this.setAttributes(attributes); + if (exception != null) { + this._setException(exception); + } } public setAttribute(key: string, value?: AnyValue) { @@ -231,6 +240,54 @@ export class LogRecordImpl implements ReadableLogRecord { return value; } + private _setException(exception: unknown): void { + let hasMinimumAttributes = false; + + if (typeof exception === 'string' || typeof exception === 'number') { + if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_MESSAGE)) { + this.setAttribute(ATTR_EXCEPTION_MESSAGE, String(exception)); + } + hasMinimumAttributes = true; + } else if (exception && typeof exception === 'object') { + const exceptionObj = exception as { + code?: string | number; + name?: string; + message?: string; + stack?: string; + }; + + if (exceptionObj.code) { + if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_TYPE)) { + this.setAttribute(ATTR_EXCEPTION_TYPE, exceptionObj.code.toString()); + } + hasMinimumAttributes = true; + } else if (exceptionObj.name) { + if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_TYPE)) { + this.setAttribute(ATTR_EXCEPTION_TYPE, exceptionObj.name); + } + hasMinimumAttributes = true; + } + + if (exceptionObj.message) { + if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_MESSAGE)) { + this.setAttribute(ATTR_EXCEPTION_MESSAGE, exceptionObj.message); + } + hasMinimumAttributes = true; + } + + if (exceptionObj.stack) { + if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_STACKTRACE)) { + this.setAttribute(ATTR_EXCEPTION_STACKTRACE, exceptionObj.stack); + } + hasMinimumAttributes = true; + } + } + + if (!hasMinimumAttributes) { + api.diag.warn(`Failed to record an exception ${exception}`); + } + } + private _truncateToLimitUtil(value: string, limit: number): string { if (value.length <= limit) { return value; diff --git a/experimental/packages/sdk-logs/test/common/LogRecord.test.ts b/experimental/packages/sdk-logs/test/common/LogRecord.test.ts index 0b3bebd4534..ff44ba41cd2 100644 --- a/experimental/packages/sdk-logs/test/common/LogRecord.test.ts +++ b/experimental/packages/sdk-logs/test/common/LogRecord.test.ts @@ -29,6 +29,11 @@ import { AnyValue } from '@opentelemetry/api-logs'; import type { HrTime } from '@opentelemetry/api'; import { hrTimeToMilliseconds, timeInputToHrTime } from '@opentelemetry/core'; import { defaultResource } from '@opentelemetry/resources'; +import { + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + ATTR_EXCEPTION_TYPE, +} from '@opentelemetry/semantic-conventions'; import { LogRecordLimits, @@ -156,6 +161,86 @@ describe('LogRecord', () => { attr2: 123, }); }); + + it('should set exception attributes from exception', () => { + const error = new Error('boom'); + const logRecordData: logsAPI.LogRecord = { + exception: error, + }; + const { logRecord } = setup(undefined, logRecordData); + + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_MESSAGE], + error.message + ); + assert.strictEqual(logRecord.attributes[ATTR_EXCEPTION_TYPE], error.name); + if (error.stack) { + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_STACKTRACE], + error.stack + ); + } + }); + + it('should not overwrite user-provided exception attributes', () => { + const error = new Error('boom'); + const logRecordData: logsAPI.LogRecord = { + exception: error, + attributes: { + [ATTR_EXCEPTION_MESSAGE]: 'user message', + [ATTR_EXCEPTION_TYPE]: 'CustomError', + }, + }; + const { logRecord } = setup(undefined, logRecordData); + + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_MESSAGE], + 'user message' + ); + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_TYPE], + 'CustomError' + ); + }); + + it('should set exception.message for string exceptions', () => { + const logRecordData: logsAPI.LogRecord = { + exception: 'boom', + }; + const { logRecord } = setup(undefined, logRecordData); + + assert.strictEqual(logRecord.attributes[ATTR_EXCEPTION_MESSAGE], 'boom'); + }); + + it('should set exception.message for numeric exceptions', () => { + const logRecordData: logsAPI.LogRecord = { + exception: 42, + }; + const { logRecord } = setup(undefined, logRecordData); + + assert.strictEqual(logRecord.attributes[ATTR_EXCEPTION_MESSAGE], '42'); + }); + + it('should warn when exception has no useful fields', () => { + const warnSpy = sinon.stub(diag, 'warn'); + const logRecordData: logsAPI.LogRecord = { + exception: {} as unknown, + }; + + setup(undefined, logRecordData); + + assert.ok(warnSpy.calledOnce); + warnSpy.restore(); + }); + + it('should set exception.type from code', () => { + const logRecordData: logsAPI.LogRecord = { + exception: { code: 12 }, + }; + const { logRecord } = setup(undefined, logRecordData); + + assert.strictEqual(logRecord.attributes[ATTR_EXCEPTION_TYPE], '12'); + }); }); describe('setAttribute', () => { diff --git a/experimental/packages/sdk-logs/tsconfig.esm.json b/experimental/packages/sdk-logs/tsconfig.esm.json index 7b44ba766b3..131e715221e 100644 --- a/experimental/packages/sdk-logs/tsconfig.esm.json +++ b/experimental/packages/sdk-logs/tsconfig.esm.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../semantic-conventions" + }, { "path": "../api-logs" } diff --git a/experimental/packages/sdk-logs/tsconfig.esnext.json b/experimental/packages/sdk-logs/tsconfig.esnext.json index 193624c1613..52e36313312 100644 --- a/experimental/packages/sdk-logs/tsconfig.esnext.json +++ b/experimental/packages/sdk-logs/tsconfig.esnext.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../semantic-conventions" + }, { "path": "../api-logs" } diff --git a/experimental/packages/sdk-logs/tsconfig.json b/experimental/packages/sdk-logs/tsconfig.json index 14fb2c7c577..392e6603dcf 100644 --- a/experimental/packages/sdk-logs/tsconfig.json +++ b/experimental/packages/sdk-logs/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../semantic-conventions" + }, { "path": "../api-logs" } diff --git a/package-lock.json b/package-lock.json index 5af17a42713..2768a25e072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1293,7 +1293,8 @@ "dependencies": { "@opentelemetry/api-logs": "0.212.0", "@opentelemetry/core": "2.5.1", - "@opentelemetry/resources": "2.5.1" + "@opentelemetry/resources": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "devDependencies": { "@babel/core": "7.27.1",