Skip to content

Commit acc4e81

Browse files
fix(sentry): filter TransformStream noise and close E2E detection gap for on_request_error events (#1145)
Add TransformStream 'transformAlgorithm is not a function' to isNextjsInternalNoise — a known Node.js web streams race condition during SSR with no app code in the stack trace. Add isolation scope fallback to isE2ETestRequest. Next.js's captureRequestError sets request headers in sdkProcessingMetadata but not in event.request, and event.contexts.browser is enriched by Sentry's ingestion pipeline after beforeSend runs. The new fallback reads the scope's normalizedRequest headers directly. Closes #1144 Co-authored-by: Ona <no-reply@ona.com>
1 parent c6a6c16 commit acc4e81

4 files changed

Lines changed: 171 additions & 7 deletions

File tree

.agents/conventions.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -480,15 +480,21 @@ filter these out:
480480
initialization (the SDK is loaded via dynamic `import()` in
481481
`instrumentation-client.ts`).
482482
- **Server-side**`sentry.server.config.ts` and `sentry.edge.config.ts` use
483-
`isE2ETestRequest(event)` which checks two sources:
483+
`isE2ETestRequest(event)` which checks four sources:
484484
1. `event.request.headers` for `HeadlessChrome/` in the User-Agent — works for
485485
unhandled exceptions where Sentry auto-attaches request context.
486486
2. `event.contexts.browser.name` for `HeadlessChrome` — fallback for manually
487487
captured exceptions (via `captureException`/`lazyCaptureException`) where
488488
`event.request` is empty but the SDK enriches browser context.
489-
490-
Both checks are needed because manually captured exceptions skip request
491-
header enrichment. Always check both paths when filtering E2E test noise.
489+
3. `event.extra.userAgent` for `HeadlessChrome/` — fallback for errors captured
490+
via `captureSupabaseError`/`captureApiError` which forward the request UA.
491+
4. Isolation scope's `sdkProcessingMetadata.normalizedRequest.headers` — fallback
492+
for `on_request_error` events (Next.js `captureRequestError`) where
493+
`event.request` is null and `event.contexts.browser` is only populated by
494+
Sentry's ingestion pipeline after `beforeSend` runs.
495+
496+
All four checks are needed because different capture mechanisms populate
497+
different fields. Always check all paths when filtering E2E test noise.
492498

493499
When adding new Sentry config files or `beforeSend` filters, use the
494500
consolidated filter functions: `shouldDropClientEvent` for client-side and

src/lib/sentry/e2e-detection.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ErrorEvent } from "@sentry/nextjs";
2+
import { getIsolationScope } from "@sentry/nextjs";
23

34
/**
45
* Returns true when the current browser session is an E2E test run.
@@ -62,5 +63,36 @@ export function isE2ETestRequest(event: ErrorEvent): boolean {
6263
const extraUa = event.extra?.userAgent;
6364
if (typeof extraUa === "string" && extraUa.includes("HeadlessChrome/")) return true;
6465

66+
// Fallback: check the isolation scope's normalizedRequest headers.
67+
// Next.js's `captureRequestError` (on_request_error hook) sets request
68+
// headers in sdkProcessingMetadata.normalizedRequest but does NOT populate
69+
// event.request — the requestDataIntegration applies it after beforeSend.
70+
// event.contexts.browser is also unavailable because Sentry's ingestion
71+
// pipeline enriches it after the SDK sends the event.
72+
if (isE2ETestFromScope()) return true;
73+
6574
return false;
6675
}
76+
77+
/**
78+
* Checks the current isolation scope's normalizedRequest headers for
79+
* HeadlessChrome. This catches on_request_error events where event.request
80+
* is null and event.contexts.browser is not yet populated.
81+
*/
82+
function isE2ETestFromScope(): boolean {
83+
try {
84+
const scopeData = getIsolationScope().getScopeData();
85+
const headers = scopeData.sdkProcessingMetadata?.normalizedRequest?.headers;
86+
if (!headers) return false;
87+
88+
const scopeUa =
89+
(headers as Record<string, string>)["user-agent"] ??
90+
(headers as Record<string, string>)["User-Agent"] ??
91+
"";
92+
return scopeUa.includes("HeadlessChrome/");
93+
} catch (_err: unknown) {
94+
// getIsolationScope may not be available in all contexts (e.g. tests).
95+
// Swallowing is intentional — this is a best-effort detection fallback.
96+
return false;
97+
}
98+
}

src/lib/sentry/event-filters.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { ErrorEvent } from "@sentry/nextjs";
22

33
/**
4-
* Error messages from Next.js internals that are caused by clients sending
5-
* malformed headers (e.g. RSC router state). These are not application bugs —
6-
* they originate from bots, crawlers, or browser quirks (Mobile Safari 17).
4+
* Error messages from Next.js/Node.js internals that are not application bugs.
5+
* Includes malformed client headers (bots, crawlers, Mobile Safari 17) and
6+
* Node.js web streams race conditions during SSR.
77
*/
88
const NEXTJS_INTERNAL_NOISE_PATTERNS = [
99
"The router state header was sent but could not be parsed",
10+
// Node.js internal web streams race condition during SSR. The TransformStream
11+
// controller's state is torn down before the transform algorithm runs.
12+
// Known issue: vercel/next.js#68319, vercel/next.js#75994.
13+
// Stack trace contains only Node.js internals — no app code is involved.
14+
"transformAlgorithm is not a function",
1015
];
1116

1217
/**

src/lib/sentry/sentry.unit.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { PostgrestError } from "@supabase/supabase-js";
44

55
const captureExceptionMock = vi.fn();
66

7+
// Mock isolation scope for isE2ETestFromScope tests
8+
let mockScopeData: Record<string, unknown> = {};
9+
710
vi.mock("@sentry/nextjs", () => ({
811
captureException: (...args: unknown[]) => captureExceptionMock(...args),
12+
getIsolationScope: () => ({
13+
getScopeData: () => mockScopeData,
14+
}),
915
}));
1016

1117
import {
@@ -1030,6 +1036,27 @@ describe("isNextjsInternalNoise", () => {
10301036
const event = makeSentryEvent([{ type: "Error" }]);
10311037
expect(isNextjsInternalNoise(event)).toBe(false);
10321038
});
1039+
1040+
it("detects Node.js TransformStream race condition (MEMO-2G)", () => {
1041+
const event = makeSentryEvent([
1042+
{
1043+
type: "TypeError",
1044+
value: "controller[kState].transformAlgorithm is not a function",
1045+
},
1046+
]);
1047+
expect(isNextjsInternalNoise(event)).toBe(true);
1048+
});
1049+
1050+
it("detects TransformStream error as substring in chained exceptions", () => {
1051+
const event = makeSentryEvent([
1052+
{ type: "Error", value: "Some wrapper error" },
1053+
{
1054+
type: "TypeError",
1055+
value: "controller[kState].transformAlgorithm is not a function",
1056+
},
1057+
]);
1058+
expect(isNextjsInternalNoise(event)).toBe(true);
1059+
});
10331060
});
10341061

10351062
/**
@@ -1667,6 +1694,65 @@ describe("isE2ETestRequest", () => {
16671694
} as unknown as ErrorEvent;
16681695
expect(isE2ETestRequest(event)).toBe(false);
16691696
});
1697+
1698+
it("returns true when isolation scope has HeadlessChrome in normalizedRequest headers (MEMO-2G on_request_error)", () => {
1699+
mockScopeData = {
1700+
sdkProcessingMetadata: {
1701+
normalizedRequest: {
1702+
headers: {
1703+
"user-agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
1704+
},
1705+
},
1706+
},
1707+
};
1708+
const event = { type: undefined } as ErrorEvent;
1709+
expect(isE2ETestRequest(event)).toBe(true);
1710+
mockScopeData = {};
1711+
});
1712+
1713+
it("returns true when isolation scope has HeadlessChrome in User-Agent (capitalized)", () => {
1714+
mockScopeData = {
1715+
sdkProcessingMetadata: {
1716+
normalizedRequest: {
1717+
headers: {
1718+
"User-Agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
1719+
},
1720+
},
1721+
},
1722+
};
1723+
const event = { type: undefined } as ErrorEvent;
1724+
expect(isE2ETestRequest(event)).toBe(true);
1725+
mockScopeData = {};
1726+
});
1727+
1728+
it("returns false when isolation scope has normal Chrome in normalizedRequest headers", () => {
1729+
mockScopeData = {
1730+
sdkProcessingMetadata: {
1731+
normalizedRequest: {
1732+
headers: {
1733+
"user-agent": "Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36",
1734+
},
1735+
},
1736+
},
1737+
};
1738+
const event = { type: undefined } as ErrorEvent;
1739+
expect(isE2ETestRequest(event)).toBe(false);
1740+
mockScopeData = {};
1741+
});
1742+
1743+
it("returns false when isolation scope has no normalizedRequest", () => {
1744+
mockScopeData = { sdkProcessingMetadata: {} };
1745+
const event = { type: undefined } as ErrorEvent;
1746+
expect(isE2ETestRequest(event)).toBe(false);
1747+
mockScopeData = {};
1748+
});
1749+
1750+
it("returns false when isolation scope has no sdkProcessingMetadata", () => {
1751+
mockScopeData = {};
1752+
const event = { type: undefined } as ErrorEvent;
1753+
expect(isE2ETestRequest(event)).toBe(false);
1754+
mockScopeData = {};
1755+
});
16701756
});
16711757

16721758
describe("shouldDropServerEvent", () => {
@@ -1734,4 +1820,39 @@ describe("shouldDropServerEvent", () => {
17341820
} as unknown as ErrorEvent;
17351821
expect(shouldDropServerEvent(event)).toBe(false);
17361822
});
1823+
1824+
it("drops Node.js TransformStream race condition errors (MEMO-2G)", () => {
1825+
const event = {
1826+
type: undefined,
1827+
exception: {
1828+
values: [
1829+
{
1830+
type: "TypeError",
1831+
value: "controller[kState].transformAlgorithm is not a function",
1832+
},
1833+
],
1834+
},
1835+
} as unknown as ErrorEvent;
1836+
expect(shouldDropServerEvent(event)).toBe(true);
1837+
});
1838+
1839+
it("drops on_request_error events from E2E tests via isolation scope (MEMO-2G)", () => {
1840+
mockScopeData = {
1841+
sdkProcessingMetadata: {
1842+
normalizedRequest: {
1843+
headers: {
1844+
"user-agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
1845+
},
1846+
},
1847+
},
1848+
};
1849+
const event = {
1850+
type: undefined,
1851+
exception: {
1852+
values: [{ value: "Some server error" }],
1853+
},
1854+
} as unknown as ErrorEvent;
1855+
expect(shouldDropServerEvent(event)).toBe(true);
1856+
mockScopeData = {};
1857+
});
17371858
});

0 commit comments

Comments
 (0)