Skip to content

fix(node): Ensure traces are propagated without spans in Node 23+ #16221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0',
tracePropagationTargets: [/\/v0/, 'v1'],
integrations: [Sentry.httpIntegration({ spans: false })],
transport: loggingTransport,
// Ensure this gets a correct hint
beforeBreadcrumb(breadcrumb, hint) {
breadcrumb.data = breadcrumb.data || {};
const req = hint?.request;
breadcrumb.data.ADDED_PATH = req?.path;
return breadcrumb;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as Sentry from '@sentry/node';
import * as http from 'http';

async function run() {
Sentry.addBreadcrumb({ message: 'manual breadcrumb' });

await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`);
await makeHttpGet(`${process.env.SERVER_URL}/api/v1`);
await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`);
await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`);

Sentry.captureException(new Error('foo'));
}

run();

function makeHttpRequest(url) {
return new Promise(resolve => {
http
.request(url, httpRes => {
httpRes.on('data', () => {
// we don't care about data
});
httpRes.on('end', () => {
resolve();
});
})
.end();
});
}

function makeHttpGet(url) {
return new Promise(resolve => {
http.get(url, httpRes => {
httpRes.on('data', () => {
// we don't care about data
});
httpRes.on('end', () => {
resolve();
});
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { describe, expect } from 'vitest';
import { conditionalTest } from '../../../../utils';
import { createEsmAndCjsTests } from '../../../../utils/runner';
import { createTestServer } from '../../../../utils/server';

describe('outgoing http requests with tracing & spans disabled', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
conditionalTest({ min: 23 })('node >=23', () => {
test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => {
expect.assertions(11);

const [SERVER_URL, closeTestServer] = await createTestServer()
.get('/api/v0', headers => {
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
expect(headers['baggage']).toEqual(expect.any(String));
})
.get('/api/v1', headers => {
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
expect(headers['baggage']).toEqual(expect.any(String));
})
.get('/api/v2', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.get('/api/v3', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.start();

await createRunner()
.withEnv({ SERVER_URL })
.ensureNoErrorOutput()
.expect({
event: {
exception: {
values: [
{
type: 'Error',
value: 'foo',
},
],
},
breadcrumbs: [
{
message: 'manual breadcrumb',
timestamp: expect.any(Number),
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v0`,
status_code: 200,
ADDED_PATH: '/api/v0',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v1`,
status_code: 200,
ADDED_PATH: '/api/v1',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v2`,
status_code: 200,
ADDED_PATH: '/api/v2',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v3`,
status_code: 200,
ADDED_PATH: '/api/v3',
},
timestamp: expect.any(Number),
type: 'http',
},
],
},
})
.start()
.completed();

closeTestServer();
});
});

// On older node versions, outgoing requests do not get trace-headers injected, sadly
// This is because the necessary diagnostics channel hook is not available yet
conditionalTest({ max: 22 })('node <=22', () => {
test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => {
expect.assertions(9);

const [SERVER_URL, closeTestServer] = await createTestServer()
.get('/api/v0', headers => {
// This is not instrumented, sadly
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.get('/api/v1', headers => {
// This is not instrumented, sadly
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.get('/api/v2', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.get('/api/v3', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.start();

await createRunner()
.withEnv({ SERVER_URL })
.ensureNoErrorOutput()
.expect({
event: {
exception: {
values: [
{
type: 'Error',
value: 'foo',
},
],
},
breadcrumbs: [
{
message: 'manual breadcrumb',
timestamp: expect.any(Number),
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v0`,
status_code: 200,
ADDED_PATH: '/api/v0',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v1`,
status_code: 200,
ADDED_PATH: '/api/v1',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v2`,
status_code: 200,
ADDED_PATH: '/api/v2',
},
timestamp: expect.any(Number),
type: 'http',
},
{
category: 'http',
data: {
'http.method': 'GET',
url: `${SERVER_URL}/api/v3`,
status_code: 200,
ADDED_PATH: '/api/v3',
},
timestamp: expect.any(Number),
type: 'http',
},
],
},
})
.start()
.completed();

closeTestServer();
});
});
});
});
72 changes: 72 additions & 0 deletions packages/node/src/integrations/http/SentryHttpInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ import {
getCurrentScope,
getIsolationScope,
getSanitizedUrlString,
getTraceData,
httpRequestToRequestData,
logger,
LRUMap,
parseUrl,
stripUrlQueryAndFragment,
withIsolationScope,
} from '@sentry/core';
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
import { DEBUG_BUILD } from '../../debug-build';
import { mergeBaggageHeaders } from '../../utils/baggage';
import { getRequestUrl } from '../../utils/getRequestUrl';

const INSTRUMENTATION_NAME = '@sentry/instrumentation-http';
Expand All @@ -49,6 +53,15 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
*/
extractIncomingTraceFromHeader?: boolean;

/**
* Whether to propagate Sentry trace headers in outgoing requests.
* By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled)
* then this instrumentation can take over.
*
* @default `false`
Comment on lines +57 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Should we mention that this only works for Node >= 23?

*/
propagateTraceInOutgoingRequests?: boolean;

/**
* Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
Expand Down Expand Up @@ -102,8 +115,12 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024;
* https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts
*/
export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpInstrumentationOptions> {
private _propagationDecisionMap: LRUMap<string, boolean>;

public constructor(config: SentryHttpInstrumentationOptions = {}) {
super(INSTRUMENTATION_NAME, VERSION, config);

this._propagationDecisionMap = new LRUMap<string, boolean>(100);
}

/** @inheritdoc */
Expand All @@ -127,6 +144,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
this._onOutgoingRequestFinish(data.request, undefined);
}) satisfies ChannelListener;

const onHttpClientRequestCreated = ((_data: unknown) => {
const data = _data as { request: http.ClientRequest };
this._onOutgoingRequestCreated(data.request);
}) satisfies ChannelListener;

/**
* You may be wondering why we register these diagnostics-channel listeners
* in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝,
Expand All @@ -153,12 +175,19 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
// In this case, `http.client.response.finish` is not triggered
subscribe('http.client.request.error', onHttpClientRequestError);

// NOTE: This channel only exist since Node 23
// Before that, outgoing requests are not patched, sadly
Comment on lines +178 to +179
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// NOTE: This channel only exist since Node 23
// Before that, outgoing requests are not patched, sadly
// NOTE: This channel only exist since Node 23
// Before that, outgoing requests are not patched
// and trace headers are not propagated, sadly.

if (this.getConfig().propagateTraceInOutgoingRequests) {
subscribe('http.client.request.created', onHttpClientRequestCreated);
}

return moduleExports;
},
() => {
unsubscribe('http.server.request.start', onHttpServerRequestStart);
unsubscribe('http.client.response.finish', onHttpClientResponseFinish);
unsubscribe('http.client.request.error', onHttpClientRequestError);
unsubscribe('http.client.request.created', onHttpClientRequestCreated);
},
),
new InstrumentationNodeModuleDefinition(
Expand Down Expand Up @@ -209,6 +238,49 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
}
}

/**
* This is triggered when an outgoing request is created.
* It has access to the request object, and can mutate it before the request is sent.
*/
private _onOutgoingRequestCreated(request: http.ClientRequest): void {
const url = getRequestUrl(request);
const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests;
const shouldPropagate =
typeof ignoreOutgoingRequests === 'function' ? !ignoreOutgoingRequests(url, getRequestOptions(request)) : true;

if (!shouldPropagate) {
return;
}

// Manually add the trace headers, if it applies
// Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
// Which we do not have in this case
const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets;
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap)
? getTraceData()
: undefined;

if (!addedHeaders) {
return;
}

const { 'sentry-trace': sentryTrace, baggage } = addedHeaders;

// We do not want to overwrite existing header here, if it was already set
if (sentryTrace && !request.getHeader('sentry-trace')) {
request.setHeader('sentry-trace', sentryTrace);
logger.log(INSTRUMENTATION_NAME, 'Added sentry-trace header to outgoing request');
}

if (baggage) {
// For baggage, we make sure to merge this into a possibly existing header
const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage);
if (newBaggage) {
request.setHeader('baggage', newBaggage);
}
}
}

/**
* Patch a server.emit function to handle process isolation for incoming requests.
* This will only patch the emit function if it was not already patched.
Expand Down
Loading
Loading