diff --git a/package-lock.json b/package-lock.json
index 258b74d..fef5898 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4112,7 +4112,7 @@
},
"packages/sdk": {
"name": "@flaggr/sdk",
- "version": "0.5.0",
+ "version": "0.6.0",
"license": "MIT",
"dependencies": {
"@flaggr/evaluator": "0.3.0"
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index f0454f6..fca8f54 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -96,6 +96,17 @@ const client = createFlaggr({ serviceId: 'web-app', apiKey: 'fgr_xxx', exposeFla
data-api-url="https://api.flaggr.dev" data-expose-flags="false" defer>
```
+## Error tracking
+
+```ts
+import { createFlaggr, flaggrErrors, captureException } from "@flaggr/sdk";
+
+createFlaggr({ serviceId: "web-app", apiKey: "fgr_...", plugins: [flaggrErrors({ release: "web@1.4.2" })] });
+captureException(error, { tags: { step: "pay" } });
+```
+
+Uncaught errors are reported automatically, with the flag variants the client evaluated and the breadcrumbs that led up to them. They show up as issues in the Flaggr console. On a server, set `FLAGGR_ERRORS_TOKEN` (or `serverToken`) to a project API token. See [Error Tracking](https://flaggr.dev/docs/sdk/typescript-sdk#error-tracking).
+
## OpenTelemetry
```typescript
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 9a74ef8..2e67a28 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@flaggr/sdk",
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "Flaggr feature flag SDK for TypeScript \u2014 type-safe evaluation with pluggable OpenTelemetry instrumentation",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts
index 11af487..9c3e500 100644
--- a/packages/sdk/src/browser.ts
+++ b/packages/sdk/src/browser.ts
@@ -8,7 +8,8 @@
*
*/
-import { configure, flaggrTelemetry } from "./index";
+import { configure, flaggrErrors, flaggrTelemetry } from "./index";
+import { getGlobalClient } from "./global";
import { recordOptOut } from "./expose-flags";
import type { FlaggrConfig, UpdateMode } from "./types";
@@ -20,6 +21,11 @@ export {
resetGlobalClient,
flag,
flaggrTelemetry,
+ flaggrErrors,
+ captureException,
+ captureMessage,
+ addBreadcrumb,
+ setUser,
} from "./index";
/**
@@ -31,7 +37,8 @@ export {
*
*
* Optional: data-api-url (default https://api.flaggr.dev), data-environment,
- * data-update-mode, data-remote-config, data-telemetry.
+ * data-update-mode, data-remote-config, data-telemetry, data-errors (error
+ * tracking, with data-release naming your app's version).
*/
if (typeof document !== "undefined") {
// data-expose-flags="false" on any script tag now keeps flag values off
@@ -58,12 +65,20 @@ if (typeof document !== "undefined") {
// data-telemetry → attach the telemetry plugin (eval stats + Web Vitals
// + error correlation, beacon-flushed to the platform)
if (tag.dataset.telemetry !== undefined) config.plugins = [flaggrTelemetry()];
+ // data-errors → error tracking (flaggrErrors), data-release its release.
+ const trackErrors = tag.dataset.errors !== undefined && tag.dataset.errors !== "false";
+ if (trackErrors) {
+ config.plugins = [...(config.plugins ?? []), flaggrErrors(tag.dataset.release ? { release: tag.dataset.release } : {})];
+ }
// data-expose-flags="false" (on this or any script tag) keeps flag
// values off the page. It isn't passed here: every client reads it
// from the page (and recordOptOut above), so it still applies after a
// later configure() and to createFlaggr() clients (see shouldExpose in
// expose-flags.ts).
configure(config);
+ // The global client is made at the first flaggr.flag(); errors before
+ // then would be missed, so error tracking makes it now.
+ if (trackErrors) getGlobalClient();
}
}
diff --git a/packages/sdk/src/errors.test.ts b/packages/sdk/src/errors.test.ts
new file mode 100644
index 0000000..d400135
--- /dev/null
+++ b/packages/sdk/src/errors.test.ts
@@ -0,0 +1,381 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { captureException, errorTrackingActive, flaggrErrors, type ErrorTrackingOptions } from "./errors";
+import { flaggrTelemetry } from "./telemetry";
+import type { FlaggrClientInstance } from "./types";
+
+/**
+ * flaggrErrors(): a browser's errors go to the app's SDK telemetry ingest
+ * with the SDK key; a server's to /api/ingest/server-events with a project
+ * API token (SDK keys are refused there). Each event carries the variants
+ * the client evaluated, the release and environment, and its breadcrumbs.
+ */
+
+const API_URL = "https://flaggr.test";
+const KEY = "fgr_sdk_key";
+
+type FetchInit = { method?: string; headers?: Record; body?: string };
+
+const plugins: Array<{ onDestroy?: () => void }> = [];
+
+afterEach(() => {
+ for (const plugin of plugins.splice(0)) plugin.onDestroy?.();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+function fakeClient(apiKey: string | undefined = KEY, environment = "production"): FlaggrClientInstance {
+ return { getConfig: () => ({ apiUrl: API_URL, apiKey, serviceId: "web", environment }) } as unknown as FlaggrClientInstance;
+}
+
+function okFetch(status = 202) {
+ const fetchMock = vi.fn(async (_url: string, _init?: FetchInit) => new Response(null, { status }));
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+/** A browser page: window and document as event targets, a location and a history. */
+function stubPage() {
+ const win = Object.assign(new EventTarget(), { location: { href: "https://shop.test/cart?coupon=SECRET#top" } });
+ const doc = Object.assign(new EventTarget(), { visibilityState: "visible" });
+ vi.stubGlobal("window", win);
+ vi.stubGlobal("document", doc);
+ vi.stubGlobal("history", { pushState: () => {}, replaceState: () => {} });
+ return win;
+}
+
+function install(options: ErrorTrackingOptions = {}, client = fakeClient()) {
+ const plugin = flaggrErrors({ release: "web@1.4.2", ...options });
+ plugin.onInit?.(client);
+ plugins.push(plugin);
+ return plugin;
+}
+
+function sentBodies(fetchMock: ReturnType) {
+ return fetchMock.mock.calls.map(([url, init]) => ({ url, headers: init?.headers, body: JSON.parse(init!.body!) }));
+}
+
+describe("flaggrErrors in a browser", () => {
+ it("reports an uncaught error with its stack, the evaluated variants, release, environment and breadcrumbs, to the SDK telemetry ingest", async () => {
+ const win = stubPage();
+ const fetchMock = okFetch();
+ const plugin = install();
+ plugin.onEvaluateComplete?.("new-cart", { value: true, reason: "TARGETING_MATCH", variant: "treatment" }, 1);
+ plugin.setUser({ id: "user-7" });
+ plugin.setTag("plan", "pro");
+
+ const error = new TypeError("Cannot read properties of undefined (reading 'price')");
+ win.dispatchEvent(Object.assign(new Event("error"), { error, message: error.message }));
+ await plugin.flush();
+
+ const [sent] = sentBodies(fetchMock);
+ expect(sent.url).toBe(`${API_URL}/api/analytics/sdk-telemetry`);
+ expect(sent.headers).toMatchObject({ Authorization: `Bearer ${KEY}` });
+ expect(sent.body.serviceId).toBe("web");
+ expect(sent.body.errors).toHaveLength(1);
+ const [event] = sent.body.errors;
+ expect(event).toMatchObject({
+ name: "TypeError",
+ message: "Cannot read properties of undefined (reading 'price')",
+ handled: false,
+ level: "error",
+ release: "web@1.4.2",
+ environment: "production",
+ user: { id: "user-7" },
+ tags: { plan: "pro" },
+ flags: { "new-cart": "treatment" },
+ // Without its query string and fragment.
+ pageUrl: "https://shop.test/cart",
+ });
+ expect(event.stack).toContain("TypeError");
+ expect(event.breadcrumbs).toEqual([
+ expect.objectContaining({ category: "flag", message: "new-cart = treatment" }),
+ ]);
+ });
+
+ it("reports unhandled rejections, and ignores failed resource loads", async () => {
+ const win = stubPage();
+ const fetchMock = okFetch();
+ const plugin = install();
+ win.dispatchEvent(new Event("error")); // an
that failed: no error, no message
+ win.dispatchEvent(Object.assign(new Event("unhandledrejection"), { reason: new Error("fetch failed") }));
+ await plugin.flush();
+ const errors = sentBodies(fetchMock)[0].body.errors;
+ expect(errors.map((e: { message: string }) => e.message)).toEqual(["fetch failed"]);
+ });
+
+ it("captureException sends handled errors with their context; the same error object only once", async () => {
+ stubPage();
+ const fetchMock = okFetch();
+ const plugin = install();
+ const error = new Error("checkout failed");
+ captureException(error, { tags: { step: "pay" }, extra: { orderId: "o-1" }, fingerprint: "checkout-pay" });
+ captureException(error);
+ plugin.captureMessage("slow checkout", { level: "warning" });
+ await plugin.flush();
+ const errors = sentBodies(fetchMock)[0].body.errors;
+ expect(errors).toHaveLength(2);
+ expect(errors[0]).toMatchObject({ handled: true, tags: { step: "pay" }, extra: { orderId: "o-1" }, fingerprint: "checkout-pay" });
+ expect(errors[1]).toMatchObject({ name: "Message", message: "slow checkout", level: "warning" });
+ });
+
+ it("drops what ignoreErrors, beforeSend or sampleRate say to, and a non-Error throw still reports", async () => {
+ stubPage();
+ const fetchMock = okFetch();
+ const plugin = install({
+ ignoreErrors: ["ResizeObserver loop", /^Script error\.?$/],
+ beforeSend: (event) => (event.message.includes("secret") ? null : { ...event, tags: { ...event.tags, edited: "yes" } }),
+ });
+ plugin.captureException(new Error("ResizeObserver loop limit exceeded"));
+ plugin.captureException(new Error("Script error."));
+ plugin.captureException(new Error("leaked secret"));
+ plugin.captureException({ code: 42 });
+ await plugin.flush();
+ const errors = sentBodies(fetchMock)[0].body.errors;
+ expect(errors).toEqual([expect.objectContaining({ message: 'Non-Error thrown: {"code":42}', tags: { edited: "yes" } })]);
+
+ const sampled = install({ sampleRate: 0, sessions: false });
+ fetchMock.mockClear();
+ sampled.captureException(new Error("never"));
+ await sampled.flush();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("cuts message and stack to the ingest's 2,000 characters", async () => {
+ stubPage();
+ const fetchMock = okFetch();
+ const plugin = install();
+ const error = new Error("x".repeat(5000));
+ error.stack = "y".repeat(5000);
+ plugin.captureException(error);
+ await plugin.flush();
+ const [event] = sentBodies(fetchMock)[0].body.errors;
+ expect(event.message).toHaveLength(2000);
+ expect(event.stack).toHaveLength(2000);
+ });
+
+ it("records fetch and console breadcrumbs, but not the SDK's own requests", async () => {
+ stubPage();
+ const fetchMock = okFetch(200);
+ const plugin = install();
+ await fetch("https://shop.test/api/cart?token=abc");
+ await fetch(`${API_URL}/api/flags/evaluate`);
+ console.warn("cart is empty");
+ plugin.captureException(new Error("boom"));
+ await plugin.flush();
+ const report = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/sdk-telemetry"));
+ const crumbs = JSON.parse(report![1]!.body!).errors[0].breadcrumbs;
+ expect(crumbs.map((c: { category: string; message: string }) => `${c.category}: ${c.message}`)).toEqual([
+ "fetch: GET https://shop.test/api/cart",
+ "console: cart is empty",
+ ]);
+ });
+
+ it("stops sending once the ingest refuses the key", async () => {
+ stubPage();
+ const fetchMock = okFetch(403);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const plugin = install();
+ plugin.captureException(new Error("one"));
+ await plugin.flush();
+ plugin.captureException(new Error("two"));
+ await plugin.flush();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(warn).toHaveBeenCalled();
+ });
+
+ it("keeps flaggrTelemetry from reporting the same window errors", async () => {
+ const win = stubPage();
+ const fetchMock = okFetch();
+ const telemetry = flaggrTelemetry({ vitals: false, beacon: false });
+ telemetry.onInit?.(fakeClient());
+ const plugin = install();
+ expect(errorTrackingActive()).toBe(true);
+ win.dispatchEvent(Object.assign(new Event("error"), { error: new Error("once"), message: "once" }));
+ await plugin.flush();
+ telemetry.onDestroy?.(); // flushes whatever the telemetry plugin logged
+ const reported = sentBodies(fetchMock).flatMap((sent) => sent.body.errors ?? []);
+ // One report, flaggrErrors' (with its name and stack), none from the telemetry log.
+ expect(reported).toEqual([expect.objectContaining({ name: "Error", message: "once" })]);
+ });
+});
+
+describe("flaggrErrors trace propagation (browser)", () => {
+ function headersOf(init: unknown): Headers {
+ return new Headers((init as { headers?: HeadersInit } | undefined)?.headers);
+ }
+
+ it("adds an unsampled traceparent to same-origin requests and records its trace id on the breadcrumb", async () => {
+ stubPage();
+ const fetchMock = okFetch(200);
+ const plugin = install({ sessions: false });
+ await fetch("/api/cart");
+ await fetch("https://other.test/api");
+ await fetch("/api/pinned", { headers: { traceparent: "00-11111111111111111111111111111111-2222222222222222-01" } });
+
+ const sameOrigin = headersOf(fetchMock.mock.calls[0][1]).get("traceparent");
+ expect(sameOrigin).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-00$/);
+ expect(headersOf(fetchMock.mock.calls[1][1]).get("traceparent")).toBeNull();
+ expect(headersOf(fetchMock.mock.calls[2][1]).get("traceparent")).toBe("00-11111111111111111111111111111111-2222222222222222-01");
+
+ plugin.captureException(new Error("after the fetch"));
+ await plugin.flush();
+ const report = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/sdk-telemetry"));
+ const crumb = JSON.parse(report![1]!.body!).errors[0].breadcrumbs.find((c: { message: string }) => c.message === "GET https://shop.test/api/cart" || c.message === "GET /api/cart");
+ expect(crumb.data.traceId).toBe(sameOrigin!.split("-")[1]);
+ });
+
+ it("propagates only to the targets given, and not at all with []", async () => {
+ stubPage();
+ const fetchMock = okFetch(200);
+ install({ sessions: false, tracePropagationTargets: ["https://api.shop.test/"] });
+ await fetch("https://api.shop.test/v1/orders");
+ await fetch("/api/cart");
+ expect(headersOf(fetchMock.mock.calls[0][1]).get("traceparent")).not.toBeNull();
+ expect(headersOf(fetchMock.mock.calls[1][1]).get("traceparent")).toBeNull();
+ });
+});
+
+describe("flaggrErrors release health sessions (browser)", () => {
+ function stubStorage() {
+ const store = new Map();
+ vi.stubGlobal("sessionStorage", {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => void store.set(key, value),
+ });
+ return store;
+ }
+
+ it("reports the tab's session once, with the release and environment; its errors carry the session id", async () => {
+ stubPage();
+ stubStorage();
+ const fetchMock = okFetch();
+ const plugin = install();
+ await plugin.flush();
+ const [first] = sentBodies(fetchMock);
+ expect(first.body.releaseSessions).toEqual([
+ { sessionId: expect.any(String), release: "web@1.4.2", environment: "production", startedAt: expect.any(Number) },
+ ]);
+ expect(first.body.errors).toEqual([]);
+ const sessionId = first.body.releaseSessions[0].sessionId;
+ expect(first.body.sessionId).toBe(sessionId);
+
+ plugin.captureException(new Error("later"));
+ await plugin.flush();
+ const second = sentBodies(fetchMock)[1];
+ expect(second.body.releaseSessions).toBeUndefined();
+ expect(second.body.sessionId).toBe(sessionId);
+ });
+
+ it("keeps the session across page loads in the tab, and starts a new one after 30 idle minutes", async () => {
+ stubPage();
+ const store = stubStorage();
+ const fetchMock = okFetch();
+ const first = install();
+ await first.flush();
+ first.onDestroy?.();
+ const id = sentBodies(fetchMock)[0].body.releaseSessions[0].sessionId;
+
+ fetchMock.mockClear();
+ const reload = install();
+ reload.captureException(new Error("x"));
+ await reload.flush();
+ expect(sentBodies(fetchMock)[0].body.sessionId).toBe(id);
+ expect(sentBodies(fetchMock)[0].body.releaseSessions).toBeUndefined();
+ reload.onDestroy?.();
+
+ const saved = JSON.parse(store.get("flaggr_errors_session")!);
+ store.set("flaggr_errors_session", JSON.stringify({ ...saved, lastActive: Date.now() - 31 * 60_000 }));
+ fetchMock.mockClear();
+ const later = install();
+ await later.flush();
+ expect(sentBodies(fetchMock)[0].body.releaseSessions[0].sessionId).not.toBe(id);
+ });
+
+ it("sends no session with sessions: false", async () => {
+ stubPage();
+ stubStorage();
+ const fetchMock = okFetch();
+ const plugin = install({ sessions: false });
+ await plugin.flush();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("flaggrErrors on a server", () => {
+ it("sends to /api/ingest/server-events with serverToken, as error events with the context in data", async () => {
+ const fetchMock = okFetch();
+ const plugin = install({ autoCapture: false, serverToken: "fgr_project_token", serverServiceId: "svc-1" });
+ plugin.onEvaluateComplete?.("coupon-engine", { value: "v2", reason: "TARGETING_MATCH", variant: "v2" }, 1);
+ plugin.captureException(new Error("payment timeout"), { user: { id: "u-9" }, extra: { orderId: "o-2", handled: "overwritten?" }, fingerprint: "pay" });
+ await plugin.flush();
+
+ const [sent] = sentBodies(fetchMock);
+ expect(sent.url).toBe(`${API_URL}/api/ingest/server-events`);
+ expect(sent.headers).toMatchObject({ Authorization: "Bearer fgr_project_token" });
+ expect(sent.body.events).toEqual([
+ expect.objectContaining({
+ kind: "error",
+ serviceId: "svc-1",
+ environment: "production",
+ release: "web@1.4.2",
+ name: "Error",
+ message: "payment timeout",
+ fingerprint: "custom_pay",
+ data: expect.objectContaining({
+ handled: true,
+ level: "error",
+ userId: "u-9",
+ orderId: "o-2",
+ flags: { "coupon-engine": "v2" },
+ sdkService: "web",
+ }),
+ }),
+ ]);
+ expect(Date.parse(sent.body.events[0].timestamp)).not.toBeNaN();
+ });
+
+ it("takes FLAGGR_ERRORS_TOKEN over the client's apiKey, and says what a server needs when the key is refused", async () => {
+ vi.stubEnv("FLAGGR_ERRORS_TOKEN", "fgr_env_token");
+ const fetchMock = okFetch(403);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const plugin = install({ autoCapture: false });
+ plugin.captureException(new Error("x"));
+ await plugin.flush();
+ expect(sentBodies(fetchMock)[0].headers).toMatchObject({ Authorization: "Bearer fgr_env_token" });
+ expect(String(warn.mock.calls[0][0])).toMatch(/project API token/);
+ });
+
+ it("on an uncaught exception, sends it as fatal, then exits with code 1 as Node would", async () => {
+ const fetchMock = okFetch();
+ let listener: ((error: unknown, origin: unknown) => void) | undefined;
+ vi.spyOn(process, "on").mockImplementation(((event: string, fn: (error: unknown, origin: unknown) => void) => {
+ if (event === "uncaughtException") listener = fn;
+ return process;
+ }) as typeof process.on);
+ vi.spyOn(process, "listenerCount").mockReturnValue(1);
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ install({ serverToken: "fgr_project_token" });
+
+ listener!(new Error("crash"), "uncaughtException");
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
+ const [event] = sentBodies(fetchMock)[0].body.events;
+ expect(event).toMatchObject({ message: "crash", data: expect.objectContaining({ level: "fatal", handled: false }) });
+ });
+
+ it("leaves the process alone with exitOnUncaught: false", async () => {
+ okFetch();
+ let listener: ((error: unknown, origin: unknown) => void) | undefined;
+ vi.spyOn(process, "on").mockImplementation(((event: string, fn: (error: unknown, origin: unknown) => void) => {
+ if (event === "uncaughtException") listener = fn;
+ return process;
+ }) as typeof process.on);
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
+ const plugin = install({ serverToken: "t", exitOnUncaught: false });
+ listener!(new Error("crash"), "uncaughtException");
+ await plugin.flush();
+ expect(exit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts
new file mode 100644
index 0000000..63da0c2
--- /dev/null
+++ b/packages/sdk/src/errors.ts
@@ -0,0 +1,743 @@
+/**
+ * Error tracking for the Flaggr SDK: crashes and errors, with the flag
+ * variants that were active when they happened, grouped into issues on
+ * Flaggr (the console's Issues page, GET /api/projects/{id}/issues).
+ *
+ * Usage:
+ * import { createFlaggr, flaggrErrors, captureException } from '@flaggr/sdk'
+ * const client = createFlaggr({
+ * serviceId: 'web-app',
+ * apiKey: 'fgr_...',
+ * plugins: [flaggrErrors({ release: 'web@1.4.2' })],
+ * })
+ * try { risky() } catch (error) { captureException(error, { tags: { step: 'checkout' } }) }
+ *
+ * Where errors go depends on where the code runs:
+ * - In a browser, to the Flaggr app's POST /api/analytics/sdk-telemetry with
+ * the client's apiKey (the project's SDK key), as flaggrTelemetry's
+ * batches go. Errors are caught from window `error` and
+ * `unhandledrejection`.
+ * - On a server (Node, Bun, Deno), to POST /api/ingest/server-events with a
+ * project API token: `serverToken`, else the FLAGGR_ERRORS_TOKEN
+ * environment variable, else the client's apiKey. That route refuses SDK
+ * keys (they ship inside client bundles), so a server needs a real
+ * project API token; when it's refused, a warning says so once and
+ * nothing more is sent. Uncaught exceptions (and, with Node's default
+ * --unhandled-rejections=throw, unhandled rejections) are caught: the
+ * error is sent, then the process exits with code 1 as it would have,
+ * after at most `shutdownTimeoutMs` (exitOnUncaught: false leaves the
+ * process to your own handler).
+ *
+ * Every event carries the variants this client evaluated (the last one per
+ * flag), the release and environment, the user and tags you set, and the
+ * breadcrumbs that led up to it: flag evaluations, console warnings and
+ * errors, fetch requests and (in a browser) navigations. Stacks and messages
+ * are cut to the ingest's 2,000 characters; the ingest scrubs tokens,
+ * addresses and URL credentials before storing them.
+ */
+
+import { appBaseUrl } from "./app-url";
+import { fetchWithKeepalive } from "./keepalive";
+import type { EvaluationResult, FlaggrClientInstance, FlaggrPlugin, RequestInfo } from "./types";
+
+export type ErrorLevel = "fatal" | "error" | "warning";
+
+export interface Breadcrumb {
+ /** ms since the epoch */
+ timestamp: number;
+ /** e.g. "flag", "console", "fetch", "navigation", or your own */
+ category: string;
+ message: string;
+ level?: "info" | "warning" | "error";
+ data?: Record;
+}
+
+export interface ErrorUser {
+ id: string;
+}
+
+/** What captureException adds to one event. */
+export interface CaptureContext {
+ tags?: Record;
+ extra?: Record;
+ level?: ErrorLevel;
+ /** Group this event with others under your own key instead of by its stack. */
+ fingerprint?: string;
+ /** Whether your code caught it (default true for captureException). */
+ handled?: boolean;
+ user?: ErrorUser | null;
+}
+
+/** One error as it is sent (beforeSend may change or drop it). */
+export interface ErrorEvent {
+ name: string;
+ message: string;
+ stack?: string;
+ level: ErrorLevel;
+ handled: boolean;
+ /** ISO time */
+ timestamp: string;
+ release?: string;
+ environment?: string;
+ user?: ErrorUser;
+ tags: Record;
+ extra: Record;
+ flags: Record;
+ breadcrumbs: Breadcrumb[];
+ fingerprint?: string;
+ /** The page (browser) */
+ url?: string;
+}
+
+export interface ErrorTrackingOptions {
+ /** Your app's version, e.g. "web@1.4.2" or a commit SHA: issues show the releases they appear in and regress across. */
+ release?: string;
+ /** Default: the client's `environment`. */
+ environment?: string;
+ /** Catch uncaught errors and unhandled rejections (default true). */
+ autoCapture?: boolean;
+ /** Server only: exit with code 1 after sending an uncaught exception, as Node would have (default true). */
+ exitOnUncaught?: boolean;
+ /** Server only: how long to wait for the send before exiting (default 2000 ms). */
+ shutdownTimeoutMs?: number;
+ /** Which breadcrumbs to record (default all), or false for none. */
+ breadcrumbs?: false | { flags?: boolean; console?: boolean; fetch?: boolean; navigation?: boolean };
+ /** The most breadcrumbs an event carries, newest kept (default 50). */
+ maxBreadcrumbs?: number;
+ /** Send this share of errors, 0 to 1 (default 1). */
+ sampleRate?: number;
+ /** Drop errors whose message contains one of these strings or matches one of these patterns. */
+ ignoreErrors?: Array;
+ /** Change an event before it is sent, or return null to drop it. */
+ beforeSend?: (event: ErrorEvent) => ErrorEvent | null;
+ /** Server only: the project API token for /api/ingest/server-events (default FLAGGR_ERRORS_TOKEN, then the client's apiKey). */
+ serverToken?: string;
+ /**
+ * Server only: the Flaggr service id (the service's id, not its slug) to
+ * file errors under. Left out by default: an id that isn't one of the
+ * token's project's services makes the ingest reject the event.
+ */
+ serverServiceId?: string;
+ /** The Flaggr app's URL, when apiUrl points at a self-hosted data plane (default: derived from apiUrl). */
+ appUrl?: string;
+ /** ms between sends of queued errors (default 2000). */
+ flushIntervalMs?: number;
+ /**
+ * Browser only: report each session once, with the release and
+ * environment, so Flaggr can show each release's crash-free session rate
+ * (default true). A session lasts across page loads in a tab until 30
+ * minutes without one, as the analytics script's do.
+ */
+ sessions?: boolean;
+ /**
+ * Browser only: requests to these origins or URLs (strings match a URL's
+ * start, patterns test the whole URL) get a W3C traceparent header, so the
+ * service that answers (the Go SDK's errorreport middleware, or any
+ * OpenTelemetry service) continues the trace: its errors and spans share the
+ * trace id the fetch breadcrumb records. Default: the page's own origin
+ * (cross-origin requests would need the header allowed by CORS). The header
+ * is sent unsampled: the service decides whether to record the trace. []
+ * turns it off.
+ */
+ tracePropagationTargets?: Array;
+}
+
+/** The plugin, with the capture API. Install it with createFlaggr({ plugins: [...] }). */
+export interface FlaggrErrors extends FlaggrPlugin {
+ captureException(error: unknown, context?: CaptureContext): void;
+ captureMessage(message: string, context?: CaptureContext): void;
+ addBreadcrumb(breadcrumb: Omit & { timestamp?: number }): void;
+ setUser(user: ErrorUser | null): void;
+ setTag(key: string, value: string | number | boolean): void;
+ setTags(tags: Record): void;
+ /** Send what is queued now; resolves when sent, or after timeoutMs (default 2000). */
+ flush(timeoutMs?: number): Promise;
+}
+
+/** The ingest's caps (src/lib/db/server-telemetry-writer.ts): a longer field would reject the event. */
+const MAX_TEXT = 2000;
+const MAX_SHORT = 500;
+/** The most errors queued between sends, and sent per minute, so an error loop can't flood the ingest. */
+const MAX_QUEUE = 100;
+const MAX_PER_MINUTE = 60;
+
+const warned = new Set();
+function warnOnce(message: string) {
+ if (warned.has(message)) return;
+ warned.add(message);
+ console.warn(`[flaggr/errors] ${message}`);
+}
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined" && typeof document !== "undefined";
+}
+
+type NodeProcess = {
+ on(event: string, listener: (...args: unknown[]) => void): unknown;
+ off?(event: string, listener: (...args: unknown[]) => void): unknown;
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
+ listenerCount(event: string): number;
+ exit(code?: number): never;
+ env: Record;
+ exitCode?: number;
+};
+
+function nodeProcess(): NodeProcess | null {
+ const p = (globalThis as { process?: Partial }).process;
+ return p && typeof p.on === "function" && typeof p.listenerCount === "function" ? (p as NodeProcess) : null;
+}
+
+function cut(value: string | undefined, max: number): string | undefined {
+ if (value === undefined) return undefined;
+ return value.length > max ? value.slice(0, max) : value;
+}
+
+/** An Error-like from anything thrown or rejected. */
+function normalizeError(error: unknown): { name: string; message: string; stack?: string } {
+ if (error instanceof Error || (error && typeof error === "object" && "message" in error)) {
+ const e = error as { name?: unknown; message?: unknown; stack?: unknown };
+ return {
+ name: typeof e.name === "string" && e.name ? e.name : "Error",
+ message: typeof e.message === "string" ? e.message : String(e.message),
+ stack: typeof e.stack === "string" ? e.stack : undefined,
+ };
+ }
+ let message: string;
+ try {
+ message = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);
+ } catch {
+ message = String(error);
+ }
+ return { name: "Error", message: `Non-Error thrown: ${message}` };
+}
+
+/** A URL without its query string or fragment (where tokens and personal data ride). */
+function bareUrl(url: string): string {
+ return url.replace(/[?#].*$/, "");
+}
+
+let current: FlaggrErrors | null = null;
+
+const SESSION_KEY = "flaggr_errors_session";
+const SESSION_IDLE_MS = 30 * 60_000;
+
+function newId(): string {
+ return typeof crypto !== "undefined" && crypto.randomUUID
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+/**
+ * The tab's error-tracking session: the stored one while it was active in the
+ * last 30 minutes, else a new one. `reported` says whether the session was
+ * already sent for release health. Without sessionStorage (blocked, or not a
+ * browser) every client is a new session.
+ */
+function browserSession(now: number): { id: string; startedAt: number; reported: boolean } {
+ try {
+ const stored = JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? "null") as
+ | { id?: unknown; startedAt?: unknown; lastActive?: unknown; reported?: unknown }
+ | null;
+ if (stored && typeof stored.id === "string" && typeof stored.lastActive === "number" && now - stored.lastActive < SESSION_IDLE_MS) {
+ return { id: stored.id, startedAt: Number(stored.startedAt) || now, reported: stored.reported === true };
+ }
+ } catch {
+ /* no sessionStorage */
+ }
+ return { id: newId(), startedAt: now, reported: false };
+}
+
+function saveBrowserSession(session: { id: string; startedAt: number; reported: boolean }, now: number) {
+ try {
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify({ ...session, lastActive: now }));
+ } catch {
+ /* no sessionStorage */
+ }
+}
+
+/** Whether a flaggrErrors() plugin is installed: flaggrTelemetry then leaves window errors to it. */
+export function errorTrackingActive(): boolean {
+ return current !== null;
+}
+
+/** Report an error through the most recently installed flaggrErrors() plugin (a no-op when none is). */
+export function captureException(error: unknown, context?: CaptureContext): void {
+ if (current) current.captureException(error, context);
+ else warnOnce("captureException() was called before a client with flaggrErrors() was created; the error was not sent");
+}
+
+/** Report a message as an error event through the most recently installed flaggrErrors() plugin. */
+export function captureMessage(message: string, context?: CaptureContext): void {
+ current?.captureMessage(message, context);
+}
+
+/** Add a breadcrumb to the most recently installed flaggrErrors() plugin. */
+export function addBreadcrumb(breadcrumb: Omit & { timestamp?: number }): void {
+ current?.addBreadcrumb(breadcrumb);
+}
+
+/** Set (or with null clear) the user the next errors are reported for. */
+export function setUser(user: ErrorUser | null): void {
+ current?.setUser(user);
+}
+
+export function flaggrErrors(options: ErrorTrackingOptions = {}): FlaggrErrors {
+ const {
+ autoCapture = true,
+ exitOnUncaught = true,
+ shutdownTimeoutMs = 2000,
+ maxBreadcrumbs = 50,
+ sampleRate = 1,
+ ignoreErrors = [],
+ beforeSend,
+ flushIntervalMs = 2000,
+ sessions: trackSessions = true,
+ } = options;
+ const crumbs = options.breadcrumbs === false ? null : { flags: true, console: true, fetch: true, navigation: true, ...options.breadcrumbs };
+
+ let client: FlaggrClientInstance | null = null;
+ let environment = options.environment;
+ let user: ErrorUser | undefined;
+ const tags: Record = {};
+ const flags: Record = {};
+ const breadcrumbs: Breadcrumb[] = [];
+ const queue: ErrorEvent[] = [];
+ const seen = new WeakSet