diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index c0ba088886e8..8e75d9457a9a 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -24,11 +24,13 @@ }, "peerDependencies": { "@opentelemetry/api": "1.x", + "@opentelemetry/core": "1.x", "@opentelemetry/sdk-trace-base": "1.x", "@opentelemetry/semantic-conventions": "1.x" }, "devDependencies": { "@opentelemetry/api": "^1.2.0", + "@opentelemetry/core": "^1.7.0", "@opentelemetry/sdk-trace-base": "^1.7.0", "@opentelemetry/sdk-trace-node": "^1.7.0" }, diff --git a/packages/opentelemetry-node/src/constants.ts b/packages/opentelemetry-node/src/constants.ts new file mode 100644 index 000000000000..55f386f2b39f --- /dev/null +++ b/packages/opentelemetry-node/src/constants.ts @@ -0,0 +1,9 @@ +import { createContextKey } from '@opentelemetry/api'; + +export const SENTRY_TRACE_HEADER = 'sentry-trace'; + +export const SENTRY_BAGGAGE_HEADER = 'baggage'; + +export const SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY = createContextKey('SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY'); + +export const SENTRY_TRACE_PARENT_CONTEXT_KEY = createContextKey('SENTRY_TRACE_PARENT_CONTEXT_KEY'); diff --git a/packages/opentelemetry-node/src/index.ts b/packages/opentelemetry-node/src/index.ts index 21b5f209d3a8..95ea469b1b74 100644 --- a/packages/opentelemetry-node/src/index.ts +++ b/packages/opentelemetry-node/src/index.ts @@ -1,3 +1,4 @@ import '@sentry/tracing'; export { SentrySpanProcessor } from './spanprocessor'; +export { SentryPropogator } from './propogator'; diff --git a/packages/opentelemetry-node/src/propogator.ts b/packages/opentelemetry-node/src/propogator.ts new file mode 100644 index 000000000000..e050bb3a2f69 --- /dev/null +++ b/packages/opentelemetry-node/src/propogator.ts @@ -0,0 +1,94 @@ +import { + Context, + isSpanContextValid, + TextMapGetter, + TextMapPropagator, + TextMapSetter, + trace, + TraceFlags, +} from '@opentelemetry/api'; +import { isTracingSuppressed } from '@opentelemetry/core'; +import { + baggageHeaderToDynamicSamplingContext, + dynamicSamplingContextToSentryBaggageHeader, + extractTraceparentData, +} from '@sentry/utils'; + +import { + SENTRY_BAGGAGE_HEADER, + SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, + SENTRY_TRACE_HEADER, + SENTRY_TRACE_PARENT_CONTEXT_KEY, +} from './constants'; +import { SENTRY_SPAN_PROCESSOR_MAP } from './spanprocessor'; + +/** + * Injects and extracts `sentry-trace` and `baggage` headers from carriers. + */ +export class SentryPropogator implements TextMapPropagator { + /** + * @inheritDoc + */ + public inject(context: Context, carrier: unknown, setter: TextMapSetter): void { + const spanContext = trace.getSpanContext(context); + if (!spanContext || !isSpanContextValid(spanContext) || isTracingSuppressed(context)) { + return; + } + + // TODO: if sentry span use `parentSpanId`. + // Same `isSentryRequest` as is used in `SentrySpanProcessor`. + // const spanId = isSentryRequest(spanContext) ? spanContext.parentSpanId : spanContext.spanId; + + const traceparent = `${spanContext.traceId}-${spanContext.spanId}-${ + // eslint-disable-next-line no-bitwise + spanContext.traceFlags & TraceFlags.SAMPLED ? 1 : 0 + }`; + setter.set(carrier, SENTRY_TRACE_HEADER, traceparent); + + const span = SENTRY_SPAN_PROCESSOR_MAP.get(spanContext.spanId); + if (span && span.transaction) { + const dynamicSamplingContext = span.transaction.getDynamicSamplingContext(); + const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); + if (sentryBaggageHeader) { + setter.set(carrier, SENTRY_BAGGAGE_HEADER, sentryBaggageHeader); + } + } + } + + /** + * @inheritDoc + */ + public extract(context: Context, carrier: unknown, getter: TextMapGetter): Context { + let newContext = context; + + const maybeSentryTraceHeader: string | string[] | undefined = getter.get(carrier, SENTRY_TRACE_HEADER); + if (maybeSentryTraceHeader) { + const header = Array.isArray(maybeSentryTraceHeader) ? maybeSentryTraceHeader[0] : maybeSentryTraceHeader; + const traceparentData = extractTraceparentData(header); + newContext = newContext.setValue(SENTRY_TRACE_PARENT_CONTEXT_KEY, traceparentData); + if (traceparentData) { + const traceFlags = traceparentData.parentSampled ? TraceFlags.SAMPLED : TraceFlags.NONE; + const spanContext = { + traceId: traceparentData.traceId || '', + spanId: traceparentData.parentSpanId || '', + isRemote: true, + traceFlags, + }; + newContext = trace.setSpanContext(newContext, spanContext); + } + } + + const maybeBaggageHeader = getter.get(carrier, SENTRY_BAGGAGE_HEADER); + const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(maybeBaggageHeader); + newContext = newContext.setValue(SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, dynamicSamplingContext); + + return newContext; + } + + /** + * @inheritDoc + */ + public fields(): string[] { + return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER]; + } +} diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 099d86a9e3e1..58aaaa37afd5 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -2,24 +2,27 @@ import { Context } from '@opentelemetry/api'; import { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { getCurrentHub, withScope } from '@sentry/core'; import { Transaction } from '@sentry/tracing'; -import { Span as SentrySpan, TransactionContext } from '@sentry/types'; +import { DynamicSamplingContext, Span as SentrySpan, TraceparentData, TransactionContext } from '@sentry/types'; import { logger } from '@sentry/utils'; +import { SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, SENTRY_TRACE_PARENT_CONTEXT_KEY } from './constants'; import { mapOtelStatus } from './utils/map-otel-status'; import { parseSpanDescription } from './utils/parse-otel-span-description'; +export const SENTRY_SPAN_PROCESSOR_MAP: Map = new Map< + SentrySpan['spanId'], + SentrySpan +>(); + /** * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via * the Sentry SDK. */ export class SentrySpanProcessor implements OtelSpanProcessor { - // public only for testing - public readonly _map: Map = new Map(); - /** * @inheritDoc */ - public onStart(otelSpan: OtelSpan, _parentContext: Context): void { + public onStart(otelSpan: OtelSpan, parentContext: Context): void { const hub = getCurrentHub(); if (!hub) { __DEBUG_BUILD__ && logger.error('SentrySpanProcessor has triggered onStart before a hub has been setup.'); @@ -39,7 +42,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { // Otel supports having multiple non-nested spans at the same time // so we cannot use hub.getSpan(), as we cannot rely on this being on the current span - const sentryParentSpan = otelParentSpanId && this._map.get(otelParentSpanId); + const sentryParentSpan = otelParentSpanId && SENTRY_SPAN_PROCESSOR_MAP.get(otelParentSpanId); if (sentryParentSpan) { const sentryChildSpan = sentryParentSpan.startChild({ @@ -49,9 +52,9 @@ export class SentrySpanProcessor implements OtelSpanProcessor { spanId: otelSpanId, }); - this._map.set(otelSpanId, sentryChildSpan); + SENTRY_SPAN_PROCESSOR_MAP.set(otelSpanId, sentryChildSpan); } else { - const traceCtx = getTraceData(otelSpan); + const traceCtx = getTraceData(otelSpan, parentContext); const transaction = hub.startTransaction({ name: otelSpan.name, ...traceCtx, @@ -59,8 +62,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { startTimestamp: otelSpan.startTime[0], spanId: otelSpanId, }); - - this._map.set(otelSpanId, transaction); + SENTRY_SPAN_PROCESSOR_MAP.set(otelSpanId, transaction); } } @@ -69,7 +71,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { */ public onEnd(otelSpan: OtelSpan): void { const otelSpanId = otelSpan.spanContext().spanId; - const sentrySpan = this._map.get(otelSpanId); + const sentrySpan = SENTRY_SPAN_PROCESSOR_MAP.get(otelSpanId); if (!sentrySpan) { __DEBUG_BUILD__ && @@ -85,7 +87,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { sentrySpan.finish(otelSpan.endTime[0]); } - this._map.delete(otelSpanId); + SENTRY_SPAN_PROCESSOR_MAP.delete(otelSpanId); } /** @@ -107,13 +109,27 @@ export class SentrySpanProcessor implements OtelSpanProcessor { } } -function getTraceData(otelSpan: OtelSpan): Partial { +function getTraceData(otelSpan: OtelSpan, parentContext: Context): Partial { const spanContext = otelSpan.spanContext(); const traceId = spanContext.traceId; const spanId = spanContext.spanId; - const parentSpanId = otelSpan.parentSpanId; - return { spanId, traceId, parentSpanId }; + + const traceparentData = parentContext.getValue(SENTRY_TRACE_PARENT_CONTEXT_KEY) as TraceparentData | undefined; + const dynamicSamplingContext = parentContext.getValue(SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY) as + | Partial + | undefined; + + return { + spanId, + traceId, + parentSpanId, + metadata: { + // only set dynamic sampling context if sentry-trace header was set + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + source: 'custom', + }, + }; } function finishTransactionWithContextFromOtelData(transaction: Transaction, otelSpan: OtelSpan): void { diff --git a/packages/opentelemetry-node/test/propogator.test.ts b/packages/opentelemetry-node/test/propogator.test.ts new file mode 100644 index 000000000000..3f87b39260de --- /dev/null +++ b/packages/opentelemetry-node/test/propogator.test.ts @@ -0,0 +1,265 @@ +import { defaultTextMapGetter, defaultTextMapSetter, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { suppressTracing } from '@opentelemetry/core'; +import { Hub, makeMain } from '@sentry/core'; +import { addExtensionMethods, Transaction } from '@sentry/tracing'; +import { TransactionContext } from '@sentry/types'; + +import { + SENTRY_BAGGAGE_HEADER, + SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, + SENTRY_TRACE_HEADER, + SENTRY_TRACE_PARENT_CONTEXT_KEY, +} from '../src/constants'; +import { SentryPropogator } from '../src/propogator'; +import { SENTRY_SPAN_PROCESSOR_MAP } from '../src/spanprocessor'; + +beforeAll(() => { + addExtensionMethods(); +}); + +describe('SentryPropogator', () => { + const propogator = new SentryPropogator(); + let carrier: { [key: string]: unknown }; + + beforeEach(() => { + carrier = {}; + }); + + it('returns fields set', () => { + expect(propogator.fields()).toEqual([SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER]); + }); + + describe('inject', () => { + describe('sentry-trace', () => { + it.each([ + [ + 'should set sentry-trace header when sampled', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1', + ], + [ + 'should set sentry-trace header when not sampled', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + }, + 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0', + ], + [ + 'should NOT set sentry-trace header when traceId is empty', + { + traceId: '', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + undefined, + ], + [ + 'should NOT set sentry-trace header when spanId is empty', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '', + traceFlags: TraceFlags.NONE, + }, + undefined, + ], + ])('%s', (_name, spanContext, expected) => { + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + propogator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(expected); + }); + + it('should NOT set sentry-trace header if instrumentation is supressed', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); + propogator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); + }); + }); + + describe('baggage', () => { + const client = { + getOptions: () => ({ + environment: 'production', + release: '1.0.0', + }), + getDsn: () => ({ + publicKey: 'abc', + }), + }; + // @ts-ignore Use mock client for unit tests + const hub: Hub = new Hub(client); + makeMain(hub); + + afterEach(() => { + SENTRY_SPAN_PROCESSOR_MAP.clear(); + }); + + enum PerfType { + Transaction = 'transaction', + Span = 'span', + } + + function createTransactionAndMaybeSpan(type: PerfType, transactionContext: TransactionContext) { + const transaction = new Transaction(transactionContext, hub); + SENTRY_SPAN_PROCESSOR_MAP.set(transaction.spanId, transaction); + if (type === PerfType.Span) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { spanId, ...ctx } = transactionContext; + const span = transaction.startChild({ ...ctx, description: transaction.name }); + SENTRY_SPAN_PROCESSOR_MAP.set(span.spanId, span); + } + } + + describe.each([PerfType.Transaction, PerfType.Span])('with active %s', type => { + it.each([ + [ + 'should set baggage header when sampled', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + { + name: 'sampled-transaction', + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + sampled: true, + }, + 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=sampled-transaction,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ], + [ + 'should NOT set baggage header when not sampled', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + }, + { + name: 'not-sampled-transaction', + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + sampled: false, + }, + 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=not-sampled-transaction,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ], + [ + 'should NOT set baggage header when traceId is empty', + { + traceId: '', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + { + name: 'empty-traceId-transaction', + traceId: '', + spanId: '6e0c63257de34c92', + sampled: true, + }, + undefined, + ], + [ + 'should NOT set baggage header when spanId is empty', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '', + traceFlags: TraceFlags.SAMPLED, + }, + { + name: 'empty-spanId-transaction', + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '', + sampled: true, + }, + undefined, + ], + ])('%s', (_name, spanContext, transactionContext, expected) => { + createTransactionAndMaybeSpan(type, transactionContext); + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + propogator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(expected); + }); + + it('should NOT set sentry-trace header if instrumentation is supressed', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const transactionContext = { + name: 'sampled-transaction', + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + sampled: true, + }; + createTransactionAndMaybeSpan(type, transactionContext); + const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); + propogator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); + }); + }); + }); + }); + + describe('extract', () => { + it('sets sentry span context on the context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propogator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + }); + }); + + it('sets defined sentry trace header on context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propogator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(context.getValue(SENTRY_TRACE_PARENT_CONTEXT_KEY)).toEqual({ + parentSampled: true, + parentSpanId: '6e0c63257de34c92', + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + }); + }); + + it('sets undefined sentry trace header on context', () => { + const sentryTraceHeader = undefined; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propogator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(context.getValue(SENTRY_TRACE_PARENT_CONTEXT_KEY)).toEqual(undefined); + }); + + it('sets defined dynamic sampling context on context', () => { + const baggage = + 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=dsc-transaction,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b'; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propogator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(context.getValue(SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY)).toEqual({ + environment: 'production', + public_key: 'abc', + release: '1.0.0', + trace_id: 'd4cda95b652f4a1592b449d5929fda1b', + transaction: 'dsc-transaction', + }); + }); + + it('sets undefined dynamic sampling context on context', () => { + const baggage = ''; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propogator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(context.getValue(SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY)).toEqual(undefined); + }); + }); +}); diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index a86645cbe462..4a6b2ec78daa 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -8,7 +8,7 @@ import { Hub, makeMain } from '@sentry/core'; import { addExtensionMethods, Span as SentrySpan, SpanStatusType, Transaction } from '@sentry/tracing'; import { Contexts, Scope } from '@sentry/types'; -import { SentrySpanProcessor } from '../src/spanprocessor'; +import { SENTRY_SPAN_PROCESSOR_MAP, SentrySpanProcessor } from '../src/spanprocessor'; // Integration Test of SentrySpanProcessor @@ -41,7 +41,7 @@ describe('SentrySpanProcessor', () => { }); function getSpanForOtelSpan(otelSpan: OtelSpan | OpenTelemetry.Span) { - return spanProcessor._map.get(otelSpan.spanContext().spanId); + return SENTRY_SPAN_PROCESSOR_MAP.get(otelSpan.spanContext().spanId); } function getContext(transaction: Transaction) { diff --git a/yarn.lock b/yarn.lock index e7732a741fdf..6f51104dd44b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3913,7 +3913,7 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/context-base/-/context-base-0.14.0.tgz#c67fc20a4d891447ca1a855d7d70fa79a3533001" integrity sha512-sDOAZcYwynHFTbLo6n8kIbLiVF3a3BLkrmehJUyEbT9F+Smbi47kLGS2gG2g0fjBLR/Lr1InPD7kXL7FaTqEkw== -"@opentelemetry/core@1.7.0": +"@opentelemetry/core@1.7.0", "@opentelemetry/core@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.7.0.tgz#83bdd1b7a4ceafcdffd6590420657caec5f7b34c" integrity sha512-AVqAi5uc8DrKJBimCTFUT4iFI+5eXpo4sYmGbQ0CypG0piOTHE2g9c5aSoTGYXu3CzOmJZf7pT6Xh+nwm5d6yQ==