Skip to content

feat(node): fetch breadcrumbs without tracing #14018

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

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8302e68
feat(node): fetch breadcrumbs without tracing
timfish Oct 18, 2024
c85c285
Include by default and export everywhere
timfish Oct 18, 2024
fd22620
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Oct 18, 2024
a17da50
Include diagnostic_channel types because they're experimental in olde…
timfish Oct 18, 2024
98b54e4
rename
timfish Oct 18, 2024
316a8e9
Fix test types
timfish Oct 18, 2024
e460246
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Oct 18, 2024
ab1debb
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Oct 22, 2024
6e24a39
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Oct 23, 2024
508615f
Fix missing export
timfish Oct 23, 2024
ef5e713
Add tests and ensure legacy disable still works
timfish Oct 23, 2024
bbc5932
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Nov 11, 2024
421585c
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Nov 25, 2024
87c135b
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Nov 27, 2024
c8a2344
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Dec 5, 2024
383272c
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Dec 5, 2024
25bf722
Merge branch 'develop' into timfish/feat/fetch-breadcrumbs-without-tr…
timfish Apr 10, 2025
1260cfa
Merge branch 'timfish/feat/fetch-breadcrumbs-without-tracing' of gith…
timfish Apr 10, 2025
fac883b
revert merge change
timfish Apr 10, 2025
c24d526
Fix linting issues
timfish Apr 10, 2025
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
Expand Up @@ -50,6 +50,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// not supported in bun:
'NodeClient',
// Doesn't have these events
'fetchBreadcrumbsIntegration',
'childProcessIntegration',
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracePropagationTargets: [/\/v0/, 'v1'],
integrations: [Sentry.nativeNodeFetchIntegration({ breadcrumbs: false })],
transport: loggingTransport,
tracesSampleRate: 0.0,
});

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

// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
await new Promise(resolve => setTimeout(resolve, 100));
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text());

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

// eslint-disable-next-line @typescript-eslint/no-floating-promises
run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracePropagationTargets: [/\/v0/, 'v1'],
integrations: [],
transport: loggingTransport,
tracesSampleRate: 0.0,
fetchBreadcrumbs: false,
});

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

// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
await new Promise(resolve => setTimeout(resolve, 100));
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text());

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

// eslint-disable-next-line @typescript-eslint/no-floating-promises
run();
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,62 @@ describe('outgoing fetch', () => {
.completed();
closeTestServer();
});

test('outgoing fetch requests should not create breadcrumbs when disabled', done => {
createTestServer(done)
.start()
.then(([SERVER_URL, closeTestServer]) => {
createRunner(__dirname, 'scenario-disabled.ts')
.withEnv({ SERVER_URL })
.ensureNoErrorOutput()
.expect({
event: {
breadcrumbs: [
{
message: 'manual breadcrumb',
timestamp: expect.any(Number),
},
],
exception: {
values: [
{
type: 'Error',
value: 'foo',
},
],
},
},
})
.start(closeTestServer);
});
});

test('outgoing fetch requests should not create breadcrumbs when legacy disabled', done => {
createTestServer(done)
.start()
.then(([SERVER_URL, closeTestServer]) => {
createRunner(__dirname, 'scenario-disabled-legacy.ts')
.withEnv({ SERVER_URL })
.ensureNoErrorOutput()
.expect({
event: {
breadcrumbs: [
{
message: 'manual breadcrumb',
timestamp: expect.any(Number),
},
],
exception: {
values: [
{
type: 'Error',
value: 'foo',
},
],
},
},
})
.start(closeTestServer);
});
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export {
mysql2Integration,
mysqlIntegration,
nativeNodeFetchIntegration,
fetchBreadcrumbsIntegration,
NodeClient,
nodeContextIntegration,
onUncaughtExceptionIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
consoleIntegration,
httpIntegration,
nativeNodeFetchIntegration,
fetchBreadcrumbsIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
modulesIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
consoleIntegration,
httpIntegration,
nativeNodeFetchIntegration,
fetchBreadcrumbsIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
modulesIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader';
export { amqplibIntegration } from './integrations/tracing/amqplib';
export { vercelAIIntegration } from './integrations/tracing/vercelai';
export { childProcessIntegration } from './integrations/childProcess';
export { fetchBreadcrumbsIntegration } from './integrations/fetch-breadcrumbs';

export { SentryContextManager } from './otel/contextManager';
export { generateInstrumentOnce } from './otel/instrument';
Expand Down
121 changes: 121 additions & 0 deletions packages/node/src/integrations/fetch-breadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { UndiciRequest, UndiciResponse } from '@opentelemetry/instrumentation-undici';
import type { Integration, IntegrationFn, SanitizedRequestData } from '@sentry/core';
import {
addBreadcrumb,
defineIntegration,
getBreadcrumbLogLevelFromHttpStatusCode,
getSanitizedUrlString,
parseUrl,
} from '@sentry/core';

import * as diagnosticsChannel from 'diagnostics_channel';
import type { NodeClient } from '../sdk/client';

type OldIntegration = Integration & { breadcrumbsDisabled: boolean };

interface NodeFetchOptions {
/**
* Do not capture breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`.
*/
ignore?: (url: string) => boolean;
}

const _fetchBreadcrumbsIntegration = ((options: NodeFetchOptions = {}) => {
function onRequestHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void {
if (options.ignore) {
const url = getAbsoluteUrl(request.origin, request.path);
const shouldIgnore = options.ignore(url);

if (shouldIgnore) {
return;
}
}

addRequestBreadcrumb(request, response);
}

return {
name: 'FetchBreadcrumbs',
setup: (client: NodeClient) => {
if (client.getOptions().fetchBreadcrumbs === false) {
return;
}

// We need to ensure all other integrations have been setup first
setImmediate(() => {
const oldIntegration = client.getIntegrationByName<OldIntegration>('NodeFetch');
if (oldIntegration?.breadcrumbsDisabled) {
return;
}

diagnosticsChannel
.channel('undici:request:headers')
.subscribe(onRequestHeaders as diagnosticsChannel.ChannelListener);
});
},
};
}) satisfies IntegrationFn;

export const fetchBreadcrumbsIntegration = defineIntegration(_fetchBreadcrumbsIntegration);

/** Add a breadcrumb for outgoing requests. */
function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
const data = getBreadcrumbData(request);
const statusCode = response.statusCode;
const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode);

addBreadcrumb(
{
category: 'http',
data: {
status_code: statusCode,
...data,
},
type: 'http',
level,
},
{
event: 'response',
request,
response,
},
);
}

function getBreadcrumbData(request: UndiciRequest): Partial<SanitizedRequestData> {
try {
const url = new URL(request.path, request.origin);
const parsedUrl = parseUrl(url.toString());

const data: Partial<SanitizedRequestData> = {
url: getSanitizedUrlString(parsedUrl),
'http.method': request.method || 'GET',
};

if (parsedUrl.search) {
data['http.query'] = parsedUrl.search;
}
if (parsedUrl.hash) {
data['http.fragment'] = parsedUrl.hash;
}

return data;
} catch {
return {};
}
}

// Matching the behavior of the base instrumentation
function getAbsoluteUrl(origin: string, path: string = '/'): string {
const url = `${origin}`;

if (url.endsWith('/') && path.startsWith('/')) {
return `${url}${path.slice(1)}`;
}

if (!url.endsWith('/') && !path.startsWith('/')) {
return `${url}/${path.slice(1)}`;
}

return `${url}${path}`;
}
107 changes: 107 additions & 0 deletions packages/node/src/integrations/node-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';
import type { IntegrationFn } from '@sentry/core';
import {
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
defineIntegration,
getClient,
getTraceData,
hasSpansEnabled
} from '@sentry/core';
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';

interface NodeFetchOptions {
/**
* @deprecated Use `fetchBreadcrumbs` init option instead.
* ```js
* Sentry.init({
* dsn: '__DSN__',
* fetchBreadcrumbs: false,
* })
* ```
*
* Whether breadcrumbs should be recorded for requests.
*
* Defaults to `true`
*/
breadcrumbs?: boolean;

/**
* Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`.
* This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled.
*/
ignoreOutgoingRequests?: (url: string) => boolean;
}

const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => {
const _ignoreOutgoingRequests = options.ignoreOutgoingRequests;

return {
name: 'NodeFetch',
setupOnce() {
const propagationDecisionMap = new LRUMap<string, boolean>(100);

const instrumentation = new UndiciInstrumentation({
requireParentforSpans: false,
ignoreRequestHook: request => {
const url = getAbsoluteUrl(request.origin, request.path);
const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url);

if (shouldIgnore) {
return true;
}

// If tracing is disabled, we still want to propagate traces
// So we do that manually here, matching what the instrumentation does otherwise
if (!hasSpansEnabled()) {
const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets;
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)
? getTraceData()
: {};

const requestHeaders = request.headers;
if (Array.isArray(requestHeaders)) {
Object.entries(addedHeaders).forEach(headers => requestHeaders.push(...headers));
} else {
request.headers += Object.entries(addedHeaders)
.map(([k, v]) => `${k}: ${v}\r\n`)
.join('');
}

// Prevent starting a span for this request
return true;
}

return false;
},
startSpanHook: () => {
return {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch',
};
},
});

registerInstrumentations({ instrumentations: [instrumentation] });
},
// eslint-disable-next-line deprecation/deprecation
breadcrumbsDisabled: options.breadcrumbs === false,
};
}) satisfies IntegrationFn;

export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration);

// Matching the behavior of the base instrumentation
function getAbsoluteUrl(origin: string, path: string = '/'): string {
const url = `${origin}`;

if (url.endsWith('/') && path.startsWith('/')) {
return `${url}${path.slice(1)}`;
}

if (!url.endsWith('/') && !path.startsWith('/')) {
return `${url}/${path.slice(1)}`;
}

return `${url}${path}`;
}
Loading
Loading