diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 7adc0ee31e5f..5a0b47358ed2 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -1,12 +1,12 @@ import type { Context } from '@opentelemetry/api'; import { SpanKind, trace } from '@opentelemetry/api'; import type { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { addGlobalEventProcessor, addTracingExtensions, getCurrentHub, Transaction } from '@sentry/core'; import type { DynamicSamplingContext, Span as SentrySpan, TraceparentData, TransactionContext } from '@sentry/types'; -import { isString, logger } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import { SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, SENTRY_TRACE_PARENT_CONTEXT_KEY } from './constants'; +import { maybeCaptureExceptionForTimedEvent } from './utils/captureExceptionForTimedEvent'; import { isSentryRequestSpan } from './utils/isSentryRequest'; import { mapOtelStatus } from './utils/mapOtelStatus'; import { parseSpanDescription } from './utils/parseOtelSpanDescription'; @@ -109,44 +109,9 @@ export class SentrySpanProcessor implements OtelSpanProcessor { return; } + const hub = getCurrentHub(); otelSpan.events.forEach(event => { - if (event.name !== 'exception') { - return; - } - - const attributes = event.attributes; - if (!attributes) { - return; - } - - const message = attributes[SemanticAttributes.EXCEPTION_MESSAGE]; - const syntheticError = new Error(message as string | undefined); - - const stack = attributes[SemanticAttributes.EXCEPTION_STACKTRACE]; - if (isString(stack)) { - syntheticError.stack = stack; - } - - const type = attributes[SemanticAttributes.EXCEPTION_TYPE]; - if (isString(type)) { - syntheticError.name = type; - } - - getCurrentHub().captureException(syntheticError, { - captureContext: { - contexts: { - otel: { - attributes: otelSpan.attributes, - resource: otelSpan.resource.attributes, - }, - trace: { - trace_id: otelSpan.spanContext().traceId, - span_id: otelSpan.spanContext().spanId, - parent_span_id: otelSpan.parentSpanId, - }, - }, - }, - }); + maybeCaptureExceptionForTimedEvent(hub, event, otelSpan); }); if (sentrySpan instanceof Transaction) { diff --git a/packages/opentelemetry-node/src/utils/captureExceptionForTimedEvent.ts b/packages/opentelemetry-node/src/utils/captureExceptionForTimedEvent.ts new file mode 100644 index 000000000000..3dde27e49e9f --- /dev/null +++ b/packages/opentelemetry-node/src/utils/captureExceptionForTimedEvent.ts @@ -0,0 +1,55 @@ +import type { Span as OtelSpan, TimedEvent } from '@opentelemetry/sdk-trace-base'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import type { Hub } from '@sentry/types'; +import { isString } from '@sentry/utils'; + +/** + * Maybe capture a Sentry exception for an OTEL timed event. + * This will check if the event is exception-like and in that case capture it as an exception. + */ +export function maybeCaptureExceptionForTimedEvent(hub: Hub, event: TimedEvent, otelSpan?: OtelSpan): void { + if (event.name !== 'exception') { + return; + } + + const attributes = event.attributes; + if (!attributes) { + return; + } + + const message = attributes[SemanticAttributes.EXCEPTION_MESSAGE]; + + if (typeof message !== 'string') { + return; + } + + const syntheticError = new Error(message); + + const stack = attributes[SemanticAttributes.EXCEPTION_STACKTRACE]; + if (isString(stack)) { + syntheticError.stack = stack; + } + + const type = attributes[SemanticAttributes.EXCEPTION_TYPE]; + if (isString(type)) { + syntheticError.name = type; + } + + hub.captureException(syntheticError, { + captureContext: otelSpan + ? { + contexts: { + otel: { + attributes: otelSpan.attributes, + resource: otelSpan.resource.attributes, + }, + trace: { + trace_id: otelSpan.spanContext().traceId, + span_id: otelSpan.spanContext().spanId, + parent_span_id: otelSpan.parentSpanId, + }, + }, + } + : undefined, + }); +} diff --git a/packages/opentelemetry-node/test/utils/captureExceptionForTimedEvent.test.ts b/packages/opentelemetry-node/test/utils/captureExceptionForTimedEvent.test.ts new file mode 100644 index 000000000000..4d0c39b3a8b9 --- /dev/null +++ b/packages/opentelemetry-node/test/utils/captureExceptionForTimedEvent.test.ts @@ -0,0 +1,147 @@ +import type { Span as OtelSpan, TimedEvent } from '@opentelemetry/sdk-trace-base'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import type { Hub } from '@sentry/types'; + +import { maybeCaptureExceptionForTimedEvent } from '../../src/utils/captureExceptionForTimedEvent'; + +describe('maybeCaptureExceptionForTimedEvent', () => { + it('ignores non-exception events', async () => { + const event: TimedEvent = { + time: [12345, 0], + name: 'test event', + }; + + const captureException = jest.fn(); + const hub = { + captureException, + } as unknown as Hub; + + maybeCaptureExceptionForTimedEvent(hub, event); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it('ignores exception events without EXCEPTION_MESSAGE', async () => { + const event: TimedEvent = { + time: [12345, 0], + name: 'exception', + }; + + const captureException = jest.fn(); + const hub = { + captureException, + } as unknown as Hub; + + maybeCaptureExceptionForTimedEvent(hub, event); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it('captures exception from event with EXCEPTION_MESSAGE', async () => { + const event: TimedEvent = { + time: [12345, 0], + name: 'exception', + attributes: { + [SemanticAttributes.EXCEPTION_MESSAGE]: 'test-message', + }, + }; + + const captureException = jest.fn(); + const hub = { + captureException, + } as unknown as Hub; + + maybeCaptureExceptionForTimedEvent(hub, event); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(expect.objectContaining({ message: 'test-message' }), { + captureContext: undefined, + }); + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + captureContext: undefined, + }); + }); + + it('captures stack and type, if available', async () => { + const event: TimedEvent = { + time: [12345, 0], + name: 'exception', + attributes: { + [SemanticAttributes.EXCEPTION_MESSAGE]: 'test-message', + [SemanticAttributes.EXCEPTION_STACKTRACE]: 'test-stack', + [SemanticAttributes.EXCEPTION_TYPE]: 'test-type', + }, + }; + + const captureException = jest.fn(); + const hub = { + captureException, + } as unknown as Hub; + + maybeCaptureExceptionForTimedEvent(hub, event); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'test-message', name: 'test-type', stack: 'test-stack' }), + { + captureContext: undefined, + }, + ); + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + captureContext: undefined, + }); + }); + + it('captures span context, if available', async () => { + const event: TimedEvent = { + time: [12345, 0], + name: 'exception', + attributes: { + [SemanticAttributes.EXCEPTION_MESSAGE]: 'test-message', + }, + }; + + const span = { + parentSpanId: 'test-parent-span-id', + attributes: { + 'test-attr1': 'test-value1', + }, + resource: { + attributes: { + 'test-attr2': 'test-value2', + }, + }, + spanContext: () => { + return { spanId: 'test-span-id', traceId: 'test-trace-id' }; + }, + } as unknown as OtelSpan; + + const captureException = jest.fn(); + const hub = { + captureException, + } as unknown as Hub; + + maybeCaptureExceptionForTimedEvent(hub, event, span); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(expect.objectContaining({ message: 'test-message' }), { + captureContext: { + contexts: { + otel: { + attributes: { + 'test-attr1': 'test-value1', + }, + resource: { + 'test-attr2': 'test-value2', + }, + }, + trace: { + trace_id: 'test-trace-id', + span_id: 'test-span-id', + parent_span_id: 'test-parent-span-id', + }, + }, + }, + }); + }); +});