Skip to content
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, and @hafzism. Thank you for your contributions!

- feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that:
- All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options.
- Integration options now follow the client. Previously a second `Sentry.init()` in the same process silently reused the options of the first one.
- fix(bun, cloudflare, deno, vercel-edge): Bound the record of spans waiting for their outgoing `fetch` to settle. A request whose promise never settles reports no end, so its entry was never released and the record grew for the lifetime of the process.
- fix(vercel-edge): `winterCGFetchIntegration` now honors the client's `propagateTraceparent` option. It was the one copy of the fetch integration that never forwarded it, so the `traceparent` header was never sent.
- feat(deno)!: Fetch breadcrumbs are now recorded by `fetchIntegration` rather than `breadcrumbsIntegration`, matching the other runtime SDKs. Disable them with `fetchIntegration({ breadcrumbs: false })`. `breadcrumbsIntegration({ fetch: false })` is deprecated, no longer has any effect, and will be removed in a future major version.
- feat(core): Accept a `CollectBehavior` shorthand for `dataCollection.httpHeaders`. Passing `true`, `false`, `{ allow: [...] }` or `{ deny: [...] }` now applies to both request and response headers; `{ request, response }` still controls each direction independently.
- feat(langchain)!: Emit `gen_ai.pipeline.name` instead of `langchain.chain.name` on LangChain chain spans. The attribute is omitted when the chain is unnamed.
- feat(deno)!: Rename several default integrations to match the other SDKs ([#22404](https://github.com/getsentry/sentry-javascript/pull/22404)). The `deno*Integration` exports are kept as deprecated aliases. If you were relying on the names (for example, to disable them), then note that these have changed:
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,5 +210,6 @@ export { bunServerIntegration } from './integrations/bunserver';
export type { BunServerIntegrationOptions } from './integrations/bunserver';
export { bunHttpServerIntegration } from './integrations/bunHttpServer';
export { fetchIntegration } from './integrations/fetch';
export type { FetchIntegrationOptions } from '@sentry/core';
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';
export { makeFetchTransport } from './transports';
166 changes: 5 additions & 161 deletions packages/bun/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,166 +1,10 @@
import type {
Client,
FetchBreadcrumbData,
FetchBreadcrumbHint,
HandlerDataFetch,
IntegrationFn,
Span,
} from '@sentry/core';
import {
addBreadcrumb,
addFetchInstrumentationHandler,
defineIntegration,
getBreadcrumbLogLevelFromHttpStatusCode,
getClient,
instrumentFetchRequest,
isSentryRequestUrl,
LRUMap,
shouldPropagateTraceForUrl,
} from '@sentry/core';

const INTEGRATION_NAME = 'Fetch' as const;

const HAS_CLIENT_MAP = new WeakMap<Client, boolean>();

interface FetchOptions {
/**
* Whether breadcrumbs should be recorded for requests.
* Defaults to true.
*/
breadcrumbs?: boolean;

/**
* Function determining whether or not to create spans to track outgoing requests to the given URL.
* By default, spans will be created for all outgoing requests.
*/
shouldCreateSpanForRequest?: (url: string) => boolean;
}

const _fetchIntegration = ((options: FetchOptions = {}) => {
const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs;
const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest;

const _createSpanUrlMap = new LRUMap<string, boolean>(100);
const _headersUrlMap = new LRUMap<string, boolean>(100);

const spans: Record<string, Span> = {};

/** Decides whether to attach trace data to the outgoing fetch request */
function _shouldAttachTraceData(url: string): boolean {
const client = getClient();

if (!client) {
return false;
}

return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap);
}

/** Helper that wraps shouldCreateSpanForRequest option */
function _shouldCreateSpan(url: string): boolean {
if (shouldCreateSpanForRequest === undefined) {
return true;
}

const cachedDecision = _createSpanUrlMap.get(url);
if (cachedDecision !== undefined) {
return cachedDecision;
}

const decision = shouldCreateSpanForRequest(url);
_createSpanUrlMap.set(url, decision);
return decision;
}

return {
name: INTEGRATION_NAME,
setupOnce() {
addFetchInstrumentationHandler(handlerData => {
const client = getClient();
if (!client || !HAS_CLIENT_MAP.get(client)) {
return;
}
const { propagateTraceparent } = client.getOptions();

if (isSentryRequestUrl(handlerData.fetchData.url, client)) {
return;
}

instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, {
spanOrigin: 'auto.http.fetch',
propagateTraceparent,
});

if (breadcrumbs) {
createBreadcrumb(handlerData);
}
});
},
setup(client) {
HAS_CLIENT_MAP.set(client, true);
},
};
}) satisfies IntegrationFn;
import { createFetchIntegration } from '@sentry/core';

/**
* Instruments outgoing `fetch` requests in Bun: creates spans, records breadcrumbs and
* attaches trace propagation headers.
*/
export const fetchIntegration = defineIntegration(_fetchIntegration);

function createBreadcrumb(handlerData: HandlerDataFetch): void {
const { startTimestamp, endTimestamp } = handlerData;

// We only capture complete fetch requests
if (!endTimestamp) {
return;
}

const breadcrumbData: FetchBreadcrumbData = {
method: handlerData.fetchData.method,
url: handlerData.fetchData.url,
};

if (handlerData.error) {
const hint: FetchBreadcrumbHint = {
data: handlerData.error,
input: handlerData.args,
startTimestamp,
endTimestamp,
};

addBreadcrumb(
{
category: 'fetch',
data: breadcrumbData,
level: 'error',
type: 'http',
},
hint,
);
} else {
const response = handlerData.response as Response | undefined;

breadcrumbData.request_body_size = handlerData.fetchData.request_body_size;
breadcrumbData.response_body_size = handlerData.fetchData.response_body_size;
breadcrumbData.status_code = response?.status;

const hint: FetchBreadcrumbHint = {
input: handlerData.args,
response,
startTimestamp,
endTimestamp,
};
const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code);

addBreadcrumb(
{
category: 'fetch',
data: breadcrumbData,
type: 'http',
level,
},
hint,
);
}
}
export const fetchIntegration = createFetchIntegration({
name: 'Fetch',
spanOrigin: 'auto.http.fetch',
});
100 changes: 100 additions & 0 deletions packages/bun/test/integrations/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import http from 'node:http';
import type { TransactionEvent } from '@sentry/core';
import { getCurrentScope, getIsolationScope, startSpan } from '@sentry/core';
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { init } from '../../src';

async function startServer(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
): Promise<{ port: number; close: () => Promise<void> }> {
const server = http.createServer(handler);
const port = await new Promise<number>(resolve => {
server.listen(0, () => resolve((server.address() as { port: number }).port));
});
return {
port,
close: () => new Promise<void>(resolve => server.close(() => resolve())),
};
}

const transactions: TransactionEvent[] = [];

/** Bind on the real completion signal so a "never arrives" regression fails instead of hanging. */
function waitForTransaction(name: string): Promise<TransactionEvent> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out waiting for the "${name}" transaction`)), 5000);
const poll = setInterval(() => {
const found = transactions.find(event => event.transaction === name);
if (found) {
clearTimeout(timer);
clearInterval(poll);
resolve(found);
}
}, 10);
});
}

function header(headers: http.IncomingHttpHeaders | undefined, name: string): string | undefined {
const value = headers?.[name];
return Array.isArray(value) ? value[0] : value;
}

describe('fetchIntegration', () => {
beforeAll(() => {
init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tracesSampleRate: 1.0,
traceLifecycle: 'static',
beforeSendTransaction(event) {
transactions.push(event);
return null;
},
transport: () => ({ send: async () => ({}), flush: async () => true }),
});
});

afterAll(() => {
getCurrentScope().setClient(undefined);
});

test('creates an http.client span and propagates trace headers', async () => {
let received: http.IncomingHttpHeaders | undefined;
const { port, close } = await startServer((req, res) => {
received = req.headers;
res.end('ok');
});

await startSpan({ name: 'parent', op: 'test' }, async () => {
await fetch(`http://localhost:${port}/downstream`).then(res => res.text());
});

const parent = await waitForTransaction('parent');
await close();

const clientSpan = parent.spans?.find(span => span.op === 'http.client');
expect(clientSpan).toBeDefined();
expect(clientSpan?.origin).toBe('auto.http.fetch');

const traceId = parent.contexts?.trace?.trace_id;
const sentryTrace = header(received, 'sentry-trace');
expect(sentryTrace).toBeDefined();
expect(sentryTrace!.split('-')[0]).toBe(traceId!);
expect(sentryTrace!.split('-')[1]).toBe(clientSpan!.span_id!);
expect(header(received, 'baggage')).toContain(`sentry-trace_id=${traceId}`);
});

test('records exactly one fetch breadcrumb', async () => {
const { port, close } = await startServer((_req, res) => res.end('ok'));
const url = `http://localhost:${port}/crumb`;

getIsolationScope().clearBreadcrumbs();
await fetch(url).then(res => res.text());
await close();

const crumbs = getIsolationScope()
.getScopeData()
.breadcrumbs.filter(crumb => crumb.category === 'fetch' && crumb.data?.url === url);

expect(crumbs).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export { _INTERNAL_wrapRequestHandler, getDefaultIntegrations } from './sdk';

export { httpServerIntegration } from './integrations/httpServer';
export { fetchIntegration } from './integrations/fetch';
export type { FetchIntegrationOptions } from '@sentry/core';
export { spotlightIntegration } from './integrations/spotlight';
export {
openTelemetryIntegration,
Expand Down
Loading
Loading