Skip to content

Commit 2875cd9

Browse files
authored
feat(node-experimental): Use new Propagator for OTEL Spans (#9214)
This fixes the propagator for node-experimental. The "old" propagator from opentelemetry-node relies on having a transaction for the active span etc, which we don't have anymore. So the propagator never attached the correct stuff etc. So now there is a new propagator for node-experimental: * Instead of keeping DSC & TraceparentData on the OTEL Context, we just keep the PropagationContext (which includes the DSC...) * Add an E2E test to make sure we attach the outgoing header correctly
1 parent a7be59e commit 2875cd9

File tree

10 files changed

+679
-53
lines changed

10 files changed

+679
-53
lines changed

packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ require('./tracing');
33
const Sentry = require('@sentry/node-experimental');
44
const { fastify } = require('fastify');
55
const fastifyPlugin = require('fastify-plugin');
6+
const http = require('http');
67

78
const FastifySentry = fastifyPlugin(async (fastify, options) => {
89
fastify.decorateRequest('_sentryContext', null);
@@ -25,14 +26,24 @@ app.get('/test-param/:param', function (req, res) {
2526
res.send({ paramWas: req.params.param });
2627
});
2728

29+
app.get('/test-inbound-headers', function (req, res) {
30+
const headers = req.headers;
31+
32+
res.send({ headers });
33+
});
34+
35+
app.get('/test-outgoing-http', async function (req, res) {
36+
const data = await makeHttpRequest('http://localhost:3030/test-inbound-headers');
37+
38+
res.send(data);
39+
});
40+
2841
app.get('/test-transaction', async function (req, res) {
2942
Sentry.startSpan({ name: 'test-span' }, () => {
3043
Sentry.startSpan({ name: 'child-span' }, () => {});
3144
});
3245

33-
res.send({
34-
transactionIds: global.transactionIds || [],
35-
});
46+
res.send({});
3647
});
3748

3849
app.get('/test-error', async function (req, res) {
@@ -45,16 +56,20 @@ app.get('/test-error', async function (req, res) {
4556

4657
app.listen({ port: port });
4758

48-
Sentry.addGlobalEventProcessor(event => {
49-
global.transactionIds = global.transactionIds || [];
50-
51-
if (event.type === 'transaction') {
52-
const eventId = event.event_id;
53-
54-
if (eventId) {
55-
global.transactionIds.push(eventId);
56-
}
57-
}
58-
59-
return event;
60-
});
59+
function makeHttpRequest(url) {
60+
return new Promise(resolve => {
61+
const data = [];
62+
63+
http
64+
.request(url, httpRes => {
65+
httpRes.on('data', chunk => {
66+
data.push(chunk);
67+
});
68+
httpRes.on('end', () => {
69+
const json = JSON.parse(Buffer.concat(data).toString());
70+
resolve(json);
71+
});
72+
})
73+
.end();
74+
});
75+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { test, expect } from '@playwright/test';
2+
import { Span } from '@sentry/types';
3+
import axios from 'axios';
4+
import { waitForTransaction } from '../event-proxy-server';
5+
6+
const authToken = process.env.E2E_TEST_AUTH_TOKEN;
7+
const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
8+
const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT;
9+
const EVENT_POLLING_TIMEOUT = 30_000;
10+
11+
test('Propagates trace for outgoing http requests', async ({ baseURL }) => {
12+
const inboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
13+
return (
14+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
15+
transactionEvent?.transaction === 'GET /test-inbound-headers'
16+
);
17+
});
18+
19+
const outboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
20+
return (
21+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
22+
transactionEvent?.transaction === 'GET /test-outgoing-http'
23+
);
24+
});
25+
26+
const { data } = await axios.get(`${baseURL}/test-outgoing-http`);
27+
28+
const inboundTransaction = await inboundTransactionPromise;
29+
const outboundTransaction = await outboundTransactionPromise;
30+
31+
const traceId = outboundTransaction?.contexts?.trace?.trace_id;
32+
const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as
33+
| ReturnType<Span['toJSON']>
34+
| undefined;
35+
36+
expect(outgoingHttpSpan).toBeDefined();
37+
38+
const outgoingHttpSpanId = outgoingHttpSpan?.span_id;
39+
40+
expect(traceId).toEqual(expect.any(String));
41+
42+
// data is passed through from the inbound request, to verify we have the correct headers set
43+
const inboundHeaderSentryTrace = data.headers?.['sentry-trace'];
44+
const inboundHeaderBaggage = data.headers?.['baggage'];
45+
46+
expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`);
47+
expect(inboundHeaderBaggage).toBeDefined();
48+
49+
const baggage = (inboundHeaderBaggage || '').split(',');
50+
expect(baggage).toEqual(
51+
expect.arrayContaining([
52+
'sentry-environment=qa',
53+
`sentry-trace_id=${traceId}`,
54+
expect.stringMatching(/sentry-public_key=/),
55+
]),
56+
);
57+
58+
expect(outboundTransaction).toEqual(
59+
expect.objectContaining({
60+
contexts: expect.objectContaining({
61+
trace: {
62+
data: {
63+
url: 'http://localhost:3030/test-outgoing-http',
64+
'otel.kind': 'SERVER',
65+
'http.response.status_code': 200,
66+
},
67+
op: 'http.server',
68+
span_id: expect.any(String),
69+
status: 'ok',
70+
tags: {
71+
'http.status_code': 200,
72+
},
73+
trace_id: traceId,
74+
},
75+
}),
76+
}),
77+
);
78+
79+
expect(inboundTransaction).toEqual(
80+
expect.objectContaining({
81+
contexts: expect.objectContaining({
82+
trace: {
83+
data: {
84+
url: 'http://localhost:3030/test-inbound-headers',
85+
'otel.kind': 'SERVER',
86+
'http.response.status_code': 200,
87+
},
88+
op: 'http.server',
89+
parent_span_id: outgoingHttpSpanId,
90+
span_id: expect.any(String),
91+
status: 'ok',
92+
tags: {
93+
'http.status_code': 200,
94+
},
95+
trace_id: traceId,
96+
},
97+
}),
98+
}),
99+
);
100+
});

packages/node-experimental/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ export const OTEL_ATTR_BREADCRUMB_EVENT_ID = 'sentry.breadcrumb.event_id';
1414
export const OTEL_ATTR_BREADCRUMB_CATEGORY = 'sentry.breadcrumb.category';
1515
export const OTEL_ATTR_BREADCRUMB_DATA = 'sentry.breadcrumb.data';
1616
export const OTEL_ATTR_SENTRY_SAMPLE_RATE = 'sentry.sample_rate';
17+
18+
export const SENTRY_TRACE_HEADER = 'sentry-trace';
19+
export const SENTRY_BAGGAGE_HEADER = 'baggage';
20+
21+
export const SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY = createContextKey('SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY');
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { Baggage, Context, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api';
2+
import { propagation, trace, TraceFlags } from '@opentelemetry/api';
3+
import { isTracingSuppressed, W3CBaggagePropagator } from '@opentelemetry/core';
4+
import { getDynamicSamplingContextFromClient } from '@sentry/core';
5+
import type { DynamicSamplingContext, PropagationContext } from '@sentry/types';
6+
import { generateSentryTraceHeader, SENTRY_BAGGAGE_KEY_PREFIX, tracingContextFromHeaders } from '@sentry/utils';
7+
8+
import { getCurrentHub } from '../sdk/hub';
9+
import { SENTRY_BAGGAGE_HEADER, SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY, SENTRY_TRACE_HEADER } from './../constants';
10+
import { getSpanScope } from './spanData';
11+
12+
/**
13+
* Injects and extracts `sentry-trace` and `baggage` headers from carriers.
14+
*/
15+
export class SentryPropagator extends W3CBaggagePropagator {
16+
/**
17+
* @inheritDoc
18+
*/
19+
public inject(context: Context, carrier: unknown, setter: TextMapSetter): void {
20+
if (isTracingSuppressed(context)) {
21+
return;
22+
}
23+
24+
let baggage = propagation.getBaggage(context) || propagation.createBaggage({});
25+
26+
const propagationContext = context.getValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY) as
27+
| PropagationContext
28+
| undefined;
29+
30+
const { spanId, traceId, sampled } = getSentryTraceData(context, propagationContext);
31+
32+
const dynamicSamplingContext = propagationContext ? getDsc(context, propagationContext, traceId) : undefined;
33+
34+
if (dynamicSamplingContext) {
35+
baggage = Object.entries(dynamicSamplingContext).reduce<Baggage>((b, [dscKey, dscValue]) => {
36+
if (dscValue) {
37+
return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue });
38+
}
39+
return b;
40+
}, baggage);
41+
}
42+
43+
setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled));
44+
45+
super.inject(propagation.setBaggage(context, baggage), carrier, setter);
46+
}
47+
48+
/**
49+
* @inheritDoc
50+
*/
51+
public extract(context: Context, carrier: unknown, getter: TextMapGetter): Context {
52+
const maybeSentryTraceHeader: string | string[] | undefined = getter.get(carrier, SENTRY_TRACE_HEADER);
53+
const maybeBaggageHeader = getter.get(carrier, SENTRY_BAGGAGE_HEADER);
54+
55+
const sentryTraceHeader = maybeSentryTraceHeader
56+
? Array.isArray(maybeSentryTraceHeader)
57+
? maybeSentryTraceHeader[0]
58+
: maybeSentryTraceHeader
59+
: undefined;
60+
61+
const { propagationContext } = tracingContextFromHeaders(sentryTraceHeader, maybeBaggageHeader);
62+
63+
// Add propagation context to context
64+
const contextWithPropagationContext = context.setValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY, propagationContext);
65+
66+
const spanContext: SpanContext = {
67+
traceId: propagationContext.traceId,
68+
spanId: propagationContext.parentSpanId || '',
69+
isRemote: true,
70+
traceFlags: propagationContext.sampled === true ? TraceFlags.SAMPLED : TraceFlags.NONE,
71+
};
72+
73+
// Add remote parent span context
74+
return trace.setSpanContext(contextWithPropagationContext, spanContext);
75+
}
76+
77+
/**
78+
* @inheritDoc
79+
*/
80+
public fields(): string[] {
81+
return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER];
82+
}
83+
}
84+
85+
function getDsc(
86+
context: Context,
87+
propagationContext: PropagationContext,
88+
traceId: string | undefined,
89+
): DynamicSamplingContext | undefined {
90+
// If we have a DSC on the propagation context, we just use it
91+
if (propagationContext.dsc) {
92+
return propagationContext.dsc;
93+
}
94+
95+
// Else, we try to generate a new one
96+
const client = getCurrentHub().getClient();
97+
const activeSpan = trace.getSpan(context);
98+
const scope = activeSpan ? getSpanScope(activeSpan) : undefined;
99+
100+
if (client) {
101+
return getDynamicSamplingContextFromClient(traceId || propagationContext.traceId, client, scope);
102+
}
103+
104+
return undefined;
105+
}
106+
107+
function getSentryTraceData(
108+
context: Context,
109+
propagationContext: PropagationContext | undefined,
110+
): {
111+
spanId: string | undefined;
112+
traceId: string | undefined;
113+
sampled: boolean | undefined;
114+
} {
115+
const span = trace.getSpan(context);
116+
const spanContext = span && span.spanContext();
117+
118+
const traceId = spanContext ? spanContext.traceId : propagationContext?.traceId;
119+
120+
// We have a few scenarios here:
121+
// If we have an active span, and it is _not_ remote, we just use the span's ID
122+
// If we have an active span that is remote, we do not want to use the spanId, as we don't want to attach it to the parent span
123+
// If `isRemote === true`, the span is bascially virtual
124+
// If we don't have a local active span, we use the generated spanId from the propagationContext
125+
const spanId = spanContext && !spanContext.isRemote ? spanContext.spanId : propagationContext?.spanId;
126+
127+
// eslint-disable-next-line no-bitwise
128+
const sampled = spanContext ? Boolean(spanContext.traceFlags & TraceFlags.SAMPLED) : propagationContext?.sampled;
129+
130+
return { traceId, spanId, sampled };
131+
}

packages/node-experimental/src/opentelemetry/sampler.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { isSpanContextValid, trace, TraceFlags } from '@opentelemetry/api';
44
import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base';
55
import { SamplingDecision } from '@opentelemetry/sdk-trace-base';
66
import { hasTracingEnabled } from '@sentry/core';
7-
import { _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY } from '@sentry/opentelemetry-node';
8-
import type { Client, ClientOptions, SamplingContext, TraceparentData } from '@sentry/types';
7+
import type { Client, ClientOptions, PropagationContext, SamplingContext } from '@sentry/types';
98
import { isNaN, logger } from '@sentry/utils';
109

11-
import { OTEL_ATTR_PARENT_SAMPLED, OTEL_ATTR_SENTRY_SAMPLE_RATE } from '../constants';
10+
import {
11+
OTEL_ATTR_PARENT_SAMPLED,
12+
OTEL_ATTR_SENTRY_SAMPLE_RATE,
13+
SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY,
14+
} from '../constants';
1215

1316
/**
1417
* A custom OTEL sampler that uses Sentry sampling rates to make it's decision
@@ -177,14 +180,14 @@ function isValidSampleRate(rate: unknown): boolean {
177180
return true;
178181
}
179182

180-
function getTraceParentData(parentContext: Context): TraceparentData | undefined {
181-
return parentContext.getValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY) as TraceparentData | undefined;
183+
function getPropagationContext(parentContext: Context): PropagationContext | undefined {
184+
return parentContext.getValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY) as PropagationContext | undefined;
182185
}
183186

184187
function getParentRemoteSampled(spanContext: SpanContext, context: Context): boolean | undefined {
185188
const traceId = spanContext.traceId;
186-
const traceparentData = getTraceParentData(context);
189+
const traceparentData = getPropagationContext(context);
187190

188191
// Only inherit sample rate if `traceId` is the same
189-
return traceparentData && traceId === traceparentData.traceId ? traceparentData.parentSampled : undefined;
192+
return traceparentData && traceId === traceparentData.traceId ? traceparentData.sampled : undefined;
190193
}

packages/node-experimental/src/sdk/initOtel.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { Resource } from '@opentelemetry/resources';
33
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
44
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
55
import { SDK_VERSION } from '@sentry/core';
6-
import { SentryPropagator } from '@sentry/opentelemetry-node';
76
import { logger } from '@sentry/utils';
87

8+
import { SentryPropagator } from '../opentelemetry/propagator';
99
import { SentrySampler } from '../opentelemetry/sampler';
1010
import { SentrySpanProcessor } from '../opentelemetry/spanProcessor';
1111
import type { NodeExperimentalClient } from '../types';
@@ -15,7 +15,6 @@ import { getCurrentHub } from './hub';
1515

1616
/**
1717
* Initialize OpenTelemetry for Node.
18-
* We use the @sentry/opentelemetry-node package to communicate with OpenTelemetry.
1918
*/
2019
export function initOtel(): void {
2120
const client = getCurrentHub().getClient<NodeExperimentalClient>();

0 commit comments

Comments
 (0)