Skip to content

feat(bun): Instrument outgoing fetch requests - #22869

Open
mydea wants to merge 1 commit into
developfrom
fn/bun-outgoing-fetch-instrumentation
Open

feat(bun): Instrument outgoing fetch requests#22869
mydea wants to merge 1 commit into
developfrom
fn/bun-outgoing-fetch-instrumentation

Conversation

@mydea

@mydea mydea commented Jul 30, 2026

Copy link
Copy Markdown
Member

Adds outgoing fetch instrumentation to @sentry/bun via a new fetchIntegration, and fixes an inverted native-fetch guard in @sentry/core.

Why the Bun SDK needed this

@sentry/bun shipped nativeNodeFetchIntegration (from @sentry/node) in its defaults, but that integration works exclusively by subscribing to undici's diagnostics_channel events (undici:request:create, etc.). Bun implements fetch natively (in Zig, with its own HTTP client) and never emits those channels, so the integration was registered but completely inert — no http.client spans, no breadcrumbs, and no trace propagation on outgoing requests.

Approach

The new fetchIntegration patches the global fetch (via core's addFetchInstrumentationHandler + instrumentFetchRequest), mirroring the Cloudflare SDK, which has the same "no undici / no diagnostics_channel" constraint. It creates http.client spans (origin auto.http.fetch), records fetch breadcrumbs, and injects sentry-trace/baggage headers gated by tracePropagationTargets. It replaces nativeNodeFetchIntegration in the Bun default integrations to avoid shipping a dead integration and any risk of double instrumentation.

Because @sentry/elysia builds on @sentry/bun's defaults, it inherits this automatically.

Core guard fix

instrumentFetch(onFetchResolved, skipNativeFetchCheck) guarded with if (skipNativeFetchCheck && !supportsNativeFetch()). That is the inverse of what the parameter name implies: passing true performed the (browser-only) native-fetch check, while the default (false) skipped it. Only Cloudflare and Bun passed true, so they were the sole callers actually running the check — and on Bun supportsNativeFetch() returns false (it probes for [native code], which fails once a host like Next.js has wrapped fetch, then falls back to an iframe DOM check that can't run outside a browser), causing the patch to bail. The guard is now !skipNativeFetchCheck && !supportsNativeFetch() so the parameter matches its name. Existing browser/deno/vercel-edge callers are unaffected (their fetch reads as native / EdgeRuntime short-circuits); core + browser fetch unit tests pass.

Tests

  • New dev-packages/bun-integration-tests/suites/fetch/ — asserts http.client span creation, header propagation to allowed targets, no propagation to disallowed targets, and breadcrumb recording. Verified against a real Bun runtime.
  • elysia-bun e2e — un-fixme'd the two outgoing-fetch propagation tests, which now pass.
  • nextjs-16-bun e2e — un-skipped; asserts propagation through the Next.js AppRouteRouteHandlers.runHandler span (see below).

Known limitation: Next.js on Bun

For apps running @sentry/nextjs (→ @sentry/node) on the Bun runtime (not @sentry/bun), the fetchIntegration can be added manually, but outgoing-fetch propagation is owned by the active OTel context, whose active span during a route handler is Next's executing api route span — not our inactive http.client span. So the downstream request becomes a sibling of our http.client span rather than its child. The nextjs-16-bun test asserts this actual behavior. A proper fix (a Bun-runtime fetch path inside @sentry/node that participates in the active context) is out of scope here.


function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void {
if (skipNativeFetchCheck && !supportsNativeFetch()) {
if (!skipNativeFetchCheck && !supportsNativeFetch()) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this check was just incorrect before I think, we only checked this for cloudflare which was the opposite of what we wanted?

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 225988a. Configure here.

'http.request.header.host': expect.any(String),
'http.request.header.user_agent': expect.stringContaining('Bun'),
'http.request.header.baggage': expect.any(String),
'http.request.header.sentry_trace': expect.any(String),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Flaky header assertions in Bunserver tests

Medium Severity

These Bunserver unit tests now require http.request.header.baggage and http.request.header.sentry_trace on plain fetch calls, but they never install fetchIntegration. Those headers only appear if another file’s init() has already patched global fetch in the shared Bun test process. That violates the flaky-test guidance in the PR review rules: the assertions are order-dependent and fail under isolation or a different file order. Flagged because it was mentioned in this rules file.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 225988a. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, I think this is valid. Order/timing dependent assertions.

Probably best to just remove all 6 of the lines referenced? I'm not sure they're doing much beyond being a test-flake landmine.

  • packages/bun/test/integrations/bunserver.test.ts#L107-L109
  • packages/bun/test/integrations/bunserver.test.ts#L201-L203

If we wanna test these specific keys, we can set them explicitly on the outgoing request in the test (eg, fetch(url, { headers: { baggage: '...', 'sentry-trace': '...' } })) as the existing test at packages/bun/test/integrations/bunserver.test.ts already does. But propagation would be better in the new dev-packages/bun-integration-tests/suites/fetch/ suite, where it is already being covered.

Add a `fetchIntegration` to `@sentry/bun` that patches the global `fetch` to
create `http.client` spans, record breadcrumbs, and inject trace-propagation
headers. Bun implements `fetch` natively rather than through undici, so the
Node SDK's `nativeNodeFetchIntegration` (which subscribes to undici's
`diagnostics_channel` events) never fires. The new integration mirrors the
Cloudflare approach and replaces `nativeNodeFetchIntegration` in the Bun
default integrations.

Also fix an inverted guard in core's `instrumentFetch`: `skipNativeFetchCheck`
did the opposite of its name — passing `true` performed the browser-only native
check, while the default skipped it. Only Cloudflare/Bun passed `true`, so they
were the only callers that ran the check. The guard now reads
`!skipNativeFetchCheck && !supportsNativeFetch()` so the parameter matches its
name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mydea
mydea force-pushed the fn/bun-outgoing-fetch-instrumentation branch from 225988a to 98d00aa Compare July 30, 2026 12:58
@mydea
mydea marked this pull request as ready for review July 30, 2026 12:58
@mydea
mydea requested a review from a team as a code owner July 30, 2026 12:58
@mydea
mydea requested review from a team, JPeer264 and isaacs and removed request for a team July 30, 2026 12:58
Comment on lines 49 to 51
function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void {
if (skipNativeFetchCheck && !supportsNativeFetch()) {
if (!skipNativeFetchCheck && !supportsNativeFetch()) {
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The updated guard condition in addFetchInstrumentationHandler incorrectly prevents fetch instrumentation in browser environments where fetch is polyfilled, such as in browser extensions or test frameworks.
Severity: MEDIUM

Suggested Fix

Add an explicit supportsNativeFetch() guard within integrations like breadcrumbsIntegration before they call addFetchInstrumentationHandler. Alternatively, revise the guard logic in addFetchInstrumentationHandler to accommodate both the intended fix for Bun/Cloudflare and the browser polyfill scenario without causing a regression.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/instrument/fetch.ts#L49-L51

Potential issue: The change to the guard condition in `addFetchInstrumentationHandler`
from `if (skipNativeFetchCheck && !supportsNativeFetch())` to `if (!skipNativeFetchCheck
&& !supportsNativeFetch())` introduces a regression. In browser environments with a
polyfilled or wrapped `fetch` implementation, `supportsNativeFetch()` returns `false`.
Integrations like `breadcrumbsIntegration` call `addFetchInstrumentationHandler` with
`skipNativeFetchCheck` as `false` (the default). The new condition `if (!false &&
!false)` evaluates to `true`, causing the function to exit early and silently disable
fetch instrumentation. This results in a loss of fetch-related breadcrumbs and tracing
spans in these specific environments.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The bot's context is wrong ("browser environments"), but the issue is real; just for deno rather than browsers.

I went looking for cases where this function is being called, because the guard here is indeed really confusing. One case where it's being called is in the deno breadcrumb handling logic.

This test shows that it passes with the old guard, and not with the new one, so this is definitely a regression: https://gist.github.com/isaacs/1fdf09ad8f6c327e8041bfdd7b035baf

Deno previously didn't have any unit or integration tests for breadcrumbs, which I assume is why this didn't get caught more helpfully.

I'm not sure what the best fix is, but it seems like we have two options:

🅰️ leave the guard as-is, which should work for Bun since supportsNativeFetch() passes there. In that case, I think it'd be good to add a comment explaining why it has the double-check, or even rename the variables here, because it's super confusing. (It seems like "don't skip the native check" would mean you ought to do the native check, not the opposite? This is very weird.)
🅱️ update deno's call to pass the flag explicitly, so packages/deno/src/integrations/breadcrumbs.ts line 50 becomes: addFetchInstrumentationHandler(_getFetchBreadcrumbHandler(client), true). This is a bit riskier, since it's also called in packages/vercel-edge/src/integrations/wintercg-fetch.ts and a few other places, and we'd need to make sure that we don't miss any.

I recommend 🅰️, and then have a follow-up to clean up the logic and make sure we're calling it properly everywhere we use it. Maybe even make the flag mandatory rather than defaulting to false.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh! actually 🅱️ is a bit more dangerous than I thought, because it actually is going through addFetchInstrumentationHandler which calls maybeInstrument, so the first global caller will win. So, it's tricky to guarantee that the flag will be set properly.

You found a sticky knot! Yeah, I recommend leaving this as-is for this PR, and cleaning up carefully in a followup issue.

shouldCreateSpanForRequest?: (url: string) => boolean;
}

const _fetchIntegration = ((options: FetchOptions = {}) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: Seems like it is an almost 1:1 copy from the one from Cloudflare. Would be nice if this could get moved to server-utils and named somehoe nativeFetchIntegration or so? Then we can reuse it 😍

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The only change that I'd gate on here is the issue with the confusing native fetch detection flags. I recommend leaving that aside in this PR, and addressing later in a cleanup that can be more careful about making that less footgunny.

Comment on lines 49 to 51
function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void {
if (skipNativeFetchCheck && !supportsNativeFetch()) {
if (!skipNativeFetchCheck && !supportsNativeFetch()) {
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The bot's context is wrong ("browser environments"), but the issue is real; just for deno rather than browsers.

I went looking for cases where this function is being called, because the guard here is indeed really confusing. One case where it's being called is in the deno breadcrumb handling logic.

This test shows that it passes with the old guard, and not with the new one, so this is definitely a regression: https://gist.github.com/isaacs/1fdf09ad8f6c327e8041bfdd7b035baf

Deno previously didn't have any unit or integration tests for breadcrumbs, which I assume is why this didn't get caught more helpfully.

I'm not sure what the best fix is, but it seems like we have two options:

🅰️ leave the guard as-is, which should work for Bun since supportsNativeFetch() passes there. In that case, I think it'd be good to add a comment explaining why it has the double-check, or even rename the variables here, because it's super confusing. (It seems like "don't skip the native check" would mean you ought to do the native check, not the opposite? This is very weird.)
🅱️ update deno's call to pass the flag explicitly, so packages/deno/src/integrations/breadcrumbs.ts line 50 becomes: addFetchInstrumentationHandler(_getFetchBreadcrumbHandler(client), true). This is a bit riskier, since it's also called in packages/vercel-edge/src/integrations/wintercg-fetch.ts and a few other places, and we'd need to make sure that we don't miss any.

I recommend 🅰️, and then have a follow-up to clean up the logic and make sure we're calling it properly everywhere we use it. Maybe even make the flag mandatory rather than defaulting to false.

'http.request.header.host': expect.any(String),
'http.request.header.user_agent': expect.stringContaining('Bun'),
'http.request.header.baggage': expect.any(String),
'http.request.header.sentry_trace': expect.any(String),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, I think this is valid. Order/timing dependent assertions.

Probably best to just remove all 6 of the lines referenced? I'm not sure they're doing much beyond being a test-flake landmine.

  • packages/bun/test/integrations/bunserver.test.ts#L107-L109
  • packages/bun/test/integrations/bunserver.test.ts#L201-L203

If we wanna test these specific keys, we can set them explicitly on the outgoing request in the test (eg, fetch(url, { headers: { baggage: '...', 'sentry-trace': '...' } })) as the existing test at packages/bun/test/integrations/bunserver.test.ts already does. But propagation would be better in the new dev-packages/bun-integration-tests/suites/fetch/ suite, where it is already being covered.

Comment on lines 49 to 51
function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void {
if (skipNativeFetchCheck && !supportsNativeFetch()) {
if (!skipNativeFetchCheck && !supportsNativeFetch()) {
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh! actually 🅱️ is a bit more dangerous than I thought, because it actually is going through addFetchInstrumentationHandler which calls maybeInstrument, so the first global caller will win. So, it's tricky to guarantee that the flag will be set properly.

You found a sticky knot! Yeah, I recommend leaving this as-is for this PR, and cleaning up carefully in a followup issue.

Comment thread packages/bun/src/index.ts
httpIntegration,
httpServerIntegration,
httpServerSpansIntegration,
nativeNodeFetchIntegration,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this still needed? Or can we drop the nativeNodeFetchIntegration (since the point is that we know it doesn't work)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, right, this should go away 👍

Comment on lines +93 to +96
const { propagateTraceparent } = client?.getOptions() || {};
if (!client || !HAS_CLIENT_MAP.get(client)) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can simplify this a bit, and skip the work in cases where it'd be thrown away immediately.

Suggested change
const { propagateTraceparent } = client?.getOptions() || {};
if (!client || !HAS_CLIENT_MAP.get(client)) {
return;
}
if (!client || !HAS_CLIENT_MAP.get(client)) {
return;
}
const { propagateTraceparent } = client.getOptions();

EDIT: ah, this is copied from cf, might be worth keeping them aligned and then abstracting them both out somewhere and making this fix? Idk, minor issue anyway.

// `skipNativeFetchCheck: true` — the native-fetch check is browser-only (it probes for
// `[native code]` and falls back to an iframe DOM check). On Bun `fetch` may already be
// wrapped by the host (e.g. Next.js) and there is no DOM, so we always patch the global.
}, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This drops fetch.preconnect (a bun-specific non-standard addition).

Would probably be worth adding ownProps back on:

const globalFetch = GLOBAL_OBJ.fetch;
// ... instrument fetch call
for (const k of Object.getOwnPropertyNames(globalFetch)) {
  Object.defineProperty(fetch, k, Object.getOwnPropertyDescriptor(globalFetch, k));
}

Or something to that effect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants