diff --git a/packages/runtime-browser/src/index.ts b/packages/runtime-browser/src/index.ts index 8f84a20..6a3efb9 100644 --- a/packages/runtime-browser/src/index.ts +++ b/packages/runtime-browser/src/index.ts @@ -32,7 +32,7 @@ export interface AutterBrowserOptions { release?: string; /** Send a session_start ping on init (default true). */ sessionTracking?: boolean; - /** Last-chance hook: mutate or drop (return null) an event before send. */ + /** Last-chance hook: mutate or drop (return null) an event before send. Throwing drops the event. */ beforeSend?: (event: BrowserEvent) => BrowserEvent | null; /** Observe failed fetch and XHR requests and 5xx responses (default true). */ captureNetworkFailures?: boolean; @@ -311,9 +311,14 @@ function enqueue(event: BrowserEvent, urgent?: boolean): void { // Scrub before beforeSend so the last-chance hook sees the final form. if (event.context) event.context = redactContext(event.context); if (opts.beforeSend) { - const mapped = opts.beforeSend(event); - if (!mapped) return; - event = mapped; + try { + const mapped = opts.beforeSend(event); + if (!mapped) return; + event = mapped; + } catch { + // A telemetry hook must not change application request outcomes. + return; + } } queue.push(event); if (queue.length >= MAX_QUEUE) { diff --git a/packages/runtime-browser/test/before-send.test.mjs b/packages/runtime-browser/test/before-send.test.mjs new file mode 100644 index 0000000..2db1f10 --- /dev/null +++ b/packages/runtime-browser/test/before-send.test.mjs @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { captureMessage, flush, initAutterBrowser } from "../dist/index.js"; + +test("beforeSend failures preserve application requests and later delivery", async () => { + const response = new Response("unavailable", { status: 503 }); + const networkError = new Error("original network failure"); + const hookError = new Error("beforeSend failure"); + const sent = []; + let rejectRequest = false; + let hookMode = "throw"; + globalThis.window = { + fetch: async () => { + if (rejectRequest) throw networkError; + return response; + }, + addEventListener() {}, + }; + globalThis.document = { addEventListener() {}, visibilityState: "visible" }; + globalThis.location = { href: "https://app.example.test/", pathname: "/" }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + sendBeacon(_url, body) { sent.push(body); return true; }, + }, + }); + initAutterBrowser({ + endpoint: "/api/autter-runtime", + service: "web", + sessionTracking: false, + captureActions: false, + captureTimings: false, + beforeSend(event) { + if (hookMode === "throw") throw hookError; + if (hookMode === "drop") return null; + return { ...event, message: "mapped message" }; + }, + }); + + assert.equal(await window.fetch("/checkout"), response); + rejectRequest = true; + await assert.rejects(window.fetch("/offline"), (error) => error === networkError); + assert.doesNotThrow(() => captureMessage("failed hook")); + flush(); + assert.equal(sent.length, 0); + + hookMode = "drop"; + captureMessage("dropped message"); + flush(); + assert.equal(sent.length, 0); + + hookMode = "map"; + captureMessage("healthy message"); + flush(); + assert.equal(sent.length, 1); + const payload = JSON.parse(await sent[0].text()); + assert.equal(payload.events.length, 1); + assert.equal(payload.events[0].message, "mapped message"); +});