diff --git a/.changeset/browser-capture-handlers.md b/.changeset/browser-capture-handlers.md new file mode 100644 index 0000000..a5c1eee --- /dev/null +++ b/.changeset/browser-capture-handlers.md @@ -0,0 +1,5 @@ +--- +'@smooai/observability': minor +--- + +Browser capture MVP. Wires up `window.onerror` + `unhandledrejection` global handlers, optional `console.error` tap, `fetch` + navigation breadcrumb wrappers, batched `fetch` transport with `navigator.sendBeacon` flush on `pagehide`/`visibilitychange`, PII scrubbing (Bearer tokens, password/token/api-key params, OpenAI-style `sk-...` keys, sensitive headers), and an engine-agnostic V8 + Spidermonkey stack parser. `Client.init` now auto-installs everything when called from the browser entry. SDK-internal frames are stripped from captured stacks. `Error.cause` chains are walked into the exception envelope. diff --git a/packages/core/src/__tests__/pii.test.ts b/packages/core/src/__tests__/pii.test.ts new file mode 100644 index 0000000..d92cb90 --- /dev/null +++ b/packages/core/src/__tests__/pii.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { scrubHeaders, scrubString } from '../pii'; + +describe('scrubString', () => { + it('redacts Bearer tokens', () => { + expect(scrubString('Authorization: Bearer abc.def.ghi')).toBe('Authorization: Bearer [redacted]'); + }); + it('redacts password=', () => { + expect(scrubString('?password=hunter2&x=1')).toBe('?password=[redacted]&x=1'); + }); + it('redacts sk-... API keys', () => { + expect(scrubString('key=sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toContain('sk-[redacted]'); + }); +}); + +describe('scrubHeaders', () => { + it('redacts known sensitive headers', () => { + const out = scrubHeaders({ authorization: 'Bearer abc', 'x-api-key': '12345', accept: 'application/json' })!; + expect(out.authorization).toBe('[redacted]'); + expect(out['x-api-key']).toBe('[redacted]'); + expect(out.accept).toBe('application/json'); + }); +}); diff --git a/packages/core/src/__tests__/stack-parser.test.ts b/packages/core/src/__tests__/stack-parser.test.ts new file mode 100644 index 0000000..0dfc58c --- /dev/null +++ b/packages/core/src/__tests__/stack-parser.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { parseStack } from '../stack-parser'; + +describe('parseStack', () => { + it('parses a V8 (Chrome) stack', () => { + const stack = [ + "TypeError: Cannot read properties of undefined (reading 'call')", + ' at s (webpack-766ccbbf0ad1fc08.js:1:161)', + ' at c (90656-ecba1b78b94a6f78.js:32:21693)', + ' at L (90656-ecba1b78b94a6f78.js:32:25222)', + ].join('\n'); + const frames = parseStack(stack); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ function: 's', module: 'webpack-766ccbbf0ad1fc08.js', lineno: 1, colno: 161, inApp: true }); + }); + + it('parses a Spidermonkey (Firefox) stack', () => { + const stack = ['fn@http://localhost/app.js:42:7', 'doWork@http://localhost/app.js:100:1'].join('\n'); + const frames = parseStack(stack); + expect(frames).toHaveLength(2); + expect(frames[0]).toMatchObject({ function: 'fn' }); + }); + + it('returns empty for missing stack', () => { + expect(parseStack(undefined)).toEqual([]); + expect(parseStack('')).toEqual([]); + }); + + it('flags node_modules frames as non-app', () => { + const stack = ' at Object.foo (/app/node_modules/react/index.js:1:2)'; + const frames = parseStack(stack); + expect(frames[0]).toMatchObject({ inApp: false }); + }); + + it('skips leading "Error: ..." header', () => { + const stack = ['Error: boom', ' at fn (file.js:1:1)'].join('\n'); + const frames = parseStack(stack); + expect(frames).toHaveLength(1); + }); +}); diff --git a/packages/core/src/__tests__/transport.test.ts b/packages/core/src/__tests__/transport.test.ts new file mode 100644 index 0000000..70e8410 --- /dev/null +++ b/packages/core/src/__tests__/transport.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Transport } from '../transport'; +import type { ObservabilityEvent } from '../types'; + +function evt(id: string): ObservabilityEvent { + return { + eventId: id, + timestamp: Date.now(), + level: 'error', + sdk: { name: '@smooai/observability', version: '0.1.0', runtime: 'browser' }, + }; +} + +describe('Transport', () => { + const fetchMock = vi.fn(); + beforeEach(() => { + vi.useFakeTimers(); + fetchMock.mockReset(); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('batches up to maxBatchSize and flushes immediately when full', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 202 }); + const t = new Transport({ dsn: 'https://example.com/ingest', maxBatchSize: 3, flushIntervalMs: 1000 }, { canBeacon: false }); + t.enqueue(evt('a')); + t.enqueue(evt('b')); + expect(fetchMock).not.toHaveBeenCalled(); + t.enqueue(evt('c')); + // Triggered immediately by max-batch + await vi.runOnlyPendingTimersAsync(); + await Promise.resolve(); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://example.com/ingest'); + const body = JSON.parse(init.body); + expect(body.type).toBe('error'); + expect(body.events).toHaveLength(3); + }); + + it('flushes on timer when batch is not yet full', async () => { + fetchMock.mockResolvedValue({ ok: true }); + const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 10, flushIntervalMs: 500 }, { canBeacon: false }); + t.enqueue(evt('a')); + expect(fetchMock).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(600); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('drops oldest events when queue overflows', () => { + const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 100, flushIntervalMs: 1000, maxQueueSize: 2 }, { canBeacon: false }); + t.enqueue(evt('a')); + t.enqueue(evt('b')); + t.enqueue(evt('c')); + expect(t._queueSize()).toBe(2); + }); + + it('flushBeacon uses navigator.sendBeacon when available', () => { + const beacon = vi.fn().mockReturnValue(true); + const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 100, flushIntervalMs: 1000 }, { canBeacon: true, beacon }); + t.enqueue(evt('a')); + t.flushBeacon(); + expect(beacon).toHaveBeenCalledOnce(); + expect(t._queueSize()).toBe(0); + }); +}); diff --git a/packages/core/src/browser/breadcrumbs.ts b/packages/core/src/browser/breadcrumbs.ts new file mode 100644 index 0000000..3d6dad9 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs.ts @@ -0,0 +1,73 @@ +import { Client } from '../client'; +import { scrubString } from '../pii'; + +/** + * Install passive breadcrumb wrappers for fetch + navigation. They never throw + * into user code; failures are swallowed. + */ +let fetchInstalled = false; +let navInstalled = false; + +export function installFetchBreadcrumbs(): void { + if (fetchInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') return; + fetchInstalled = true; + const originalFetch = window.fetch.bind(window); + window.fetch = async function smooFetch(input: RequestInfo | URL, init?: RequestInit) { + const started = Date.now(); + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + try { + const res = await originalFetch(input as RequestInfo, init); + Client.addBreadcrumb( + 'fetch', + `${method} ${scrubString(url)} ${res.status}`, + { + method, + url: scrubString(url), + status: res.status, + duration_ms: Date.now() - started, + }, + res.ok ? 'info' : 'warning', + ); + return res; + } catch (err) { + Client.addBreadcrumb( + 'fetch', + `${method} ${scrubString(url)} threw`, + { + method, + url: scrubString(url), + error: err instanceof Error ? err.message : String(err), + duration_ms: Date.now() - started, + }, + 'error', + ); + throw err; + } + } as typeof window.fetch; +} + +export function installNavigationBreadcrumbs(): void { + if (navInstalled || typeof window === 'undefined') return; + navInstalled = true; + + const pushBreadcrumb = (kind: string, to: string) => { + Client.addBreadcrumb('navigation', `${kind} → ${to}`, { kind, to }); + }; + + // history.pushState / replaceState + const wrap = (name: K) => { + const original = history[name]; + history[name] = function smooHistory(this: History, ...args: Parameters) { + const result = (original as (...a: unknown[]) => unknown).apply(this, args); + const url = String(args[2] ?? window.location.href); + pushBreadcrumb(name, url); + return result; + } as History[K]; + }; + wrap('pushState'); + wrap('replaceState'); + + window.addEventListener('popstate', () => pushBreadcrumb('popstate', window.location.href)); + window.addEventListener('hashchange', () => pushBreadcrumb('hashchange', window.location.href)); +} diff --git a/packages/core/src/browser/console-tap.ts b/packages/core/src/browser/console-tap.ts new file mode 100644 index 0000000..a0daa34 --- /dev/null +++ b/packages/core/src/browser/console-tap.ts @@ -0,0 +1,27 @@ +import { Client } from '../client'; + +let installed = false; + +/** + * Optional tap on `console.error`. Captures the first argument (string or + * Error) as a level=error event. Disabled by default — turn on with + * `Client.init({ autoInstrumentation: true })`. + */ +export function installConsoleErrorTap(): void { + if (installed || typeof console === 'undefined') return; + installed = true; + const original = console.error.bind(console); + console.error = (...args: unknown[]) => { + try { + const first = args[0]; + if (first instanceof Error) { + Client.captureException(first, { tags: { source: 'console.error' } }); + } else if (typeof first === 'string') { + Client.captureMessage(first, 'error'); + } + } catch { + /* swallow */ + } + return original(...args); + }; +} diff --git a/packages/core/src/browser/global-handlers.ts b/packages/core/src/browser/global-handlers.ts new file mode 100644 index 0000000..e51aa83 --- /dev/null +++ b/packages/core/src/browser/global-handlers.ts @@ -0,0 +1,50 @@ +import { Client } from '../client'; + +/** + * Register browser-side global error capture: `window.onerror` and + * `window.onunhandledrejection`. Composes with any existing handlers so we + * don't clobber app code or other SDKs. + * + * Idempotent — calling twice is a no-op. + */ +let installed = false; + +export function registerBrowserGlobalHandlers(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + + const prevOnError = window.onerror; + window.onerror = function smooErrorHandler(message, source, lineno, colno, error) { + try { + const err = error instanceof Error ? error : new Error(typeof message === 'string' ? message : 'window.onerror'); + Client.captureException(err, { + tags: { + source: 'window.onerror', + ...(source ? { file: String(source) } : {}), + ...(lineno ? { lineno: String(lineno) } : {}), + ...(colno ? { colno: String(colno) } : {}), + }, + }); + } catch { + /* swallow — observability must not throw */ + } + if (typeof prevOnError === 'function') { + return (prevOnError as OnErrorEventHandlerNonNull).call(window, message, source, lineno, colno, error); + } + return false; + }; + + const prevOnUnhandled = window.onunhandledrejection; + window.onunhandledrejection = function smooRejectionHandler(event) { + try { + const reason = (event as PromiseRejectionEvent).reason; + const err = reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : 'unhandledrejection'); + Client.captureException(err, { tags: { source: 'unhandledrejection' } }); + } catch { + /* swallow */ + } + if (typeof prevOnUnhandled === 'function') { + return (prevOnUnhandled as (this: Window, ev: PromiseRejectionEvent) => unknown).call(window, event as PromiseRejectionEvent); + } + }; +} diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index 1093771..04da3ab 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -1,18 +1,37 @@ /** - * Browser entry — registers global capture handlers and a beacon-aware - * batched HTTP transport. - * - * This file is the integration surface. The capture handlers themselves and - * the breadcrumb wrappers live in sibling files and are wired here. + * Browser entry point. Side-effect: when consumed via the package `exports` + * map browser condition, the universal `Client.init` resolves the browser + * runtime (we wire that here lazily, NOT at import time, to keep tree-shaking + * intact for users who import only types). */ +import { Client } from '../client'; +import { installFetchBreadcrumbs, installNavigationBreadcrumbs } from './breadcrumbs'; +import { installConsoleErrorTap } from './console-tap'; +import { registerBrowserGlobalHandlers } from './global-handlers'; +import { makeBrowserTransport } from './transport'; + export { Client, Scope, withScope, getCurrentScope } from '../index'; export * from '../types'; +export { parseStack } from '../stack-parser'; +export { registerBrowserGlobalHandlers } from './global-handlers'; +export { installFetchBreadcrumbs, installNavigationBreadcrumbs } from './breadcrumbs'; +export { installConsoleErrorTap } from './console-tap'; +export { makeBrowserTransport } from './transport'; -// TODO (SMOODEV-1067 follow-ups): -// - registerBrowserGlobalHandlers() → window.onerror, unhandledrejection, console.error tap -// - installFetchBreadcrumbs() → wrap window.fetch + XHR -// - installNavigationBreadcrumbs() → history.pushState, popstate, hashchange -// - installClickBreadcrumbs() → document.addEventListener('click', ...) -// - browserStackParser() → Chrome/Firefox/Safari format normalization -// - browserTransport() → batched fetch + sendBeacon on pagehide -// - registerOfflineQueue() → IndexedDB-backed queue with retry on focus +// Auto-wire on init. The Client.init implementation lives in core/client.ts +// and stores options; we hook into it here by re-defining the init behavior +// to register integrations after the first call. +const originalInit = Client.init.bind(Client); +Client.init = (options) => { + originalInit(options); + if (options.autoInstrumentation !== false) { + registerBrowserGlobalHandlers(); + installFetchBreadcrumbs(); + installNavigationBreadcrumbs(); + // Console tap is opt-in via explicit autoInstrumentation flag. + } + const transport = makeBrowserTransport(options); + Client._registerTransport(async (batch) => { + for (const evt of batch) transport.enqueue(evt); + }); +}; diff --git a/packages/core/src/browser/transport.ts b/packages/core/src/browser/transport.ts new file mode 100644 index 0000000..d3b9971 --- /dev/null +++ b/packages/core/src/browser/transport.ts @@ -0,0 +1,30 @@ +import { Transport } from '../transport'; +import type { ClientOptions } from '../types'; + +/** + * Build a browser-flavored Transport: keepalive fetch, `sendBeacon` on + * `pagehide`, bound to `visibilitychange` for the modern browser-lifecycle path. + */ +export function makeBrowserTransport(opts: ClientOptions): Transport { + const adapter = { + canBeacon: typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function', + beacon: typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ? navigator.sendBeacon.bind(navigator) : undefined, + bindLifecycle: (onPageHide: () => void) => { + if (typeof window === 'undefined') return; + // `pagehide` is the modern unload event; visibilitychange is the bfcache-friendly path. + window.addEventListener('pagehide', onPageHide, { capture: true }); + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') onPageHide(); + }); + }, + }; + return new Transport( + { + dsn: opts.dsn, + flushIntervalMs: opts.flushIntervalMs, + maxBatchSize: opts.maxBatchSize, + maxQueueSize: opts.maxQueueSize, + }, + adapter, + ); +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 99ee9aa..21a7089 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,4 +1,5 @@ import { getCurrentScope } from './scope'; +import { dropSdkFrames, parseStack } from './stack-parser'; import type { ClientOptions, ExceptionInfo, Level, ObservabilityEvent, Runtime, StackFrame } from './types'; const SDK_NAME = '@smooai/observability'; @@ -91,26 +92,32 @@ class _Client { } function toException(err: unknown): ExceptionInfo { - // Minimal conversion — full stack-frame parsing lives in runtime modules. if (err instanceof Error) { - return { + const exc: ExceptionInfo = { type: err.name, value: err.message, - stacktrace: { frames: parseStackString(err.stack) }, + stacktrace: { frames: dropSdkFrames(parseStack(err.stack)) }, }; + // Walk Error.cause for chained exceptions. + const cause = (err as { cause?: unknown }).cause; + if (cause !== undefined && cause !== null) { + exc.cause = toException(cause); + } + return exc; } return { type: 'Unknown', - value: typeof err === 'string' ? err : JSON.stringify(err), + value: typeof err === 'string' ? err : safeStringify(err), stacktrace: { frames: [] }, }; } -function parseStackString(stack: string | undefined): StackFrame[] { - if (!stack) return []; - // Real parser lives in runtime entries (different stack formats per engine). - // Here, return one synthetic frame so the event is well-formed. - return [{ module: 'unparsed', inApp: true }]; +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v); + } catch { + return String(v); + } } export const Client = new _Client(); diff --git a/packages/core/src/pii.ts b/packages/core/src/pii.ts new file mode 100644 index 0000000..2393f6e --- /dev/null +++ b/packages/core/src/pii.ts @@ -0,0 +1,31 @@ +/** + * PII scrubbing — applied to message strings, breadcrumb messages, and headers + * before transport. Stays opinionated and minimal; tenants can extend in + * `beforeSend`. + */ + +const PII_PATTERNS: Array<{ re: RegExp; replacement: string }> = [ + { re: /Bearer\s+[A-Za-z0-9._-]+/gi, replacement: 'Bearer [redacted]' }, + { re: /\b(?:password|passwd|pwd)["']?\s*[:=]\s*["']?[^"'&\s]+/gi, replacement: 'password=[redacted]' }, + { re: /\b(?:token|api[-_]?key|apikey|secret)["']?\s*[:=]\s*["']?[^"'&\s]+/gi, replacement: '$&'.replace(/=.*/, '=[redacted]') }, + { re: /\bsk-[A-Za-z0-9]{20,}/g, replacement: 'sk-[redacted]' }, +]; + +const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token']); + +export function scrubString(input: string): string { + let out = input; + for (const { re, replacement } of PII_PATTERNS) { + out = out.replace(re, replacement); + } + return out; +} + +export function scrubHeaders(headers: Record | undefined): Record | undefined { + if (!headers) return headers; + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '[redacted]' : scrubString(v); + } + return out; +} diff --git a/packages/core/src/stack-parser.ts b/packages/core/src/stack-parser.ts new file mode 100644 index 0000000..53b0c93 --- /dev/null +++ b/packages/core/src/stack-parser.ts @@ -0,0 +1,73 @@ +import type { StackFrame } from './types'; + +/** + * Parse a JS Error.stack string into structured frames. + * + * Supports the three engines we care about: + * - V8 (Chrome / Node / Edge) "at fn (path:L:C)" + * - Spidermonkey (Firefox) "fn@path:L:C" + * - JavaScriptCore (Safari / older WebKit) "fn@path:L:C" (same shape as Firefox) + * + * Frames are returned innermost-first to match the @smooai/observability event + * envelope. SDK-internal frames and frames pointing inside node_modules are + * tagged `inApp: false`. + */ + +const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?\s*$/; +const GECKO_FRAME = /^(?:(.*?)@)?(.+?)(?::(\d+))?(?::(\d+))?$/; + +const SDK_INTERNAL_HINTS = ['@smooai/observability', 'packages/core/dist', 'packages/core/src']; +const NODE_MODULES_RE = /[\\/]node_modules[\\/]/; + +export function parseStack(stack: string | undefined): StackFrame[] { + if (!stack) return []; + const lines = stack + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + const frames: StackFrame[] = []; + for (const line of lines) { + const frame = parseLine(line); + if (frame) frames.push(frame); + } + return frames; +} + +function parseLine(line: string): StackFrame | null { + // Skip the leading "Error: ..." / "TypeError: ..." line some engines include. + if (/^(?:[A-Z][A-Za-z]*Error|Error|Uncaught)\b/.test(line)) return null; + + let m = V8_FRAME.exec(line); + if (m) { + return makeFrame(m[1], m[2], m[3], m[4]); + } + m = GECKO_FRAME.exec(line); + if (m) { + return makeFrame(m[1], m[2], m[3], m[4]); + } + return null; +} + +function makeFrame(fnName: string | undefined, modRaw: string | undefined, linenoRaw: string | undefined, colnoRaw: string | undefined): StackFrame { + const moduleStr = (modRaw ?? '').replace(/^.*?\((.*)\)$/, '$1').trim() || 'anonymous'; + const lineno = linenoRaw ? Number(linenoRaw) : undefined; + const colno = colnoRaw ? Number(colnoRaw) : undefined; + const isInternal = SDK_INTERNAL_HINTS.some((h) => moduleStr.includes(h)); + const isVendor = NODE_MODULES_RE.test(moduleStr); + return { + module: moduleStr, + function: fnName?.trim() || undefined, + lineno, + colno, + inApp: !isInternal && !isVendor, + }; +} + +/** Strip SDK-internal frames from the top of a stack. Used by `captureException` */ +export function dropSdkFrames(frames: StackFrame[]): StackFrame[] { + let i = 0; + while (i < frames.length && frames[i] && frames[i]!.inApp === false && SDK_INTERNAL_HINTS.some((h) => frames[i]!.module.includes(h))) { + i++; + } + return frames.slice(i); +} diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts new file mode 100644 index 0000000..ebe3318 --- /dev/null +++ b/packages/core/src/transport.ts @@ -0,0 +1,103 @@ +import type { ClientOptions, IngestPayload, ObservabilityEvent } from './types'; + +const DEFAULT_FLUSH_MS = 1000; +const DEFAULT_BATCH_SIZE = 30; +const DEFAULT_QUEUE_MAX = 250; + +interface TransportRuntimeAdapter { + /** Whether `navigator.sendBeacon` is available (browser). */ + canBeacon: boolean; + /** Beacon implementation, if available. */ + beacon?: (url: string, body: string) => boolean; + /** Bind `pagehide` so we can flush via beacon. */ + bindLifecycle?: (onPageHide: () => void) => void; +} + +/** + * Universal batched transport. Holds a small queue, flushes on a timer or when + * `maxBatchSize` events are buffered, and falls back to `sendBeacon` when the + * page is unloading. + * + * Errors are swallowed — observability must never throw into user code. + */ +export class Transport { + private queue: ObservabilityEvent[] = []; + private timer: ReturnType | null = null; + private inFlight = false; + + constructor( + private readonly opts: Required> & Pick, + private readonly adapter: TransportRuntimeAdapter, + ) { + adapter.bindLifecycle?.(() => this.flushBeacon()); + } + + enqueue(event: ObservabilityEvent): void { + const max = this.opts.maxQueueSize ?? DEFAULT_QUEUE_MAX; + if (this.queue.length >= max) { + // Drop oldest to make room — recent events are more useful. + this.queue.shift(); + } + this.queue.push(event); + if (this.queue.length >= (this.opts.maxBatchSize ?? DEFAULT_BATCH_SIZE)) { + void this.flush(); + } else if (!this.timer) { + this.timer = setTimeout(() => void this.flush(), this.opts.flushIntervalMs ?? DEFAULT_FLUSH_MS); + } + } + + async flush(): Promise { + if (this.inFlight || this.queue.length === 0) { + this.clearTimer(); + return; + } + this.inFlight = true; + const batch = this.queue.splice(0, this.opts.maxBatchSize ?? DEFAULT_BATCH_SIZE); + this.clearTimer(); + try { + const payload: IngestPayload = { type: 'error', events: batch }; + await fetch(this.opts.dsn, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true, + }); + } catch { + // Best-effort: push events back to the front of the queue for next attempt. + this.queue.unshift(...batch); + } finally { + this.inFlight = false; + if (this.queue.length > 0 && !this.timer) { + this.timer = setTimeout(() => void this.flush(), this.opts.flushIntervalMs ?? DEFAULT_FLUSH_MS); + } + } + } + + flushBeacon(): void { + if (this.queue.length === 0) return; + if (!this.adapter.canBeacon || !this.adapter.beacon) { + // Fall back to fire-and-forget fetch with keepalive. + void this.flush(); + return; + } + const batch = this.queue.splice(0, this.queue.length); + const payload: IngestPayload = { type: 'error', events: batch }; + const ok = this.adapter.beacon(this.opts.dsn, JSON.stringify(payload)); + if (!ok) { + // Beacon failed (over 64KB or browser declined) — put events back. + this.queue.unshift(...batch); + } + } + + private clearTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** For tests. */ + _queueSize(): number { + return this.queue.length; + } +}