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
22 changes: 16 additions & 6 deletions js/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ try {
}
```

Install once, early in application startup. Global errors and unhandled promise
Install once, early in application startup. A second `installBrowserErrors` call
on the same window returns the existing installation instead of duplicating
listeners; dispose it first to reinstall with different options. Calling it where
no `window` exists throws. Global errors and unhandled promise
rejections are captured without suppressing the browser's handlers. Page URLs,
user agent and language are included; query strings, fragments and URL credentials
are removed. Popstate/hash changes add navigation breadcrumbs. No form values,
Expand Down Expand Up @@ -117,12 +120,19 @@ value in free text. The hook is synchronous; a thrown exception drops that event
`sampleRate` is 0–1, default 1, and sampling happens before queueing.

The in-memory queue holds 30 events by default (`maxQueueSize`, maximum 100).
Events exceeding 60 KB or a full queue are dropped. Delivery retries network
failures, `408`, `429` and `5xx` up to five attempts, preserving exact bytes and ID.
It respects `Retry-After` and uses exponential backoff. Events older than five
minutes are discarded; other `4xx` responses are terminal. `getStats()` returns
An event above 60 KB first loses its breadcrumbs, then its context and most of
its raw stack; `context.truncated` records what was removed. Only an exception
that is still too large is dropped. A full queue drops the new event. Frames
passed to `captureException` are validated and limited to 100 entries. Delivery
retries network failures, `408`, `429` and `5xx` up to five attempts, preserving
exact bytes and ID. It respects `Retry-After` and uses exponential backoff.
Events older than five minutes are discarded; other `4xx` responses are terminal.
A `401` or `403` means the key or origin is rejected for every event: queued
events are dropped and capture pauses for one minute, doubling up to 32 minutes
while rejections continue (`getStats().paused`, diagnostic type `paused`). The
pause ends on its own; fix the ingestion key or origin configuration. `getStats()` returns
sent, dropped, retried and queued counts, plus `droppedByReason`. An optional
`onDiagnostic` callback reports drops and retries without event payloads. Its
`onDiagnostic` callback reports drops, retries and pauses without event payloads. Its
exceptions are ignored and error capture inside that callback is suppressed to
avoid recursion. Use a separate health channel to monitor delivery losses. No delivery failure creates another
telemetry error. Browser visibility changes request a flush with `keepalive`. Flush deadlines
Expand Down
8 changes: 8 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@
"esbuild": "^0.28.1",
"brace-expansion": "^5.0.8"
},
"typesVersions": {
"*": {
"browser": ["./dist/browser.d.ts"],
"react-native": ["./dist/react-native.d.ts"],
"nuxt": ["./dist/nuxt.d.ts"],
"react": ["./dist/react.d.ts"]
}
},
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
133 changes: 133 additions & 0 deletions js/src/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ErrorClient, parseStack, sanitize } from "../errors/core";
import type { ErrorDiagnostic } from "../errors/core";
import {
installReactNativeErrors,
type GlobalErrorHandler,
Expand Down Expand Up @@ -335,3 +336,135 @@ it("native fatal errors start delivery before invoking the existing crash handle
await reporting.client.flush();
reporting.dispose();
});

it("rejects endpoints with credentials, queries or non-HTTP schemes without URL getters", () => {
for (const endpoint of [
"https://u:p@host/x",
"https://host/x?y=1",
"https://host/x#f",
"ftp://host/x",
"host/x",
])
expect(() => new ErrorClient({ ...options, endpoint })).toThrow(
"endpoint",
);
clients.push(
new ErrorClient({
...options,
endpoint: "http://localhost:3009/api/v1/client-errors",
}),
);
});

it("pauses capture after a rejected key and resumes after the backoff", async () => {
const diagnostics: ErrorDiagnostic[] = [];
const fetch = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
const client = new ErrorClient({
...options,
fetch,
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
});
clients.push(client);
client.captureException(new Error("one"));
client.captureException(new Error("two"));
await client.flush();
expect(fetch).toHaveBeenCalledTimes(1);
expect(client.getStats()).toMatchObject({
paused: true,
queued: 0,
droppedByReason: { rejected: 2 },
});
expect(client.captureException(new Error("three"))).toBeUndefined();
expect(client.getStats().droppedByReason.paused).toBe(1);
expect(diagnostics.find((d) => d.type === "paused")).toMatchObject({
status: 401,
until: expect.any(Number),
});
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + 61000);
fetch.mockResolvedValue(new Response("{}", { status: 201 }));
expect(client.captureException(new Error("four"))).toBeDefined();
await client.flush();
expect(client.getStats()).toMatchObject({ sent: 1, paused: false });
});

it("sheds breadcrumbs, then context and raw stack, before dropping an oversize event", async () => {
const { client, fetch } = setup();
for (let i = 0; i < 50; i++)
client.addBreadcrumb({ category: "log", message: "x".repeat(1000) });
client.setContext(
Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [`k${i}`, "y".repeat(2000)]),
),
);
client.captureException(new Error("big"));
await client.flush();
const first = JSON.parse(fetch.mock.calls[0][1].body);
expect(first.breadcrumbs).toEqual([]);
expect(first.context.truncated).toBe("breadcrumbs");
expect(first.context.k0).toHaveLength(2000);

client.clearContext();
client.setContext(
Object.fromEntries(
Array.from({ length: 30 }, (_, i) => [`k${i}`, "y".repeat(2000)]),
),
);
const error = new Error("bigger");
error.stack =
"Error: bigger\n" + " at f (https://app.test/a.js:1:1)\n".repeat(900);
client.captureException(error);
await client.flush();
const second = JSON.parse(fetch.mock.calls[1][1].body);
expect(second.context).toEqual({
session_id: expect.any(String),
truncated: "context",
});
expect(second.exception.stack.length).toBeLessThanOrEqual(8000);
expect(second.exception.frames).toHaveLength(100);
expect(client.getStats().dropped).toBe(0);
});

it("normalizes caller-supplied frames to the server contract", async () => {
const { client, fetch } = setup();
const frames = Array.from({ length: 120 }, (_, i) => ({
filename: `https://app.test/a.js?v=${i}`,
line: i + 1,
column: i,
}));
client.captureException(new Error("frames"), {
frames: [
{ filename: "", line: 1, column: 0 },
{ filename: "x", line: 0, column: 0 },
...frames,
],
});
await client.flush();
const body = JSON.parse(fetch.mock.calls[0][1].body);
expect(body.exception.frames).toHaveLength(100);
expect(body.exception.frames[0]).toEqual({
filename: "https://app.test/a.js",
line: 1,
column: 0,
});
});

it("installs browser reporting once per window", () => {
const target = Object.assign(new EventTarget(), {
location: { href: "https://app.test/" },
document: Object.assign(new EventTarget(), {
visibilityState: "visible",
}),
}) as unknown as Window;
const first = installBrowserErrors({ ...options, fetch: vi.fn() }, target);
clients.push(first.client);
expect(installBrowserErrors({ ...options, fetch: vi.fn() }, target)).toBe(
first,
);
first.dispose();
const second = installBrowserErrors({ ...options, fetch: vi.fn() }, target);
clients.push(second.client);
expect(second).not.toBe(first);
second.dispose();
expect(() => installBrowserErrors(options)).toThrow("window");
});
33 changes: 30 additions & 3 deletions js/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,34 @@ import { ErrorClient, cleanUrl } from "./errors/core";
import type { ErrorClientOptions } from "./errors/core";
export * from "./errors/core";

export interface BrowserErrorReporting {
client: ErrorClient;
dispose(): void;
}
const installations = new WeakMap<object, BrowserErrorReporting>();
/**
* Installs once per window. A second call (hot module replacement, a plugin
* registered twice) returns the existing installation instead of duplicating
* listeners and deliveries. Dispose it first to reinstall with new options.
*/
export function installBrowserErrors(
options: Omit<ErrorClientOptions, "platform">,
target: Window = window,
) {
target?: Window,
): BrowserErrorReporting {
const resolved =
target ?? (typeof window === "undefined" ? undefined : window);
if (!resolved)
throw new Error(
"installBrowserErrors requires a window; call it from client-only code",
);
const existing = installations.get(resolved);
if (existing) return existing;
return install(options, resolved);
}
function install(
options: Omit<ErrorClientOptions, "platform">,
target: Window,
): BrowserErrorReporting {
const client = new ErrorClient({ ...options, platform: "browser" });
const page = () => {
client.setContext({
Expand Down Expand Up @@ -66,9 +90,10 @@ export function installBrowserErrors(
target.addEventListener("hashchange", onNavigation);
target.addEventListener("online", onOnline);
target.document.addEventListener("visibilitychange", onHidden);
return {
const reporting: BrowserErrorReporting = {
client,
dispose() {
if (installations.get(target) === reporting) installations.delete(target);
target.removeEventListener("error", onError);
target.removeEventListener("unhandledrejection", onRejection);
target.removeEventListener("popstate", onNavigation);
Expand All @@ -78,4 +103,6 @@ export function installBrowserErrors(
client.close();
},
};
installations.set(target, reporting);
return reporting;
}
Loading
Loading