fix(sentry): stamp a header-only stack on the createTimeoutSignal fallback reason so extension leaks stay suppressed

Author: koala73Created Sep 17, 2026Updated Sep 17, 2026
Labelsbugarea: infrastructureP2

Problem

When a runtime lacks AbortSignal.timeout, createTimeoutSignal falls back to aborting with a JS-built new DOMException('signal timed out', 'TimeoutError') (src/services/timeout-signal.ts:40-44). That reason carries none of the native reason's header-only stack.

  • Chromium-family engines leave a JS-built DOMException stackless (stack === undefined).
  • Sentry's fetch instrumentation (@sentry/core 10.46 instrumentFetch, build/esm/instrument/fetch.js:100-106) fills an undefined stack on a rejected error with the fetch call site. It writes that onto the shared error object.

So if a browser extension that hooks window.fetch leaks an unhandled copy of a fallback timeout, the event carries first-party frames (the createTimeoutSignal caller's fetch). It then escapes the dashboard's !hasFirstParty /signal timed out/ suppression (src/bootstrap/sentry-init.ts) and reads as our own bug. On engines that do record a DOMException stack, the reason carries the timer callback's first-party frames instead, so the same misattribution happens.

This is the same failure mode fixed for insights-loader in #8296 (WORLDMONITOR-125/12Z/11N), documented in docs/solutions/logic-errors/sentry-stack-backfill-makes-a-stackless-abort-reason-look-first-party.md (#8297). Native AbortSignal.timeout reasons already have stack === "TimeoutError: signal timed out", parse to zero frames, and are suppressed correctly. Only the fallback path diverges.

Scope / impact: only runtimes without AbortSignal.timeout. No production event has been attributed to this path yet; this is consistency hardening, not an active incident.

Proposed fix

Stamp the native header-only stack on the fallback reason, as #8296 did in src/services/insights-loader.ts abortInFlightRequest:

let reason: DOMException | undefined;
if (typeof DOMException === 'function') {
  reason = new DOMException('signal timed out', 'TimeoutError');
  Object.defineProperty(reason, 'stack', {
    value: 'TimeoutError: signal timed out',
    configurable: true,
    writable: true,
  });
}
controller.abort(reason);

Keep name === 'TimeoutError'. analytics-collector-transport.ts branches on it to tell timeouts from cancellations.

Constraints

  • Two bundles, byte-identical. pro-test/src/services/timeout-signal.ts must change identically. tests/marketing-mirror-parity.test.mts enforces the match.
  • Marketing surface keeps timeouts visible. pro-test/src/sentry-filter-policy.ts deliberately has no signal timed out suppression. After the stamp, a fallback timeout there still reports; it just loses its frames, which matches native engines. Update that file's comment, which says createTimeoutSignal "mints one on the pre-Baseline-2024 fallback path", to reflect the stamped shape.
  • Trade-off (dashboard). After the stamp, a first-party caller that leaks a fallback timeout unhandled and untagged becomes invisible, exactly as it already is on native engines. Callers that must surface a timeout have to catch it or report with a kind tag. The checkout transport already reports with one.

Callers affected on the fallback path:

  • Dashboard: checkout-transport, market, supply-chain, scorecard, telegram-intel, passkey-offer-reservation, NqCatalystsPanel, NqPulsePanel.
  • Marketing: checkout, checkout-transport, teasers, PricingSection, App.

Acceptance criteria

  • Both timeout-signal.ts copies stamp stack === 'TimeoutError: signal timed out' on the fallback reason; the name and message are unchanged.
  • The regression test forces the fallback (stub out AbortSignal.timeout) and swaps in a stackless DOMException subclass. That recreates Chromium; Node's DOMException already has a stack, so without the swap the test passes vacuously. The pattern is tests/insights-loader.test.mjs "aborts with a frameless stack". The test is red before the change and green after.
  • tests/marketing-mirror-parity.test.mts, tests/pro-timeout-signal.test.mts, tests/checkout-transport.test.mts and tests/sentry-beforesend.test.mjs all pass.
  • The stale comment in pro-test/src/sentry-filter-policy.ts is updated.
  • docs/solutions/logic-errors/sentry-stack-backfill-makes-a-stackless-abort-reason-look-first-party.md and the CONCEPTS.md "Stack Backfill" entry drop the "fallback not yet stamped" caveat.

Related: #8296, #8297, #8299