Skip to content

Commit 15ef153

Browse files
committed
feat(node-experimental): Use new Propagator for OTEL Spans
1 parent 5fd5033 commit 15ef153

File tree

10 files changed

+638
-53
lines changed

10 files changed

+638
-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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`);
45+
// Baggage and DSC are not set
46+
expect(data.headers?.['baggage']).toEqual(undefined);
47+
48+
expect(outboundTransaction).toEqual(
49+
expect.objectContaining({
50+
contexts: expect.objectContaining({
51+
trace: {
52+
data: {
53+
url: 'http://localhost:3030/test-outgoing-http',
54+
'otel.kind': 'SERVER',
55+
'http.response.status_code': 200,
56+
},
57+
op: 'http.server',
58+
span_id: expect.any(String),
59+
status: 'ok',
60+
tags: {
61+
'http.status_code': 200,
62+
},
63+
trace_id: traceId,
64+
},
65+
}),
66+
}),
67+
);
68+
69+
expect(inboundTransaction).toEqual(
70+
expect.objectContaining({
71+
contexts: expect.objectContaining({
72+
trace: {
73+
data: {
74+
url: 'http://localhost:3030/test-inbound-headers',
75+
'otel.kind': 'SERVER',
76+
'http.response.status_code': 200,
77+
},
78+
op: 'http.server',
79+
parent_span_id: outgoingHttpSpanId,
80+
span_id: expect.any(String),
81+
status: 'ok',
82+
tags: {
83+
'http.status_code': 200,
84+
},
85+
trace_id: traceId,
86+
},
87+
}),
88+
}),
89+
);
90+
});

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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 type { PropagationContext } from '@sentry/types';
5+
import { generateSentryTraceHeader, SENTRY_BAGGAGE_KEY_PREFIX, tracingContextFromHeaders } from '@sentry/utils';
6+
7+
import { SENTRY_BAGGAGE_HEADER, SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY, SENTRY_TRACE_HEADER } from './../constants';
8+
9+
/**
10+
* Injects and extracts `sentry-trace` and `baggage` headers from carriers.
11+
*/
12+
export class SentryPropagator extends W3CBaggagePropagator {
13+
/**
14+
* @inheritDoc
15+
*/
16+
public inject(context: Context, carrier: unknown, setter: TextMapSetter): void {
17+
if (isTracingSuppressed(context)) {
18+
return;
19+
}
20+
21+
let baggage = propagation.getBaggage(context) || propagation.createBaggage({});
22+
23+
const propagationContext = context.getValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY) as
24+
| PropagationContext
25+
| undefined;
26+
const dynamicSamplingContext = propagationContext?.dsc;
27+
28+
if (dynamicSamplingContext) {
29+
baggage = Object.entries(dynamicSamplingContext).reduce<Baggage>((b, [dscKey, dscValue]) => {
30+
if (dscValue) {
31+
return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue });
32+
}
33+
return b;
34+
}, baggage);
35+
}
36+
37+
const { spanId, traceId, sampled } = getSentryTraceData(context, propagationContext);
38+
39+
setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled));
40+
41+
super.inject(propagation.setBaggage(context, baggage), carrier, setter);
42+
}
43+
44+
/**
45+
* @inheritDoc
46+
*/
47+
public extract(context: Context, carrier: unknown, getter: TextMapGetter): Context {
48+
const maybeSentryTraceHeader: string | string[] | undefined = getter.get(carrier, SENTRY_TRACE_HEADER);
49+
const maybeBaggageHeader = getter.get(carrier, SENTRY_BAGGAGE_HEADER);
50+
51+
const sentryTraceHeader = maybeSentryTraceHeader
52+
? Array.isArray(maybeSentryTraceHeader)
53+
? maybeSentryTraceHeader[0]
54+
: maybeSentryTraceHeader
55+
: undefined;
56+
57+
const { propagationContext } = tracingContextFromHeaders(sentryTraceHeader, maybeBaggageHeader);
58+
59+
// Add propagation context to context
60+
const contextWithPropagationContext = context.setValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY, propagationContext);
61+
62+
const spanContext: SpanContext = {
63+
traceId: propagationContext.traceId,
64+
spanId: propagationContext.parentSpanId || '',
65+
isRemote: true,
66+
traceFlags: propagationContext.sampled === true ? TraceFlags.SAMPLED : TraceFlags.NONE,
67+
};
68+
69+
// Add remote parent span context
70+
return trace.setSpanContext(contextWithPropagationContext, spanContext);
71+
}
72+
73+
/**
74+
* @inheritDoc
75+
*/
76+
public fields(): string[] {
77+
return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER];
78+
}
79+
}
80+
81+
function getSentryTraceData(
82+
context: Context,
83+
propagationContext: PropagationContext | undefined,
84+
): {
85+
spanId: string | undefined;
86+
traceId: string | undefined;
87+
sampled: boolean | undefined;
88+
} {
89+
const span = trace.getSpan(context);
90+
const spanContext = span && span.spanContext();
91+
92+
const traceId = spanContext ? spanContext.traceId : propagationContext?.traceId;
93+
94+
// We have a few scenarios here:
95+
// If we have an active span, and it is _not_ remote, we just use the span's ID
96+
// 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
97+
// If `isRemote === true`, the span is bascially virtual
98+
// If we don't have a local active span, we use the generated spanId from the propagationContext
99+
const spanId = spanContext && !spanContext.isRemote ? spanContext.spanId : propagationContext?.spanId;
100+
101+
// eslint-disable-next-line no-bitwise
102+
const sampled = spanContext ? Boolean(spanContext.traceFlags & TraceFlags.SAMPLED) : propagationContext?.sampled;
103+
104+
return { traceId, spanId, sampled };
105+
}

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>();

packages/node-experimental/test/integration/transactions.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api';
22
import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
33
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
4-
import { _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY } from '@sentry/opentelemetry-node';
5-
import type { TransactionEvent } from '@sentry/types';
4+
import type { PropagationContext, TransactionEvent } from '@sentry/types';
65
import { logger } from '@sentry/utils';
76

87
import * as Sentry from '../../src';
98
import { startSpan } from '../../src';
9+
import { SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY } from '../../src/constants';
1010
import type { Http } from '../../src/integrations';
1111
import { SentrySpanProcessor } from '../../src/opentelemetry/spanProcessor';
1212
import type { NodeExperimentalClient } from '../../src/sdk/client';
@@ -348,10 +348,11 @@ describe('Integration | Transactions', () => {
348348
traceFlags: TraceFlags.SAMPLED,
349349
};
350350

351-
const traceParentData = {
351+
const propagationContext: PropagationContext = {
352352
traceId,
353353
parentSpanId,
354-
parentSampled: true,
354+
spanId: '6e0c63257de34c93',
355+
sampled: true,
355356
};
356357

357358
mockSdkInit({ enableTracing: true, beforeSendTransaction });
@@ -362,7 +363,7 @@ describe('Integration | Transactions', () => {
362363
// We simulate the correct context we'd normally get from the SentryPropagator
363364
context.with(
364365
trace.setSpanContext(
365-
context.active().setValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY, traceParentData),
366+
context.active().setValue(SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY, propagationContext),
366367
spanContext,
367368
),
368369
() => {

0 commit comments

Comments
 (0)