Skip to content

Commit de7f71e

Browse files
Lms24chargome
andauthored
fix(node-core): Recycle propagationContext for each request (#19835)
This PR fixes a bug in our node-core `httpServerIntegration` (user-facing it's `httpIntegration`), which caused traceIds (or rather our propagationContext) to stay the same across requests. This would surface in SDK setups where tracing is not explicitly enabled (e.g. missing `tracesSampleRate`), causing caught errors across request to be associated with the same trace. This PR now recycles the propagationContext on the current as well as isolation scope to ensure traces are isolated on a request level. Added node(-core) integration tests to demonstrate that traceIds are now scoped to requests, when tracing is enabled or disabled. Prior to this PR, the test for tracing being disabled failed. Note: This should only have an effect on SDKs configured for tracing without spans (i.e. (and confusingly) no `tracesSampleRate` set), as for tracing with spans, we take the trace data from the active span directly. I added a test demonstrating this, just to be sure. closes #19815 ref #17101 --------- Co-authored-by: Charly Gomez <charly.gomez1310@gmail.com>
1 parent 2b62357 commit de7f71e

File tree

9 files changed

+286
-4
lines changed

9 files changed

+286
-4
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { loggingTransport } = require('@sentry-internal/node-core-integration-tests');
2+
const Sentry = require('@sentry/node-core');
3+
const { setupOtel } = require('../../../utils/setupOtel.js');
4+
5+
const client = Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
transport: loggingTransport,
8+
tracesSampleRate: 1.0,
9+
});
10+
11+
setupOtel(client);
12+
13+
const express = require('express');
14+
const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-core-integration-tests');
15+
16+
const app = express();
17+
18+
app.get('/test', (_req, res) => {
19+
Sentry.captureException(new Error('test error'));
20+
res.json({ success: true });
21+
});
22+
23+
startExpressServerAndSendPortToRunner(app);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('errors from in different requests each get a unique traceId when tracing is enabled', async () => {
9+
const eventTraceIds: string[] = [];
10+
11+
const runner = createRunner(__dirname, 'server.js')
12+
.expect({
13+
event: event => {
14+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
15+
},
16+
})
17+
.expect({
18+
event: event => {
19+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
20+
},
21+
})
22+
.expect({
23+
event: event => {
24+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
25+
},
26+
})
27+
.start();
28+
29+
await runner.makeRequest('get', '/test');
30+
await runner.makeRequest('get', '/test');
31+
await runner.makeRequest('get', '/test');
32+
33+
await runner.completed();
34+
35+
expect(new Set(eventTraceIds).size).toBe(3);
36+
for (const traceId of eventTraceIds) {
37+
expect(traceId).toMatch(/^[a-f\d]{32}$/);
38+
}
39+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { loggingTransport } = require('@sentry-internal/node-core-integration-tests');
2+
const Sentry = require('@sentry/node-core');
3+
const { setupOtel } = require('../../../utils/setupOtel.js');
4+
5+
const client = Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
transport: loggingTransport,
8+
});
9+
10+
setupOtel(client);
11+
12+
const express = require('express');
13+
const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-core-integration-tests');
14+
15+
const app = express();
16+
17+
app.get('/test', (_req, res) => {
18+
Sentry.captureException(new Error('test error'));
19+
const traceId = Sentry.getCurrentScope().getPropagationContext().traceId;
20+
res.json({ traceId });
21+
});
22+
23+
startExpressServerAndSendPortToRunner(app);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('each request gets a unique traceId when tracing is disabled', async () => {
9+
const eventTraceIds: string[] = [];
10+
11+
const runner = createRunner(__dirname, 'server.js')
12+
.expect({
13+
event: event => {
14+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
15+
},
16+
})
17+
.expect({
18+
event: event => {
19+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
20+
},
21+
})
22+
.expect({
23+
event: event => {
24+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
25+
},
26+
})
27+
.start();
28+
29+
const propagationContextTraceIds = [
30+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
31+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
32+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
33+
];
34+
35+
await runner.completed();
36+
37+
expect(new Set(propagationContextTraceIds).size).toBe(3);
38+
for (const traceId of propagationContextTraceIds) {
39+
expect(traceId).toMatch(/^[a-f\d]{32}$/);
40+
}
41+
42+
expect(eventTraceIds).toEqual(propagationContextTraceIds);
43+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
transport: loggingTransport,
8+
tracesSampleRate: 1.0,
9+
});
10+
11+
import express from 'express';
12+
13+
const app = express();
14+
15+
app.get('/test', async (_req, res) => {
16+
Sentry.captureException(new Error('test error'));
17+
// calling Sentry.flush() here to ensure that the order in which we send transaction and errors
18+
// is guaranteed to be 1. error, 2. transaction (repeated 3x in test)
19+
await Sentry.flush();
20+
res.json({ success: true });
21+
});
22+
23+
startExpressServerAndSendPortToRunner(app);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('errors and transactions get a unique traceId per request, when tracing is enabled', async () => {
9+
const eventTraceIds: string[] = [];
10+
const transactionTraceIds: string[] = [];
11+
12+
const runner = createRunner(__dirname, 'server.ts')
13+
.expect({
14+
event: event => {
15+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
16+
},
17+
})
18+
.expect({
19+
transaction: transaction => {
20+
transactionTraceIds.push(transaction.spans?.[0]?.trace_id || '');
21+
},
22+
})
23+
.expect({
24+
event: event => {
25+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
26+
},
27+
})
28+
.expect({
29+
transaction: transaction => {
30+
transactionTraceIds.push(transaction.spans?.[0]?.trace_id || '');
31+
},
32+
})
33+
.expect({
34+
event: event => {
35+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
36+
},
37+
})
38+
.expect({
39+
transaction: transaction => {
40+
transactionTraceIds.push(transaction.spans?.[0]?.trace_id || '');
41+
},
42+
})
43+
.start();
44+
45+
await runner.makeRequest('get', '/test');
46+
await runner.makeRequest('get', '/test');
47+
await runner.makeRequest('get', '/test');
48+
49+
await runner.completed();
50+
51+
expect(new Set(transactionTraceIds).size).toBe(3);
52+
for (const traceId of transactionTraceIds) {
53+
expect(traceId).toMatch(/^[a-f\d]{32}$/);
54+
}
55+
56+
expect(eventTraceIds).toEqual(transactionTraceIds);
57+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
transport: loggingTransport,
8+
});
9+
10+
import express from 'express';
11+
12+
const app = express();
13+
14+
app.get('/test', (_req, res) => {
15+
Sentry.captureException(new Error('test error'));
16+
const traceId = Sentry.getCurrentScope().getPropagationContext().traceId;
17+
res.json({ traceId });
18+
});
19+
20+
startExpressServerAndSendPortToRunner(app);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('each request gets a unique traceId when tracing is disabled', async () => {
9+
const eventTraceIds: string[] = [];
10+
11+
const runner = createRunner(__dirname, 'server.ts')
12+
.expect({
13+
event: event => {
14+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
15+
},
16+
})
17+
.expect({
18+
event: event => {
19+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
20+
},
21+
})
22+
.expect({
23+
event: event => {
24+
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
25+
},
26+
})
27+
.start();
28+
29+
const propagationContextTraceIds = [
30+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
31+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
32+
((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId,
33+
];
34+
35+
await runner.completed();
36+
37+
expect(new Set(propagationContextTraceIds).size).toBe(3);
38+
for (const traceId of propagationContextTraceIds) {
39+
expect(traceId).toMatch(/^[a-f\d]{32}$/);
40+
}
41+
42+
expect(eventTraceIds).toEqual(propagationContextTraceIds);
43+
});

packages/node-core/src/integrations/http/httpServerIntegration.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import type { Socket } from 'node:net';
66
import { context, createContextKey, propagation } from '@opentelemetry/api';
77
import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core';
88
import {
9+
_INTERNAL_safeMathRandom,
910
addNonEnumerableProperty,
1011
debug,
1112
generateSpanId,
13+
generateTraceId,
1214
getClient,
1315
getCurrentScope,
1416
getIsolationScope,
@@ -218,10 +220,19 @@ function instrumentServer(
218220
}
219221

220222
return withIsolationScope(isolationScope, () => {
221-
// Set a new propagationSpanId for this request
222-
// We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope
223-
// This way we can save an "unnecessary" `withScope()` invocation
224-
getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId();
223+
const newPropagationContext = {
224+
traceId: generateTraceId(),
225+
sampleRand: _INTERNAL_safeMathRandom(),
226+
propagationSpanId: generateSpanId(),
227+
};
228+
// - Set a fresh propagation context so each request gets a unique traceId.
229+
// When there are incoming trace headers, propagation.extract() below sets a remote
230+
// span on the OTel context which takes precedence in getTraceContextForScope().
231+
// - We can write directly to the current scope here because it is forked implicitly via
232+
// `context.with` in `withIsolationScope` (See `SentryContextManager`).
233+
// - explicitly making a deep copy to avoid mutation of original PC on the other scope
234+
getCurrentScope().setPropagationContext({ ...newPropagationContext });
235+
isolationScope.setPropagationContext({ ...newPropagationContext });
225236

226237
const ctx = propagation
227238
.extract(context.active(), normalizedRequest.headers)

0 commit comments

Comments
 (0)