Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions experimental/packages/sdk-logs/src/LogRecordImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions experimental/packages/sdk-logs/test/common/LogRecord.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/opentelemetry-core/src/common/exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

export function buildExceptionCauseChain(cause: unknown): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC if we could pass the top error here. The only issue would be that the 1st stack will start with the string \nCaused by: producing something like

'\nCaused by: 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'

with a couple of changes this function could get the top error and return the whole chain.

I wonder if there is a case where we want to have only the cause chain 🤔

const visited = new Set<unknown>();
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;
}
1 change: 1 addition & 0 deletions packages/opentelemetry-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions packages/opentelemetry-core/test/common/exception.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
6 changes: 5 additions & 1 deletion packages/opentelemetry-sdk-trace-base/src/Span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { diag, SpanStatusCode } from '@opentelemetry/api';
import type { InstrumentationScope } from '@opentelemetry/core';
import {
addHrTimes,
buildExceptionCauseChain,
millisToHrTime,
hrTime,
hrTimeDuration,
Expand Down Expand Up @@ -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;
}
}

Expand Down
55 changes: 55 additions & 0 deletions packages/opentelemetry-sdk-trace-base/test/common/Span.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading