Skip to content

Commit 76df64d

Browse files
committed
test(node): add integration test for http double-instrumentation
1 parent b45ad65 commit 76df64d

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
integrations: [
10+
// Disable Sentry's span creation so that OTel HttpInstrumentation
11+
// is the only source of http.client spans. Breadcrumbs and
12+
// trace-propagation headers are still injected; only span creation
13+
// is suppressed.
14+
Sentry.httpIntegration({ spans: false }),
15+
],
16+
});
17+
18+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
19+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
20+
21+
registerInstrumentations({
22+
instrumentations: [new HttpInstrumentation()],
23+
});
24+
25+
import * as http from 'http';
26+
27+
void Sentry.startSpan({ name: 'test_transaction' }, async () => {
28+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`);
29+
});
30+
31+
function makeHttpRequest(url: string): Promise<void> {
32+
return new Promise<void>(resolve => {
33+
http
34+
.request(url, httpRes => {
35+
httpRes.on('data', () => {});
36+
httpRes.on('end', resolve);
37+
})
38+
.end();
39+
});
40+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
10+
11+
// Simulate a user who independently sets up OTel HttpInstrumentation
12+
// alongside the Sentry SDK, as when adopting Sentry into existing OTel app
13+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
14+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
15+
16+
registerInstrumentations({
17+
instrumentations: [new HttpInstrumentation()],
18+
});
19+
20+
import * as http from 'http';
21+
22+
void Sentry.startSpan({ name: 'test_transaction' }, async () => {
23+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`);
24+
});
25+
26+
function makeHttpRequest(url: string): Promise<void> {
27+
return new Promise<void>(resolve => {
28+
http
29+
.request(url, httpRes => {
30+
httpRes.on('data', () => {});
31+
httpRes.on('end', resolve);
32+
})
33+
.end();
34+
});
35+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createTestServer } from '@sentry-internal/test-utils';
2+
import { expect, test } from 'vitest';
3+
import { createRunner } from '../../../../utils/runner';
4+
5+
test('registers double spans when OTel HttpInstrumentation is also active — documents known issue', async () => {
6+
const [SERVER_URL, closeTestServer] = await createTestServer()
7+
.get('/api/v0', () => {})
8+
.start();
9+
10+
await createRunner(__dirname, 'scenario.ts')
11+
.withEnv({ SERVER_URL })
12+
.expect({
13+
transaction: txn => {
14+
expect(txn.transaction).toBe('test_transaction');
15+
16+
const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client');
17+
18+
// PROBLEM: two http.client spans are produced for a single
19+
// outgoing request when @opentelemetry/instrumentation-http runs
20+
// alongside Sentry.
21+
//
22+
// - OTel's HttpInstrumentation monkey-patches http.request and
23+
// creates an OTel span; Sentry's SentrySpanProcessor converts that
24+
// to a Sentry span.
25+
// - On Node >=22.12 Sentry's SentryHttpInstrumentation additionally
26+
// subscribes to the http.client.request.created diagnostic channel.
27+
// The channel fires inside OTel's already-patched http.request,
28+
// triggering Sentry to create a *second* http.client span as a
29+
// child of the first.
30+
// - On Node <22.12 both instrumentations monkey-patch http.request,
31+
// so both wrappers fire and each creates its own span.
32+
//
33+
// MITIGATION: pass `spans: false` to httpIntegration() so Sentry
34+
// defers all outgoing span creation to OTel's HttpInstrumentation
35+
// (whose spans Sentry already captures via SentrySpanProcessor).
36+
//
37+
// See the 'mitigation' scenario alongside this test.
38+
expect(httpClientSpans).toHaveLength(2);
39+
40+
// The outer span comes from OTel HttpInstrumentation (no Sentry
41+
// origin). The inner span is the one Sentry's own handler creates;
42+
// it is a *child* of the outer span with origin 'auto.http.client'.
43+
const sentrySpan = httpClientSpans.find(s => s.data?.['sentry.origin'] === 'auto.http.client');
44+
const otelSpan = httpClientSpans.find(s => s.data?.['sentry.origin'] !== 'auto.http.client');
45+
46+
expect(sentrySpan).toBeDefined();
47+
expect(otelSpan).toBeDefined();
48+
49+
// the sentry-created span is nested inside the otel-created span.
50+
expect(sentrySpan!.parent_span_id).toBe(otelSpan!.span_id);
51+
},
52+
})
53+
.start()
54+
.completed();
55+
56+
closeTestServer();
57+
});
58+
59+
test('mitigation: spans: false on httpIntegration prevents double-instrumentation', async () => {
60+
const [SERVER_URL, closeTestServer] = await createTestServer()
61+
.get('/api/v0', () => {})
62+
.start();
63+
64+
await createRunner(__dirname, 'scenario-mitigation.ts')
65+
.withEnv({ SERVER_URL })
66+
.expect({
67+
transaction: txn => {
68+
expect(txn.transaction).toBe('test_transaction');
69+
70+
const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client');
71+
// With spans: false in httpIntegration(), Sentry does not create its
72+
// own span. OTel's HttpInstrumentation still creates one, which
73+
// flows through SentrySpanProcessor, so there is exactly one
74+
// http.client span.
75+
expect(httpClientSpans).toHaveLength(1);
76+
expect(httpClientSpans[0]).toMatchObject({
77+
description: expect.stringMatching(/GET .*\/api\/v0/),
78+
status: 'ok',
79+
});
80+
},
81+
})
82+
.start()
83+
.completed();
84+
85+
closeTestServer();
86+
});

0 commit comments

Comments
 (0)