Files
nexu-io-open-design/apps/web/tests/analytics/error-tracking.test.ts
lefarcen dab92a8cf2 fix(analytics): stamp frame platform + drop fetch-failure noise in exception transport (#4661)
* fix(analytics): stamp frame platform + drop fetch-failure noise in exception transport

The hand-rolled browser-exception transport in error-tracking.ts (the sole
$exception source since client.ts sets capture_exceptions:false) had two bugs
that PostHog's weekly digest surfaced as "512K exceptions / 511K failed to
ingest":

1. Frames never carried `platform`. PostHog's exception ingestion requires it
   per frame to pick the symbolication/grouping strategy; without it every
   event failed processing with "missing field platform" and was dropped
   server-side. 100% of our ingested $exception events had platform=(missing),
   all from capture_source=web/error-tracking. Now stamped 'web:javascript' on
   every frame, matching posthog-js.

2. `TypeError: Failed to fetch` from the packaged app's daemon connection
   dropping (restart, boot race, navigation/unmount abort, offline) was ~90% of
   all captured exceptions — environmental noise, not bugs, one polling loop can
   emit thousands. Dropped at the capture chokepoint along with AbortError and
   the WebKit/Firefox network-error wordings.

Also hardens the telemetry beacon: `void fetch(...)` had only a synchronous
try/catch, so a failed beacon rejected unhandled and got re-captured as its own
Failed to fetch — a self-amplifying loop. Now `.catch()`-ed.

Red specs in error-tracking.test.ts go red on main (frames lack platform; noise
is dispatched) and green here.

* fix(analytics): scope fetch-noise drop to packaged runtime, stop dropping AbortError

Address review: captureException is the single chokepoint for window.error /
unhandledrejection / reportHandledException, so a blanket drop of
`Failed to fetch` would also suppress a real broken-/api/* or CORS/TLS signal
from the normal web app — and blanket-dropping AbortError was never justified
by the data.

- Gate the fetch-failure drop on the exception originating in packaged app
  code (od:// stack origin), which is where the ~90% daemon-connection churn
  lives. The same TypeError from a web (http/https) context stays captured.
- Remove the AbortError drop entirely (not a measured noise source, and not
  necessarily fetch-lifecycle cancellation).

Test now asserts the invariant directly: packaged od:// fetch noise dropped,
the identical web-origin error kept, AbortError kept.

* test(analytics): cover beacon-failure self-amplification backstop

Address review: add a regression that a failed telemetry beacon, surfacing as
an unhandledrejection from od:// transport code, does not re-enter capture as a
second $exception/beacon. Exercises the packaged-noise backstop (the
loop-breaking layer observable under jsdom; dispatch()'s .catch() is a
process-level promise rejection that jsdom does not route to
window.onunhandledrejection, so it is not unit-observable here).

* fix(analytics): recognize file:// app-bundle frames as packaged origin

Address blocking review: packaged exceptions don't only arrive as od:// frames
— source-mapped frames surface as file:///.../<Channel>.app/Contents/Resources/...
(the shape scrub.ts rewrites). The od://-only check let those packaged
fetch-failures leak through. Broaden originatesInPackagedApp to also match the
.app/Contents/Resources bundle marker (channel-agnostic, so Beta/Preview builds
are covered). Regression added: file:// app-bundle Failed-to-fetch stacks are
dropped too; red against the od://-only check, green here.
2026-06-23 12:41:56 +00:00

422 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearExceptionTrackingContext,
installErrorHandlers,
patchExceptionTrackingAppVersion,
reportHandledException,
setExceptionTrackingContext,
} from '../../src/analytics/error-tracking';
/**
* These tests exercise the always-on, consent-independent exception
* pipeline. They do NOT touch posthog-js. The pipeline's full contract:
*
* 1. `installErrorHandlers()` hooks `window.error` and
* `unhandledrejection` at module load. Idempotent.
* 2. Captured exceptions buffer in memory until
* `setExceptionTrackingContext({apiKey, host, distinctId})` arrives.
* 3. Once a context is set, buffered events drain to the PostHog ingest
* endpoint via `fetch`. Subsequent captures dispatch immediately.
* 4. Buffer is capped at 50 entries to bound memory in an error loop.
* 5. Scrubbing (file path redaction) runs before dispatch.
*/
const fetchMock = vi.fn();
const ORIGINAL_FETCH = globalThis.fetch;
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('', { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
clearExceptionTrackingContext();
});
afterEach(() => {
clearExceptionTrackingContext();
globalThis.fetch = ORIGINAL_FETCH;
});
function lastFetchedBody(): Record<string, unknown> {
expect(fetchMock).toHaveBeenCalled();
const lastCall = fetchMock.mock.calls.at(-1)!;
const init = lastCall[1] as RequestInit;
return JSON.parse(init.body as string) as Record<string, unknown>;
}
describe('error-tracking', () => {
it('buffers captures dispatched before a context is set, then flushes', async () => {
installErrorHandlers();
// Fire a captured (handled) exception BEFORE the context is wired up.
reportHandledException(new Error('early-boom'));
expect(fetchMock).not.toHaveBeenCalled();
// Now the bootstrap completes — buffer should drain.
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-1',
appVersion: '1.2.3',
sessionId: 'session-abc',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastFetchedBody();
expect(body).toMatchObject({
api_key: 'phc_test',
event: '$exception',
distinct_id: 'user-1',
properties: expect.objectContaining({
$exception_type: 'Error',
$exception_message: 'early-boom',
app_version: '1.2.3',
session_id: 'session-abc',
}),
});
});
it('dispatches immediately when a context is already set', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-2',
});
reportHandledException(new TypeError('immediate'));
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'TypeError',
$exception_message: 'immediate',
handled: true,
});
});
it('captures unhandledrejection events via the window hook', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-3',
});
// jsdom's PromiseRejectionEvent constructor exists but doesn't auto-fire
// when a promise rejects; we synthesize one to drive the listener.
const reason = new RangeError('boom-async');
const event = new Event('unhandledrejection') as Event & {
reason?: unknown;
promise?: Promise<unknown>;
};
event.reason = reason;
event.promise = Promise.reject(reason);
// Silence Node's actual unhandled-rejection warning on the synthesized
// promise. jsdom forwards it to process otherwise.
event.promise.catch(() => undefined);
window.dispatchEvent(event);
expect(fetchMock).toHaveBeenCalled();
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'RangeError',
$exception_message: 'boom-async',
handled: false,
});
});
it('captures synchronous window.error events', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-4',
});
const error = new Error('sync-boom');
const event = new Event('error') as Event & {
error?: unknown;
message?: string;
filename?: string;
lineno?: number;
colno?: number;
};
event.error = error;
event.message = 'sync-boom';
event.filename = 'app://apps/web/src/foo.tsx';
event.lineno = 42;
event.colno = 7;
window.dispatchEvent(event);
expect(fetchMock).toHaveBeenCalled();
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'Error',
$exception_message: 'sync-boom',
handled: false,
});
});
it('caps the buffer at 50 entries to bound memory in an error loop', () => {
// No context — every capture lands in the buffer.
for (let i = 0; i < 75; i += 1) {
reportHandledException(new Error(`loop-${i}`));
}
expect(fetchMock).not.toHaveBeenCalled();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-loop',
});
// Buffer cap is 50; the oldest 25 should have been evicted in FIFO order.
expect(fetchMock).toHaveBeenCalledTimes(50);
const firstBody = JSON.parse(
(fetchMock.mock.calls[0]![1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(
(firstBody.properties as Record<string, unknown>).$exception_message,
).toBe('loop-25');
const lastBody = lastFetchedBody();
expect(
(lastBody.properties as Record<string, unknown>).$exception_message,
).toBe('loop-74');
});
it('scrubs absolute filesystem paths from stack frames before dispatch', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-scrub',
});
// Synthesize an Error with a stack that contains a packaged-app path —
// the scrub layer should rewrite it to the repo-relative form.
const error = new Error('scrub-target');
error.stack = [
'Error: scrub-target',
' at handleClick (file:///Applications/Open Design.app/Contents/Resources/apps/web/src/FileViewer.tsx:147:23)',
' at /Users/jane/dev/checkout/apps/web/src/index.tsx:12:1',
].join('\n');
reportHandledException(error);
const body = lastFetchedBody();
const list = (body.properties as Record<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
const frames = list[0]?.stacktrace?.frames;
expect(frames).toBeTruthy();
expect(frames!.length).toBeGreaterThanOrEqual(2);
for (const frame of frames!) {
const filename = frame.filename;
if (typeof filename === 'string') {
expect(filename).toMatch(/^app:\/\/apps\/web\//);
expect(filename).not.toContain('Applications/Open Design.app');
expect(filename).not.toContain('/Users/jane');
}
}
});
it('treats reportHandledException string input as a non-Error message', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-str',
});
reportHandledException('weird global signal');
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_message: 'weird global signal',
handled: true,
});
});
it('drops events silently when no context is ever set (no key in env)', () => {
reportHandledException(new Error('orphan'));
expect(fetchMock).not.toHaveBeenCalled();
// Even after explicitly clearing — the buffer is bounded and harmless.
clearExceptionTrackingContext();
expect(fetchMock).not.toHaveBeenCalled();
});
it('patchExceptionTrackingAppVersion updates appVersion on dispatched events', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-patch',
appVersion: '0.0.0',
});
patchExceptionTrackingAppVersion('1.2.3');
reportHandledException(new Error('patched'));
const body = lastFetchedBody();
expect((body.properties as Record<string, unknown>).app_version).toBe('1.2.3');
});
it('patchExceptionTrackingAppVersion is a no-op when no context is set', () => {
expect(() => patchExceptionTrackingAppVersion('1.2.3')).not.toThrow();
reportHandledException(new Error('no context'));
expect(fetchMock).not.toHaveBeenCalled();
});
// Regression: 100% of our hand-built `$exception` events were failing to
// ingest with "missing field platform" because the frames omitted the
// `platform` key PostHog's exception pipeline requires. posthog-js stamps
// `web:javascript` on every frame; we must too.
it('stamps platform on every stack frame so PostHog ingestion accepts the event', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-platform',
});
const error = new Error('needs-platform');
error.stack = [
'Error: needs-platform',
' at handleClick (app://apps/web/src/FileViewer.tsx:147:23)',
' at app://apps/web/src/index.tsx:12:1',
].join('\n');
reportHandledException(error);
const body = lastFetchedBody();
const list = (body.properties as Record<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
const frames = list[0]?.stacktrace?.frames;
expect(frames).toBeTruthy();
expect(frames!.length).toBeGreaterThanOrEqual(2);
for (const frame of frames!) {
expect(frame.platform).toBe('web:javascript');
}
});
// Regression: `TypeError: Failed to fetch` from the packaged renderer's
// daemon connection dropping (restart, boot race, navigation abort,
// offline) was ~90% of all captured exceptions — environmental noise.
// Drop it, but ONLY when it originates in packaged app code (od:// scheme).
it('drops packaged-app fetch noise but keeps the same error from the web app', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-noise',
});
// Packaged: the failing fetch ran in od:// app code → dropped.
const packaged = new TypeError('Failed to fetch');
packaged.stack = [
'TypeError: Failed to fetch',
' at window.fetch (od://app/_next/static/chunks/abc.js:1:100)',
' at poll (od://app/_next/static/chunks/abc.js:1:200)',
].join('\n');
reportHandledException(packaged);
expect(fetchMock).not.toHaveBeenCalled();
// Web app: SAME message, but the fetch failed in non-packaged code. In a
// browser this can be the only signal of a broken /api/* deploy or a
// CORS/TLS regression, so it must stay captured.
const web = new TypeError('Failed to fetch');
web.stack = [
'TypeError: Failed to fetch',
' at loadProjects (https://app.example.com/_next/static/chunks/x.js:1:50)',
].join('\n');
reportHandledException(web);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((lastFetchedBody().properties as Record<string, unknown>).$exception_message).toBe(
'Failed to fetch',
);
});
// Packaged exceptions don't only arrive as od:// frames — source-mapped
// frames surface as `file:///…/<Channel>.app/Contents/Resources/…` (the
// shape scrub.ts rewrites). Those packaged fetch failures must be dropped
// too, otherwise part of the noise path leaks through.
it('drops packaged fetch noise from file:// app-bundle stack frames', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-bundle',
});
const bundled = new TypeError('Failed to fetch');
bundled.stack = [
'TypeError: Failed to fetch',
' at fetchProjects (file:///Applications/Open Design.app/Contents/Resources/apps/web/src/state/projects.ts:88:14)',
].join('\n');
reportHandledException(bundled);
expect(fetchMock).not.toHaveBeenCalled();
// Beta/preview channels use a different app-bundle name — still dropped.
const beta = new TypeError('Failed to fetch');
beta.stack = [
'TypeError: Failed to fetch',
' at fetchProjects (file:///Applications/Open Design Beta.app/Contents/Resources/apps/web/src/state/projects.ts:88:14)',
].join('\n');
reportHandledException(beta);
expect(fetchMock).not.toHaveBeenCalled();
});
// AbortError is NOT blanket-dropped: it isn't necessarily fetch-lifecycle
// cancellation and was never a measured noise source. It flows through.
it('does not drop AbortError', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-abort',
});
const aborted = new Error('the operation was aborted.');
aborted.name = 'AbortError';
aborted.stack = 'AbortError: the operation was aborted.\n at x (od://app/_next/static/chunks/y.js:1:1)';
reportHandledException(aborted);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((lastFetchedBody().properties as Record<string, unknown>).$exception_type).toBe(
'AbortError',
);
});
// Regression: a failed telemetry beacon (PostHog unreachable) must not
// manufacture another exception. In the real browser the beacon's own
// `fetch` rejection is swallowed by the `.catch()` in dispatch(); if it
// ever surfaces anyway — as an unhandledrejection whose TypeError
// originates in our od:// transport code — the packaged noise filter is the
// backstop that stops it re-entering as a second `$exception`/beacon. This
// exercises that backstop (the part observable under jsdom — Node routes
// promise rejections to `process`, not `window.onunhandledrejection`, so
// the `.catch()` itself isn't unit-observable here).
it('a failed beacon surfacing as an unhandledrejection does not spawn a second beacon', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-loop',
});
// A genuine exception dispatches exactly one beacon.
reportHandledException(new Error('real-bug'));
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockClear();
// Now simulate that beacon's own request failing and surfacing as an
// unhandledrejection from our od:// transport code.
const beaconFailure = new TypeError('Failed to fetch');
beaconFailure.stack = [
'TypeError: Failed to fetch',
' at dispatch (od://app/_next/static/chunks/error-tracking.js:1:42)',
].join('\n');
const rejection = new Event('unhandledrejection') as Event & {
reason?: unknown;
promise?: Promise<unknown>;
};
rejection.reason = beaconFailure;
rejection.promise = Promise.reject(beaconFailure);
rejection.promise.catch(() => undefined);
window.dispatchEvent(rejection);
// No second beacon — the loop is broken.
expect(fetchMock).not.toHaveBeenCalled();
});
});