Skip to content
Merged
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
14 changes: 10 additions & 4 deletions .agents/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,15 +480,21 @@ filter these out:
initialization (the SDK is loaded via dynamic `import()` in
`instrumentation-client.ts`).
- **Server-side** — `sentry.server.config.ts` and `sentry.edge.config.ts` use
`isE2ETestRequest(event)` which checks two sources:
`isE2ETestRequest(event)` which checks four sources:
1. `event.request.headers` for `HeadlessChrome/` in the User-Agent — works for
unhandled exceptions where Sentry auto-attaches request context.
2. `event.contexts.browser.name` for `HeadlessChrome` — fallback for manually
captured exceptions (via `captureException`/`lazyCaptureException`) where
`event.request` is empty but the SDK enriches browser context.

Both checks are needed because manually captured exceptions skip request
header enrichment. Always check both paths when filtering E2E test noise.
3. `event.extra.userAgent` for `HeadlessChrome/` — fallback for errors captured
via `captureSupabaseError`/`captureApiError` which forward the request UA.
4. Isolation scope's `sdkProcessingMetadata.normalizedRequest.headers` — fallback
for `on_request_error` events (Next.js `captureRequestError`) where
`event.request` is null and `event.contexts.browser` is only populated by
Sentry's ingestion pipeline after `beforeSend` runs.

All four checks are needed because different capture mechanisms populate
different fields. Always check all paths when filtering E2E test noise.

When adding new Sentry config files or `beforeSend` filters, use the
consolidated filter functions: `shouldDropClientEvent` for client-side and
Expand Down
32 changes: 32 additions & 0 deletions src/lib/sentry/e2e-detection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ErrorEvent } from "@sentry/nextjs";
import { getIsolationScope } from "@sentry/nextjs";

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

// Fallback: check the isolation scope's normalizedRequest headers.
// Next.js's `captureRequestError` (on_request_error hook) sets request
// headers in sdkProcessingMetadata.normalizedRequest but does NOT populate
// event.request — the requestDataIntegration applies it after beforeSend.
// event.contexts.browser is also unavailable because Sentry's ingestion
// pipeline enriches it after the SDK sends the event.
if (isE2ETestFromScope()) return true;

return false;
}

/**
* Checks the current isolation scope's normalizedRequest headers for
* HeadlessChrome. This catches on_request_error events where event.request
* is null and event.contexts.browser is not yet populated.
*/
function isE2ETestFromScope(): boolean {
try {
const scopeData = getIsolationScope().getScopeData();
const headers = scopeData.sdkProcessingMetadata?.normalizedRequest?.headers;
if (!headers) return false;

const scopeUa =
(headers as Record<string, string>)["user-agent"] ??
(headers as Record<string, string>)["User-Agent"] ??
"";
return scopeUa.includes("HeadlessChrome/");
} catch (_err: unknown) {
// getIsolationScope may not be available in all contexts (e.g. tests).
// Swallowing is intentional — this is a best-effort detection fallback.
return false;
}
}
11 changes: 8 additions & 3 deletions src/lib/sentry/event-filters.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { ErrorEvent } from "@sentry/nextjs";

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

/**
Expand Down
121 changes: 121 additions & 0 deletions src/lib/sentry/sentry.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import { PostgrestError } from "@supabase/supabase-js";

const captureExceptionMock = vi.fn();

// Mock isolation scope for isE2ETestFromScope tests
let mockScopeData: Record<string, unknown> = {};

vi.mock("@sentry/nextjs", () => ({
captureException: (...args: unknown[]) => captureExceptionMock(...args),
getIsolationScope: () => ({
getScopeData: () => mockScopeData,
}),
}));

import {
Expand Down Expand Up @@ -1030,6 +1036,27 @@ describe("isNextjsInternalNoise", () => {
const event = makeSentryEvent([{ type: "Error" }]);
expect(isNextjsInternalNoise(event)).toBe(false);
});

it("detects Node.js TransformStream race condition (MEMO-2G)", () => {
const event = makeSentryEvent([
{
type: "TypeError",
value: "controller[kState].transformAlgorithm is not a function",
},
]);
expect(isNextjsInternalNoise(event)).toBe(true);
});

it("detects TransformStream error as substring in chained exceptions", () => {
const event = makeSentryEvent([
{ type: "Error", value: "Some wrapper error" },
{
type: "TypeError",
value: "controller[kState].transformAlgorithm is not a function",
},
]);
expect(isNextjsInternalNoise(event)).toBe(true);
});
});

/**
Expand Down Expand Up @@ -1667,6 +1694,65 @@ describe("isE2ETestRequest", () => {
} as unknown as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(false);
});

it("returns true when isolation scope has HeadlessChrome in normalizedRequest headers (MEMO-2G on_request_error)", () => {
mockScopeData = {
sdkProcessingMetadata: {
normalizedRequest: {
headers: {
"user-agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
},
},
},
};
const event = { type: undefined } as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(true);
mockScopeData = {};
});

it("returns true when isolation scope has HeadlessChrome in User-Agent (capitalized)", () => {
mockScopeData = {
sdkProcessingMetadata: {
normalizedRequest: {
headers: {
"User-Agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
},
},
},
};
const event = { type: undefined } as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(true);
mockScopeData = {};
});

it("returns false when isolation scope has normal Chrome in normalizedRequest headers", () => {
mockScopeData = {
sdkProcessingMetadata: {
normalizedRequest: {
headers: {
"user-agent": "Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36",
},
},
},
};
const event = { type: undefined } as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(false);
mockScopeData = {};
});

it("returns false when isolation scope has no normalizedRequest", () => {
mockScopeData = { sdkProcessingMetadata: {} };
const event = { type: undefined } as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(false);
mockScopeData = {};
});

it("returns false when isolation scope has no sdkProcessingMetadata", () => {
mockScopeData = {};
const event = { type: undefined } as ErrorEvent;
expect(isE2ETestRequest(event)).toBe(false);
mockScopeData = {};
});
});

describe("shouldDropServerEvent", () => {
Expand Down Expand Up @@ -1734,4 +1820,39 @@ describe("shouldDropServerEvent", () => {
} as unknown as ErrorEvent;
expect(shouldDropServerEvent(event)).toBe(false);
});

it("drops Node.js TransformStream race condition errors (MEMO-2G)", () => {
const event = {
type: undefined,
exception: {
values: [
{
type: "TypeError",
value: "controller[kState].transformAlgorithm is not a function",
},
],
},
} as unknown as ErrorEvent;
expect(shouldDropServerEvent(event)).toBe(true);
});

it("drops on_request_error events from E2E tests via isolation scope (MEMO-2G)", () => {
mockScopeData = {
sdkProcessingMetadata: {
normalizedRequest: {
headers: {
"user-agent": "Mozilla/5.0 HeadlessChrome/147.0.0.0 Safari/537.36",
},
},
},
};
const event = {
type: undefined,
exception: {
values: [{ value: "Some server error" }],
},
} as unknown as ErrorEvent;
expect(shouldDropServerEvent(event)).toBe(true);
mockScopeData = {};
});
});
Loading