diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a45c4c689..26ffe75c822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(opentelemetry-core,sdk-trace-base): append `exception.cause` chain to `ATTR_EXCEPTION_STACKTRACE` [#6423](https://github.com/open-telemetry/opentelemetry-js/issues/6423) @abhisheksurve45 + ### :bug: Bug Fixes ### :books: Documentation diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 5ebf2bd8c14..0e2841e6404 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -10,6 +10,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(sdk-logs): append `exception.cause` chain to `ATTR_EXCEPTION_STACKTRACE` [#6423](https://github.com/open-telemetry/opentelemetry-js/issues/6423) @abhisheksurve45 * feat(sdk-node): wire attribute_keys from declarative configuration to ViewOptions.attributesProcessors [#6427](https://github.com/open-telemetry/opentelemetry-js/issues/6427) @ravitheja4531-cell * feat(sdk-node): set TracerProvider in startNodeSDK() [#6607](https://github.com/open-telemetry/opentelemetry-js/pull/6607) @maryliag diff --git a/experimental/packages/sdk-logs/src/LogRecordImpl.ts b/experimental/packages/sdk-logs/src/LogRecordImpl.ts index c71f2646023..6bbb3cde051 100644 --- a/experimental/packages/sdk-logs/src/LogRecordImpl.ts +++ b/experimental/packages/sdk-logs/src/LogRecordImpl.ts @@ -12,7 +12,7 @@ import type { } from '@opentelemetry/api-logs'; import * as api from '@opentelemetry/api'; import type { InstrumentationScope } from '@opentelemetry/core'; -import { timeInputToHrTime } from '@opentelemetry/core'; +import { buildExceptionCauseChain, timeInputToHrTime } from '@opentelemetry/core'; import type { Resource } from '@opentelemetry/resources'; import { ATTR_EXCEPTION_MESSAGE, @@ -274,7 +274,13 @@ export class LogRecordImpl implements ReadableLogRecord { if (exceptionObj.stack) { if (!Object.hasOwn(this.attributes, ATTR_EXCEPTION_STACKTRACE)) { - this.setAttribute(ATTR_EXCEPTION_STACKTRACE, exceptionObj.stack); + const causeChain = buildExceptionCauseChain( + (exceptionObj as { cause?: unknown }).cause + ); + this.setAttribute( + ATTR_EXCEPTION_STACKTRACE, + exceptionObj.stack + causeChain + ); } hasMinimumAttributes = true; } diff --git a/experimental/packages/sdk-logs/test/common/LogRecord.test.ts b/experimental/packages/sdk-logs/test/common/LogRecord.test.ts index 649604a7f2d..4f7cc8ba11a 100644 --- a/experimental/packages/sdk-logs/test/common/LogRecord.test.ts +++ b/experimental/packages/sdk-logs/test/common/LogRecord.test.ts @@ -220,6 +220,39 @@ describe('LogRecord', () => { assert.strictEqual(logRecord.attributes[ATTR_EXCEPTION_TYPE], '12'); }); + + it('should append cause stack to stacktrace', () => { + const cause = new Error('inner'); + cause.stack = 'Error: inner\n at inner:1:1'; + const outer = new Error('outer') as Error & { cause?: unknown }; + outer.stack = 'Error: outer\n at outer:1:1'; + outer.cause = cause; + + const { logRecord } = setup(undefined, { exception: outer }); + + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_STACKTRACE], + 'Error: outer\n at outer:1:1\nCaused by: Error: inner\n at inner:1:1' + ); + }); + + it('should walk a multi-level cause chain in stacktrace', () => { + const root = new Error('root') as any; + root.stack = 'Error: root\n at root:1:1'; + const mid = new Error('mid') as any; + mid.stack = 'Error: mid\n at mid:1:1'; + mid.cause = root; + const top = new Error('top') as any; + top.stack = 'Error: top\n at top:1:1'; + top.cause = mid; + + const { logRecord } = setup(undefined, { exception: top }); + + assert.strictEqual( + logRecord.attributes[ATTR_EXCEPTION_STACKTRACE], + 'Error: top\n at top:1:1\nCaused by: Error: mid\n at mid:1:1\nCaused by: Error: root\n at root:1:1' + ); + }); }); describe('setAttribute', () => { diff --git a/packages/opentelemetry-core/src/common/exception.ts b/packages/opentelemetry-core/src/common/exception.ts new file mode 100644 index 00000000000..a52ba4d6052 --- /dev/null +++ b/packages/opentelemetry-core/src/common/exception.ts @@ -0,0 +1,19 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function buildExceptionCauseChain(cause: unknown): string { + const visited = new Set(); + let result = ''; + let current = cause; + while (current && typeof current === 'object' && !visited.has(current)) { + visited.add(current); + const c = current as { stack?: string; cause?: unknown }; + if (c.stack) { + result += `\nCaused by: ${c.stack}`; + } + current = c.cause; + } + return result; +} diff --git a/packages/opentelemetry-core/src/index.ts b/packages/opentelemetry-core/src/index.ts index 81456120b4a..26c170169a1 100644 --- a/packages/opentelemetry-core/src/index.ts +++ b/packages/opentelemetry-core/src/index.ts @@ -7,6 +7,7 @@ export { W3CBaggagePropagator } from './baggage/propagation/W3CBaggagePropagator export { AnchoredClock } from './common/anchored-clock'; export type { Clock } from './common/anchored-clock'; export { isAttributeValue, sanitizeAttributes } from './common/attributes'; +export { buildExceptionCauseChain } from './common/exception'; export { globalErrorHandler, setGlobalErrorHandler, diff --git a/packages/opentelemetry-core/test/common/exception.test.ts b/packages/opentelemetry-core/test/common/exception.test.ts new file mode 100644 index 00000000000..948591170cd --- /dev/null +++ b/packages/opentelemetry-core/test/common/exception.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert'; +import { buildExceptionCauseChain } from '../../src/common/exception'; + +describe('exception', () => { + describe('#buildExceptionCauseChain', () => { + it('should return empty string when cause is undefined', () => { + assert.strictEqual(buildExceptionCauseChain(undefined), ''); + }); + + it('should return empty string when cause is null', () => { + assert.strictEqual(buildExceptionCauseChain(null), ''); + }); + + it('should return empty string when cause is a primitive', () => { + assert.strictEqual(buildExceptionCauseChain('string cause'), ''); + assert.strictEqual(buildExceptionCauseChain(42), ''); + }); + + it('should append cause stack when cause has a stack', () => { + const cause = new Error('inner error'); + cause.stack = 'Error: inner error\n at inner:1:1'; + const result = buildExceptionCauseChain(cause); + assert.strictEqual( + result, + '\nCaused by: Error: inner error\n at inner:1:1' + ); + }); + + it('should skip cause silently when cause has no stack', () => { + const cause = { message: 'no stack here' }; + const result = buildExceptionCauseChain(cause); + assert.strictEqual(result, ''); + }); + + it('should walk a multi-level cause chain', () => { + const root = new Error('root'); + root.stack = 'Error: root\n at root:1:1'; + + const mid = new Error('mid'); + mid.stack = 'Error: mid\n at mid:1:1'; + (mid as any).cause = root; + + const result = buildExceptionCauseChain(mid); + assert.strictEqual( + result, + '\nCaused by: Error: mid\n at mid:1:1\nCaused by: Error: root\n at root:1:1' + ); + }); + + it('should terminate on circular cause references without infinite loop', () => { + const a: any = new Error('a'); + a.stack = 'Error: a\n at a:1:1'; + const b: any = new Error('b'); + b.stack = 'Error: b\n at b:1:1'; + a.cause = b; + b.cause = a; + + const result = buildExceptionCauseChain(a); + assert.strictEqual( + result, + '\nCaused by: Error: a\n at a:1:1\nCaused by: Error: b\n at b:1:1' + ); + }); + }); +}); diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index 7dab28618c2..e78e3b55c23 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -21,6 +21,7 @@ import { diag, SpanStatusCode } from '@opentelemetry/api'; import type { InstrumentationScope } from '@opentelemetry/core'; import { addHrTimes, + buildExceptionCauseChain, millisToHrTime, hrTime, hrTimeDuration, @@ -432,7 +433,10 @@ export class SpanImpl implements Span { attributes[ATTR_EXCEPTION_MESSAGE] = exception.message; } if (exception.stack) { - attributes[ATTR_EXCEPTION_STACKTRACE] = exception.stack; + const causeChain = buildExceptionCauseChain( + (exception as { cause?: unknown }).cause + ); + attributes[ATTR_EXCEPTION_STACKTRACE] = exception.stack + causeChain; } } diff --git a/packages/opentelemetry-sdk-trace-base/test/common/Span.test.ts b/packages/opentelemetry-sdk-trace-base/test/common/Span.test.ts index c3d68f4ab0e..2c8c5415947 100644 --- a/packages/opentelemetry-sdk-trace-base/test/common/Span.test.ts +++ b/packages/opentelemetry-sdk-trace-base/test/common/Span.test.ts @@ -1919,6 +1919,61 @@ describe('Span', () => { }); }); + describe('when exception has a cause', () => { + it('should append cause stack to stacktrace', () => { + const span = new SpanImpl({ + scope: tracer.instrumentationScope, + resource: tracer['_resource'], + context: ROOT_CONTEXT, + spanContext, + name, + kind: SpanKind.CLIENT, + spanLimits: tracer.getSpanLimits(), + spanProcessor: tracer['_spanProcessor'], + }); + const cause = new Error('inner'); + cause.stack = 'Error: inner\n at inner:1:1'; + const outer = new Error('outer') as Error & { cause?: unknown }; + outer.stack = 'Error: outer\n at outer:1:1'; + outer.cause = cause; + + span.recordException(outer); + const event = span.events[0]; + assert.strictEqual( + event.attributes?.[ATTR_EXCEPTION_STACKTRACE], + 'Error: outer\n at outer:1:1\nCaused by: Error: inner\n at inner:1:1' + ); + }); + + it('should walk a multi-level cause chain', () => { + const span = new SpanImpl({ + scope: tracer.instrumentationScope, + resource: tracer['_resource'], + context: ROOT_CONTEXT, + spanContext, + name, + kind: SpanKind.CLIENT, + spanLimits: tracer.getSpanLimits(), + spanProcessor: tracer['_spanProcessor'], + }); + const root = new Error('root') as any; + root.stack = 'Error: root\n at root:1:1'; + const mid = new Error('mid') as any; + mid.stack = 'Error: mid\n at mid:1:1'; + mid.cause = root; + const top = new Error('top') as any; + top.stack = 'Error: top\n at top:1:1'; + top.cause = mid; + + span.recordException(top); + const event = span.events[0]; + assert.strictEqual( + event.attributes?.[ATTR_EXCEPTION_STACKTRACE], + 'Error: top\n at top:1:1\nCaused by: Error: mid\n at mid:1:1\nCaused by: Error: root\n at root:1:1' + ); + }); + }); + describe('when attributes are specified', () => { it('should store specified attributes', () => { const span = new SpanImpl({