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(); + const sentAt: number[] = []; + let timer: ReturnType | null = null; + let stopped = false; + let sessionId = ""; + /** Browser: this session, until it has been sent for release health. */ + let session: { id: string; startedAt: number; reported: boolean } | null = null; + const inFlight = new Set>(); + const teardown: Array<() => void> = []; + + function record(breadcrumb: Breadcrumb) { + breadcrumbs.push(breadcrumb); + if (breadcrumbs.length > maxBreadcrumbs) breadcrumbs.splice(0, breadcrumbs.length - maxBreadcrumbs); + } + + function ignored(message: string): boolean { + return ignoreErrors.some((pattern) => (typeof pattern === "string" ? message.includes(pattern) : pattern.test(message))); + } + + function underRate(now: number): boolean { + while (sentAt.length > 0 && now - sentAt[0] > 60_000) sentAt.shift(); + if (sentAt.length >= MAX_PER_MINUTE) return false; + sentAt.push(now); + return true; + } + + function enqueue(error: unknown, context: CaptureContext, defaults: { handled: boolean; level: ErrorLevel }): void { + if (stopped || !client) return; + if (error && typeof error === "object") { + // One error object reaches a handler more than once (a rethrow, a framework boundary and window.onerror). + if (seen.has(error)) return; + seen.add(error); + } + const base = normalizeError(error); + if (ignored(base.message)) return; + if (sampleRate < 1 && Math.random() >= sampleRate) return; + const now = Date.now(); + if (!underRate(now)) return; + + const eventTags = { ...tags }; + for (const [key, value] of Object.entries(context.tags ?? {})) eventTags[key] = String(value); + let event: ErrorEvent | null = { + name: cut(base.name, MAX_SHORT)!, + message: cut(base.message, MAX_TEXT)!, + stack: cut(base.stack, MAX_TEXT), + level: context.level ?? defaults.level, + handled: context.handled ?? defaults.handled, + timestamp: new Date(now).toISOString(), + release: options.release, + environment, + user: context.user === null ? undefined : context.user ?? user, + tags: eventTags, + extra: { ...(context.extra ?? {}) }, + flags: { ...flags }, + breadcrumbs: breadcrumbs.slice(), + fingerprint: context.fingerprint, + url: isBrowser() ? bareUrl(window.location.href) : undefined, + }; + if (beforeSend) { + try { + event = beforeSend(event); + } catch { + /* a throwing beforeSend sends the event as it was */ + } + } + if (!event) return; + if (session) saveBrowserSession(session, now); + queue.push(event); + if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE); + record({ timestamp: now, category: "error", level: "error", message: `${event.name}: ${event.message}`.slice(0, 200) }); + } + + /** The browser batch (POST /api/analytics/sdk-telemetry): the SDK key's project, its Sites' issues. */ + function browserBatch(events: ErrorEvent[], withSession: boolean) { + return { + serviceId: client?.getConfig().serviceId, + sessionId, + ...(withSession && session + ? { releaseSessions: [{ sessionId: session.id, release: options.release, environment, startedAt: session.startedAt }] } + : {}), + summaries: [], + vitals: [], + errors: events.map((e) => ({ + name: e.name, + message: e.message, + stack: e.stack, + level: e.level, + handled: e.handled, + fatal: e.level === "fatal", + timestamp: Date.parse(e.timestamp), + release: e.release, + environment: e.environment, + user: e.user, + tags: e.tags, + extra: e.extra, + flags: e.flags, + breadcrumbs: e.breadcrumbs, + fingerprint: e.fingerprint, + pageUrl: e.url, + })), + }; + } + + /** The server batch (POST /api/ingest/server-events): the token's project. */ + function serverBatch(events: ErrorEvent[]) { + return { + events: events.map((e) => ({ + kind: "error", + serviceId: options.serverServiceId, + environment: cut(e.environment, MAX_SHORT), + fingerprint: e.fingerprint ? cut(`custom_${e.fingerprint}`, MAX_SHORT) : undefined, + name: e.name, + message: e.message, + stack: e.stack, + release: cut(e.release, MAX_SHORT), + timestamp: e.timestamp, + data: { + // Extra context first: the fields the issue reads take win over a key of the same name. + ...e.extra, + handled: e.handled, + level: e.level, + ...(e.user ? { userId: e.user.id } : {}), + flags: e.flags, + tags: e.tags, + breadcrumbs: e.breadcrumbs, + sdkService: client?.getConfig().serviceId, + }, + })), + }; + } + + function appUrl(): string { + return options.appUrl ? options.appUrl.replace(/\/$/, "") : appBaseUrl(client?.getConfig().apiUrl); + } + + function serverToken(): string | undefined { + return options.serverToken ?? nodeProcess()?.env.FLAGGR_ERRORS_TOKEN ?? client?.getConfig().apiKey; + } + + function onResponse(response: Response, route: string) { + if (response.status === 401 || response.status === 403) { + stopped = true; + warnOnce( + isBrowser() + ? `${route} refused the apiKey (${response.status}); no errors are sent from this client` + : `${route} refused the token (${response.status}): server-side error reporting needs a project API token (serverToken or FLAGGR_ERRORS_TOKEN), not an SDK key; no errors are sent from this process` + ); + } else if (response.status === 404 || response.status === 405) { + stopped = true; + warnOnce(`${appUrl()}${route} doesn't take errors (${response.status}); set flaggrErrors({ appUrl }) to the Flaggr app's URL`); + } + } + + function send(): Promise { + const browser = isBrowser(); + const sessionDue = browser && !!session && !session.reported; + if (stopped || !client || (queue.length === 0 && !sessionDue)) return Promise.resolve(); + const events = queue.splice(0, MAX_QUEUE); + if (sessionDue && session) { + session.reported = true; + saveBrowserSession(session, Date.now()); + } + const route = browser ? "/api/analytics/sdk-telemetry" : "/api/ingest/server-events"; + const token = browser ? client.getConfig().apiKey : serverToken(); + if (!token) { + stopped = true; + warnOnce(browser ? "flaggrErrors() needs the client's apiKey (the project's SDK key)" : "flaggrErrors() on a server needs a project API token (serverToken or FLAGGR_ERRORS_TOKEN)"); + return Promise.resolve(); + } + const request = fetchWithKeepalive(`${appUrl()}${route}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(browser ? browserBatch(events, sessionDue) : serverBatch(events)), + }) + .then((response) => onResponse(response, route)) + .catch(() => { + /* offline or blocked: these events are lost, as a crash reporter's are */ + }); + inFlight.add(request); + void request.finally(() => inFlight.delete(request)); + return request; + } + + async function flush(timeoutMs = 2000): Promise { + void send(); + const pending = Promise.all([...inFlight]).then(() => undefined); + await Promise.race([pending, new Promise((resolve) => { + const t = setTimeout(resolve, timeoutMs); + (t as unknown as { unref?: () => void }).unref?.(); + })]); + } + + function installBrowser() { + const onError = (event: Event) => { + const e = event as unknown as { error?: unknown; message?: string; filename?: string; lineno?: number; colno?: number }; + // A resource that failed to load (img, script) has no error: not a crash. + if (e.error === undefined && !e.message) return; + enqueue(e.error ?? new Error(e.message), { handled: false }, { handled: false, level: "error" }); + }; + const onRejection = (event: Event) => { + enqueue((event as unknown as { reason?: unknown }).reason, { handled: false }, { handled: false, level: "error" }); + }; + const onHide = () => void send(); + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + window.addEventListener("pagehide", onHide); + teardown.push(() => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + window.removeEventListener("pagehide", onHide); + }); + } + + function installNode(proc: NodeProcess) { + const onUncaught = (error: unknown, origin: unknown) => { + enqueue(error, { handled: false }, { handled: false, level: "fatal" }); + if (!exitOnUncaught) return; + // With our listener Node no longer prints and exits: do as it would, after the send. + const others = proc.listenerCount("uncaughtException") - 1; + void flush(shutdownTimeoutMs).then(() => { + if (others > 0) return; + console.error(origin === "unhandledRejection" ? "Unhandled promise rejection:" : "Uncaught exception:", error); + proc.exit(1); + }); + }; + proc.on("uncaughtException", onUncaught); + teardown.push(() => proc.removeListener("uncaughtException", onUncaught)); + } + + function wrapConsole() { + if (typeof console === "undefined") return; + for (const level of ["warn", "error"] as const) { + const original = console[level]; + if (typeof original !== "function") continue; + let active = true; + const wrapped = (...args: unknown[]) => { + if (!active) return original.apply(console, args); + const message = args + .map((arg) => (typeof arg === "string" ? arg : arg instanceof Error ? `${arg.name}: ${arg.message}` : safeJson(arg))) + .join(" ") + .slice(0, 300); + // Our own warnings stay out of the trail. + if (!message.startsWith("[flaggr")) record({ timestamp: Date.now(), category: "console", level: level === "warn" ? "warning" : "error", message }); + original.apply(console, args); + }; + console[level] = wrapped; + teardown.push(() => { + // Wrapped again since (another plugin, a logger): pass through instead of unwrapping theirs. + active = false; + if (console[level] === wrapped) console[level] = original; + }); + } + } + + function wrapFetch() { + const g = globalThis as { fetch?: typeof fetch }; + const original = g.fetch; + if (typeof original !== "function") return; + let active = true; + const wrapped: typeof fetch = async (input, init) => { + if (!active) return original(input, init); + const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET")).toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const started = Date.now(); + // A traceparent for the service that answers (unsampled: it decides), unless the request has one. + let traceId: string | undefined; + if (propagatesTo(url, options.tracePropagationTargets)) { + const headers = new Headers(init?.headers ?? (typeof input === "object" && "headers" in input ? input.headers : undefined)); + if (!headers.has("traceparent")) { + traceId = hexId(16); + headers.set("traceparent", `00-${traceId}-${hexId(8)}-00`); + init = { ...init, headers }; + } + } + const crumb = (data: Record, level: Breadcrumb["level"]) => { + // The SDK's own requests (evaluations, these reports) stay out of the trail. + if (/\/api\/(flags|ofrep|analytics\/sdk-telemetry|ingest\/server-events|sdk-config|connect)\b/.test(url)) return; + record({ + timestamp: started, + category: "fetch", + level, + message: `${method} ${bareUrl(url)}`, + data: { ...data, durationMs: Date.now() - started, ...(traceId ? { traceId } : {}) }, + }); + }; + try { + const response = await original(input, init); + crumb({ status: response.status }, response.status >= 500 ? "error" : response.status >= 400 ? "warning" : "info"); + return response; + } catch (error) { + crumb({ error: normalizeError(error).message.slice(0, 200) }, "error"); + throw error; + } + }; + g.fetch = wrapped; + teardown.push(() => { + active = false; + if (g.fetch === wrapped) g.fetch = original; + }); + } + + function watchNavigation() { + let last = bareUrl(window.location.href); + const navigated = () => { + const to = bareUrl(window.location.href); + if (to === last) return; + record({ timestamp: Date.now(), category: "navigation", message: `Navigated to ${to}`, data: { from: last } }); + last = to; + }; + const { pushState, replaceState } = history; + history.pushState = function (...args: Parameters) { + pushState.apply(this, args); + navigated(); + }; + history.replaceState = function (...args: Parameters) { + replaceState.apply(this, args); + navigated(); + }; + window.addEventListener("popstate", navigated); + teardown.push(() => { + history.pushState = pushState; + history.replaceState = replaceState; + window.removeEventListener("popstate", navigated); + }); + } + + const plugin: FlaggrErrors = { + name: "flaggr-errors", + + onInit(c) { + client = c; + current = plugin; + environment = options.environment ?? c.getConfig().environment; + sessionId = newId(); + if (isBrowser()) { + // Errors carry the tab session's id, so a release's crashed sessions are the ones with an unhandled error. + const now = Date.now(); + const tab = browserSession(now); + sessionId = tab.id; + session = trackSessions ? tab : null; + saveBrowserSession(trackSessions ? tab : { ...tab, reported: true }, now); + } + timer = setInterval(() => void send(), flushIntervalMs); + (timer as unknown as { unref?: () => void }).unref?.(); + + if (crumbs?.console) wrapConsole(); + if (crumbs?.fetch) wrapFetch(); + if (isBrowser()) { + if (crumbs?.navigation) watchNavigation(); + if (autoCapture) installBrowser(); + } else { + const proc = nodeProcess(); + if (proc && autoCapture) installNode(proc); + } + }, + + onEvaluateComplete(flagKey: string, result: EvaluationResult) { + const variant = result.variant ?? String(result.value); + const changed = flags[flagKey] !== variant; + flags[flagKey] = variant; + if (crumbs?.flags && changed) { + record({ timestamp: Date.now(), category: "flag", message: `${flagKey} = ${variant}`, data: { reason: result.reason } }); + } + }, + + onRequest(info: RequestInfo) { + if (!info.ok && crumbs?.fetch) { + record({ timestamp: Date.now(), category: "flaggr", level: "warning", message: `Flag request failed (${info.durationMs.toFixed(0)} ms)` }); + } + }, + + onDestroy() { + if (timer) clearInterval(timer); + timer = null; + for (const undo of teardown.splice(0)) undo(); + void send(); + if (current === plugin) current = null; + }, + + captureException(error, context = {}) { + enqueue(error, context, { handled: true, level: "error" }); + }, + + captureMessage(message, context = {}) { + const error = new Error(message); + error.name = "Message"; + enqueue(error, context, { handled: true, level: context.level ?? "warning" }); + }, + + addBreadcrumb(breadcrumb) { + record({ ...breadcrumb, timestamp: breadcrumb.timestamp ?? Date.now() }); + }, + + setUser(next) { + user = next ?? undefined; + }, + + setTag(key, value) { + tags[key] = String(value); + }, + + setTags(next) { + for (const [key, value] of Object.entries(next)) tags[key] = String(value); + }, + + flush, + }; + + return plugin; +} + +function hexId(bytes: number): string { + const values = new Uint8Array(bytes); + if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(values); + else for (let i = 0; i < bytes; i += 1) values[i] = Math.floor(Math.random() * 256); + const hex = Array.from(values, (b) => b.toString(16).padStart(2, "0")).join(""); + return /^0+$/.test(hex) ? hexId(bytes) : hex; +} + +/** Whether a request URL gets a traceparent: one of the targets, by default the page's own origin. */ +function propagatesTo(url: string, targets: Array | undefined): boolean { + if (!isBrowser()) return false; + let absolute: URL; + try { + absolute = new URL(url, window.location.href); + } catch { + return false; + } + if (!targets) return absolute.origin === new URL(window.location.href).origin; + return targets.some((target) => (typeof target === "string" ? absolute.href.startsWith(target) : target.test(absolute.href))); +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8f8678f..f727fa7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -149,3 +149,15 @@ export { readEnvConfig } from "./env"; // Browser telemetry plugin (opt-in) export { flaggrTelemetry } from "./telemetry"; export type { TelemetryOptions } from "./telemetry"; + +// Error tracking: crashes and errors with the active flag variants, grouped into issues +export { flaggrErrors, captureException, captureMessage, addBreadcrumb, setUser } from "./errors"; +export type { + Breadcrumb, + CaptureContext, + ErrorEvent as FlaggrErrorEvent, + ErrorLevel, + ErrorTrackingOptions, + ErrorUser, + FlaggrErrors, +} from "./errors"; diff --git a/packages/sdk/src/telemetry.ts b/packages/sdk/src/telemetry.ts index 47a9fc4..45643e5 100644 --- a/packages/sdk/src/telemetry.ts +++ b/packages/sdk/src/telemetry.ts @@ -30,6 +30,7 @@ */ import { appBaseUrl } from "./app-url"; +import { errorTrackingActive } from "./errors"; import { fetchWithKeepalive, fitsKeepaliveBudget } from "./keepalive"; import type { EvaluationResult, @@ -359,6 +360,8 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { if (captureErrors) { onError = (e: ErrorEvent) => { + // flaggrErrors() reports it in full (stack, breadcrumbs, release): don't count it twice. + if (errorTrackingActive()) return; errorLog.push({ message: e.message ?? "unknown", flags: { ...activeFlags }, ts: Date.now() }); if (errorLog.length > 100) errorLog.splice(0, errorLog.length - 100); };