From 4508425483a1727238797d31907732b62ddc0f77 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:41 +0100 Subject: [PATCH 1/2] fix(sdk): capture cannot change the app's outcome, and both SDKs coordinate on one page Browser session core (bundled into both published SDKs): - Network capture only observes. Whatever it throws before or after the real fetch (a URL filter, a body pattern, the background body read) is dropped, so the app's fetch resolves or rejects exactly as the real one did. A failed request is still recorded. The XHR loadend bookkeeping is guarded the same way, since a throw there is an uncaught error on the page. - Session events are stamped with time, URL and trace id in emit, not when the row is built. A flush runs up to a minute later, so a click that navigated was attributed to the destination page and the trace id was nearly always empty. - The stored session record is parsed once per distinct string. It was parsed and validated on every span and replay input event. Reads still hit storage, so when a session is active or expired is unchanged. Browser SDK: - init() with no argument runs on defaults with the missing-name warning. A throw during startup is reported, what had started is shut down, and init() returns a handle that does nothing. - Remove .npmignore: package.json files already whitelists dist and README. Effect SDK: - The client uses the page-wide error claim and tells the page's error listeners, and leases the webVitals signal, so one error is one issue and vitals are reported once with both SDKs on a page. - No replay for crawlers, as the browser SDK already does. - The client keeps and calls the sanitizeUrl remover, and always listens for consent changes, since another SDK copy can turn the gate on later. - Maple.layer (client and server) sends x-maple-sdk; the server layer also names itself in user-agent like the other server presets. Housekeeping: - Ignore @maple-examples/effect-todo in changesets, so an SDK release no longer bumps the example and rewrites its changelog. - Docs: anticipatedErrorTags is deprecated in favour of anticipatedErrorIdentifiers. - sdk-core option resolvers are only used by the browser SDK: say so. --- .changeset/config.json | 2 +- .changeset/sdk-audit-capture-fixes.md | 15 +++ .../src/content/docs/sdks/effect-client.md | 2 +- .../src/events/events-sink.browser.test.ts | 57 +++++++++- .../browser-session/src/events/events-sink.ts | 37 +++--- .../replay/capture/network.browser.test.ts | 98 +++++++++++++++- .../src/replay/capture/network.ts | 105 ++++++++++++------ .../src/session/session.test.ts | 26 +++++ .../browser-session/src/session/session.ts | 14 ++- packages/browser/.npmignore | 6 - packages/browser/src/config.ts | 2 +- packages/browser/src/init.test.ts | 50 ++++++++- packages/browser/src/init.ts | 46 ++++++-- .../src/client/flushable-web-vitals.test.ts | 32 +++++- .../effect-sdk/src/client/flushable.test.ts | 75 +++++++++++++ packages/effect-sdk/src/client/flushable.ts | 27 +++-- packages/effect-sdk/src/client/layer.test.ts | 29 +++++ packages/effect-sdk/src/client/layer.ts | 7 +- .../src/client/replay-loader.test.ts | 89 ++++++++++++++- .../effect-sdk/src/client/replay-loader.ts | 46 ++++---- packages/effect-sdk/src/server/layer.test.ts | 19 ++++ packages/effect-sdk/src/server/layer.ts | 15 ++- .../effect-sdk/src/shared/flushable-tracer.ts | 19 +++- packages/sdk-core/src/index.ts | 2 + packages/sdk-core/src/options.ts | 8 +- 25 files changed, 710 insertions(+), 118 deletions(-) create mode 100644 .changeset/sdk-audit-capture-fixes.md delete mode 100644 packages/browser/.npmignore diff --git a/.changeset/config.json b/.changeset/config.json index fba4642bd1..74c37f8381 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,6 +7,6 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@maple/landing", "@maple/clickhouse-cli"], + "ignore": ["@maple/landing", "@maple/clickhouse-cli", "@maple-examples/effect-todo"], "privatePackages": { "version": true, "tag": false } } diff --git a/.changeset/sdk-audit-capture-fixes.md b/.changeset/sdk-audit-capture-fixes.md new file mode 100644 index 0000000000..4630be6290 --- /dev/null +++ b/.changeset/sdk-audit-capture-fixes.md @@ -0,0 +1,15 @@ +--- +"@maple-dev/browser": patch +"@maple-dev/effect-sdk": patch +--- + +Capture no longer changes what the app sees, and both SDKs agree on one page: + +- Network capture cannot reject a `fetch` that succeeded. Anything capture throws after the response arrived (a URL filter, a body pattern, reading the body in the background) is dropped, the app gets the real response, and a failed request still rejects with its own error. An XHR's `loadend` bookkeeping can no longer raise an uncaught error on the page. +- Session events carry the time, page URL and trace id of the moment they happened. Clicks, inputs, errors, navigations, console and network events were stamped when the batch was sent, up to a minute later: a click that navigated was attributed to the destination page, and the trace id was almost always empty. +- `MapleBrowser.init()` with no argument starts on defaults and warns about the missing `serviceName` instead of throwing. If startup throws, `init()` warns, undoes what had started and returns a handle that does nothing, so telemetry cannot take the app's entrypoint down. +- With `@maple-dev/browser` and the Effect client on one page, an uncaught error is recorded once and web vitals are reported by one of the two. The Effect client now uses the page-wide error claim and the `webVitals` lease, and tells the page's error listeners (breadcrumbs, replay on error) about the errors it records. +- The Effect client records no replay for crawlers, as `@maple-dev/browser` already did. They still get a session row and events. +- The Effect client removes its `privacy.sanitizeUrl` when it stops, so a remount no longer stacks a second copy, and it stops capturing when another Maple SDK on the page turns `privacy.requireConsent` on later. +- `Maple.layer` (client and server) sends the `x-maple-sdk` header like the other presets; the server layer also names itself in `user-agent`. +- The session record is no longer JSON-parsed and validated for every span and replay input event: while the stored string is unchanged, the record parsed last time is reused. diff --git a/apps/landing/src/content/docs/sdks/effect-client.md b/apps/landing/src/content/docs/sdks/effect-client.md index fcb57ab47f..48541cb81a 100644 --- a/apps/landing/src/content/docs/sdks/effect-client.md +++ b/apps/landing/src/content/docs/sdks/effect-client.md @@ -132,7 +132,7 @@ Effect.runPromise(program.pipe(Effect.provide(telemetry.layer))) await telemetry.flush() ``` -It takes `serviceName`, `region`, `endpoint`, `ingestKey`, `serviceVersion`, `serviceNamespace`, `environment`, `attributes`, `excludeLogSpans`, `replay`, `emitSessionMeta` and `privacy` as `Maple.layer` does, the span filters (`dropSpanNames`, `dropSpanSubtrees`, `dropSpan`, `anticipatedErrorTags`, `anticipatedErrorIdentifiers`, `isAnticipatedError`), and these options of its own: +It takes `serviceName`, `region`, `endpoint`, `ingestKey`, `serviceVersion`, `serviceNamespace`, `environment`, `attributes`, `excludeLogSpans`, `replay`, `emitSessionMeta` and `privacy` as `Maple.layer` does, the span filters (`dropSpanNames`, `dropSpanSubtrees`, `dropSpan`, `anticipatedErrorIdentifiers`, `isAnticipatedError`, and the deprecated `anticipatedErrorTags`, which `anticipatedErrorIdentifiers` replaces with the same values), and these options of its own: | Option | Default | Description | | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------- | diff --git a/packages/browser-session/src/events/events-sink.browser.test.ts b/packages/browser-session/src/events/events-sink.browser.test.ts index fe121defa7..689c525428 100644 --- a/packages/browser-session/src/events/events-sink.browser.test.ts +++ b/packages/browser-session/src/events/events-sink.browser.test.ts @@ -10,7 +10,7 @@ vi.mock("../platform/transport", async (importOriginal) => ({ postSessionEvents: vi.fn(async () => "accepted" as const), })) -const { resetSinkForTests, startEventSink } = await import("./events-sink") +const { resetSinkForTests, setActiveTraceIdProvider, startEventSink } = await import("./events-sink") const { postSessionEvents } = await import("../platform/transport") const session = await import("../session/session") const { resetVisitorCacheForTests, setVisitorTracking } = await import("../identity/visitor") @@ -420,6 +420,61 @@ describe("startEventSink activity", () => { }) }) +// A row is built when it is sent, up to a minute after the event. It must still +// say when and where the event happened. +describe("startEventSink event stamping", () => { + const postedRows = () => vi.mocked(postSessionEvents).mock.calls.flatMap(([, rows]) => rows) + const path = (row: Record): string => new URL(String(row.url)).pathname + + beforeEach(() => { + resetSinkForTests() + vi.mocked(postSessionEvents).mockReset().mockResolvedValue("accepted") + history.replaceState(null, "", "/cart") + }) + + it("keeps the time and page of an event flushed after a navigation", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }) + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")) + try { + const sink = startEventSink(CONFIG, "sess-stamp") + // The click that sends the app to the next page. + sink.emit({ type: "click", targetSelector: "button#checkout" }) + vi.setSystemTime(new Date("2026-03-01T10:00:04.000Z")) + history.pushState(null, "", "/checkout") + vi.setSystemTime(new Date("2026-03-01T10:00:30.000Z")) + await sink.flush() + + const click = postedRows().find((row) => row.type === "click") + expect(click?.timestamp).toBe("2026-03-01 10:00:00.000") + expect(click && path(click)).toBe("/cart") + const navigation = postedRows().find( + (row) => row.type === "navigation" && path(row) === "/checkout", + ) + expect(navigation?.timestamp).toBe("2026-03-01 10:00:04.000") + sink.stop() + } finally { + vi.useRealTimers() + } + }) + + it("reads the active trace when the event happens, not when it is sent", async () => { + const sink = startEventSink(CONFIG, "sess-trace") + setActiveTraceIdProvider(() => "0af7651916cd43dd8448eb211c80319c") + try { + sink.emit({ type: "click", targetSelector: "button" }) + } finally { + // The span has ended by the time the flush timer runs. + setActiveTraceIdProvider(() => undefined) + } + await sink.flush() + + expect(postedRows().find((row) => row.type === "click")?.trace_id).toBe( + "0af7651916cd43dd8448eb211c80319c", + ) + sink.stop() + }) +}) + describe("startEventSink history wrapper", () => { beforeEach(() => resetSinkForTests()) diff --git a/packages/browser-session/src/events/events-sink.ts b/packages/browser-session/src/events/events-sink.ts index 7a1f914473..655f69d497 100644 --- a/packages/browser-session/src/events/events-sink.ts +++ b/packages/browser-session/src/events/events-sink.ts @@ -90,8 +90,11 @@ export interface SessionEventSink { ignoreUrl: (url: string) => boolean } +/** An event as buffered: `emit` has filled in when and where it happened. */ +type StampedEvent = SessionEvent & { readonly timestamp: number; readonly url: string } + interface BufferedEvent { - readonly ev: SessionEvent + readonly ev: StampedEvent readonly seq: number } @@ -273,8 +276,16 @@ export function startEventSink(config: IngestConfig, sessionId: string): Session } else if (ev.type === "click") clickCount++ else if (ev.type === "error" || (ev.type === "console" && ev.level === "error")) errorCount++ if (refused || isRepeatedError(ev)) return - buffer.push({ ev, seq: seq++ }) - bufferBytes += approximateSize(ev) + // Stamped now, not when the row is built: a flush can run a minute later, on + // another page of the app and outside the span that was active. + const stamped: StampedEvent = { + ...ev, + timestamp: ev.timestamp ?? Date.now(), + url: ev.url ?? (typeof location !== "undefined" ? location.href : ""), + traceId: ev.traceId ?? activeTraceId(), + } + buffer.push({ ev: stamped, seq: seq++ }) + bufferBytes += approximateSize(stamped) while (bufferBytes > MAX_BUFFER_BYTES && buffer.length > 1) { const dropped = buffer.shift() if (dropped) bufferBytes -= approximateSize(dropped.ev) @@ -283,7 +294,7 @@ export function startEventSink(config: IngestConfig, sessionId: string): Session for (const listener of listeners()) { // A listener must never break capture. try { - listener(ev) + listener(stamped) } catch {} } } @@ -444,16 +455,16 @@ function drainPending(sink: SessionEventSink): void { /** * Map an internal event to the snake_case ingest row (org_id is added server-side). * - * Identity is resolved here, at flush time, alongside the trace id: it is the - * person key funnels group on, and reading it late means an `identify()` that - * lands shortly after init still stamps the initial page view. `visitor_id` is - * `""` whenever the visitor cookie is off (consent, GPC, `persistVisitorId: - * false`) — the same rule the metadata row applies. + * Identity is resolved here, at flush time: it is the person key funnels group + * on, and reading it late means an `identify()` that lands shortly after init + * still stamps the initial page view. `visitor_id` is `""` whenever the visitor + * cookie is off (consent, GPC, `persistVisitorId: false`), the same rule the + * metadata row applies. Time, URL and trace id were stamped by `emit`. */ function toRow( config: IngestConfig, sessionId: string, - ev: SessionEvent, + ev: StampedEvent, seq: number, ): Record { const identity = config.getIdentity?.() @@ -462,11 +473,11 @@ function toRow( visitor_id: getVisitorId() ?? "", user_id: identity?.id ?? "", group_id: identity?.groupId ?? "", - timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())), + timestamp: formatCHDateTime(new Date(ev.timestamp)), seq, type: ev.type, - url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")), - trace_id: ev.traceId ?? activeTraceId() ?? "", + url: scrubUrl(ev.url), + trace_id: ev.traceId ?? "", level: ev.level ?? "", message: ev.message ?? "", target_selector: ev.targetSelector ?? "", diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index 5f11015c90..426d1bf25e 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { noteStartedTraceId } from "../../events/trace-id" import type { SessionEvent } from "../../events/events-sink" +import { forgetPatchesForTests } from "../../capture/patch" import { installNetworkCapture } from "./network" const originalOpen = XMLHttpRequest.prototype.open @@ -357,3 +358,98 @@ describe("installNetworkCapture next to the app's own wrappers", () => { expect(events.filter((event) => event.type === "network")).toHaveLength(1) }) }) + +describe("installNetworkCapture when capture itself throws", () => { + const realFetch = window.fetch + // The tests above leave an inert wrapper registered for a `fetch` that is gone. + beforeEach(() => forgetPatchesForTests(window)) + afterEach(() => { + // Capture first: it only unwraps while its wrapper is still the page's fetch. + uninstall?.() + uninstall = undefined + window.fetch = realFetch + }) + const brokenFilter = (): boolean => { + throw new Error("filter broke") + } + + it("resolves the app's fetch with the real response", async () => { + window.fetch = async () => new Response("ok", { status: 201 }) + uninstall = installNetworkCapture(() => {}, brokenFilter) + + const response = await fetch("https://api.test/orders") + + expect(response.status).toBe(201) + expect(await response.text()).toBe("ok") + }) + + it("resolves it when the body is read in the background, with no stray rejection", async () => { + window.fetch = async () => new Response("ok", { headers: { "content-type": "text/plain" } }) + const rejections: unknown[] = [] + const onRejection = (event: PromiseRejectionEvent): void => { + event.preventDefault() + rejections.push(event.reason) + } + window.addEventListener("unhandledrejection", onRejection) + try { + uninstall = installNetworkCapture(() => {}, brokenFilter, { urls: ["/orders"], maxLength: 100 }) + + const response = await fetch("https://api.test/orders") + expect(await response.text()).toBe("ok") + // `unhandledrejection` is dispatched a task after the microtasks drain. + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(rejections).toEqual([]) + } finally { + window.removeEventListener("unhandledrejection", onRejection) + } + }) + + it("rejects with the real fetch's own error", async () => { + const failure = new TypeError("Failed to fetch") + window.fetch = async () => { + throw failure + } + uninstall = installNetworkCapture(() => {}, brokenFilter) + + await expect(fetch("https://api.test/orders")).rejects.toBe(failure) + }) + + it("still records a request the real fetch failed", async () => { + const failure = new TypeError("Failed to fetch") + window.fetch = async () => { + throw failure + } + const events: SessionEvent[] = [] + uninstall = installNetworkCapture( + (event) => events.push(event), + () => false, + ) + + await expect(fetch("https://api.test/orders")).rejects.toBe(failure) + + expect(events.map((event) => [event.net?.status, event.attrs?.error])).toEqual([ + [0, "TypeError: Failed to fetch"], + ]) + }) + + it("keeps an XHR's loadend listener from throwing on the page", async () => { + const errors: unknown[] = [] + const onError = (event: ErrorEvent): void => { + event.preventDefault() + errors.push(event.error) + } + window.addEventListener("error", onError) + try { + uninstall = installNetworkCapture(() => {}, brokenFilter) + const xhr = new XMLHttpRequest() + xhr.open("GET", "/") + await request(xhr) + + expect(xhr.readyState).toBe(XMLHttpRequest.DONE) + expect(errors).toEqual([]) + } finally { + window.removeEventListener("error", onError) + } + }) +}) diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index dd8522eab9..8893d94fdf 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -168,51 +168,77 @@ function wrapFetch(original: typeof fetch, hooks: () => NetworkHooks | undefined return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const live = hooks() if (!live) return original(input, init) - const url = requestUrl(input) - const method = requestMethod(input, init) - const ambientTraceId = activeTraceId() + // Capture only observes: whatever it throws is dropped, so the app's fetch + // resolves or rejects exactly as the real one did. + const request = quietly(() => ({ + url: requestUrl(input), + method: requestMethod(input, init), + traceId: activeTraceId(), + })) + if (!request) return original(input, init) + const { url, method } = request const start = performance.now() - let traceId = ambientTraceId + let traceId = request.traceId + let res: Response try { // Synchronous up to here, so a fetch instrumentation wrapped inside // this one starts its span within the slot. const call = withStartedTraceId(() => original(input, init)) - traceId = call.traceId ?? ambientTraceId - const res = await call.result - if (!live.wantsBody(url)) { - live.record(url, method, res.status, start, traceId) - return res - } - // Read a clone in the background: the app gets its response untouched and unwaited. - const requestBody = typeof init?.body === "string" ? init.body : undefined - const done = performance.now() - const contentType = res.headers.get("content-type") ?? "" - void ( - isReadableText(contentType) - ? readPrefix(res.clone(), live.maxLength) - : Promise.resolve(undefined) - ) - .catch(() => undefined) - .then((responseBody) => - live.record( - url, - method, - res.status, - start, - traceId, - undefined, - live.bodyAttrs(requestBody, responseBody), - done, - ), - ) - return res + traceId = call.traceId ?? traceId + res = await call.result } catch (error) { - live.record(url, method, 0, start, traceId, String(error)) + quietly(() => live.record(url, method, 0, start, traceId, String(error))) throw error } + quietly(() => recordResponse(live, res, url, method, start, traceId, init)) + return res } } +/** Run a piece of capture bookkeeping, giving `undefined` if it throws. */ +function quietly(fn: () => T): T | undefined { + try { + return fn() + } catch { + return undefined + } +} + +function recordResponse( + live: NetworkHooks, + res: Response, + url: string, + method: string, + start: number, + traceId: string | undefined, + init: RequestInit | undefined, +): void { + if (!live.wantsBody(url)) { + live.record(url, method, res.status, start, traceId) + return + } + // Read a clone in the background: the app gets its response untouched and unwaited. + const requestBody = typeof init?.body === "string" ? init.body : undefined + const done = performance.now() + const contentType = res.headers.get("content-type") ?? "" + void (isReadableText(contentType) ? readPrefix(res.clone(), live.maxLength) : Promise.resolve(undefined)) + .catch(() => undefined) + .then((responseBody) => + live.record( + url, + method, + res.status, + start, + traceId, + undefined, + live.bodyAttrs(requestBody, responseBody), + done, + ), + ) + // Nothing awaits this chain: a throw here would be an unhandled rejection in the app. + .catch(() => undefined) +} + // XMLHttpRequest: `open` captures method and url, `send` times and observes. function wrapXhrOpen( original: XMLHttpRequest["open"], @@ -241,12 +267,17 @@ function wrapXhrSend( if (!live) return original.apply(this, args as never) const meta = xhrMeta(this) const start = performance.now() - let traceId = meta.__mapleTraceId ?? activeTraceId() + let traceId = meta.__mapleTraceId ?? quietly(activeTraceId) const url = meta.__mapleUrl ?? "" const requestBody = typeof args[0] === "string" ? args[0] : undefined this.addEventListener("loadend", () => { - const extra = live.wantsBody(url) ? live.bodyAttrs(requestBody, xhrResponseText(this)) : undefined - live.record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, undefined, extra) + // A throw from a listener is an uncaught error on the app's page. + quietly(() => { + const extra = live.wantsBody(url) + ? live.bodyAttrs(requestBody, xhrResponseText(this)) + : undefined + live.record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, undefined, extra) + }) }) const call = withStartedTraceId(() => original.apply(this, args as never)) traceId = call.traceId ?? traceId diff --git a/packages/browser-session/src/session/session.test.ts b/packages/browser-session/src/session/session.test.ts index b64803136d..28c538102d 100644 --- a/packages/browser-session/src/session/session.test.ts +++ b/packages/browser-session/src/session/session.test.ts @@ -208,6 +208,32 @@ describe("getSessionId", () => { expect(storage.getItem(STORAGE_KEY)).not.toBe(before) }) + it("parses the stored record once for a burst of spans", () => { + const id = getSessionId() + const parses = vi.spyOn(JSON, "parse") + for (let span = 0; span < 50; span++) expect(getSessionId()).toBe(id) + + expect(parses.mock.calls.length).toBeLessThanOrEqual(1) + parses.mockRestore() + }) + + it("sees a record another SDK copy rewrote on the very next read", () => { + getSessionId() + getSessionId() + const rewritten = { ...JSON.parse(storage.getItem(STORAGE_KEY)!), chunkSeq: 7 } + storage.setItem(STORAGE_KEY, JSON.stringify(rewritten)) + + expect(markActivity()?.chunkSeq).toBe(7) + }) + + it("hands each caller its own copy of the stored record", () => { + getSession() + const first = peekSession() + if (first) first.chunkSeq = 99 + + expect(peekSession()?.chunkSeq).toBe(0) + }) + it("keeps a session alive through span activity that getSession would have rotated", () => { const id = getSessionId() for (let i = 0; i < 5; i++) { diff --git a/packages/browser-session/src/session/session.ts b/packages/browser-session/src/session/session.ts index 610d981dc7..67aaa8d9cd 100644 --- a/packages/browser-session/src/session/session.ts +++ b/packages/browser-session/src/session/session.ts @@ -197,6 +197,12 @@ let ephemeralStorage: Storage | undefined * every heartbeat as a new session. Keep this separate from the read try/catch. */ let writesFailed = false +/** + * The raw record last read from storage and what it parsed to. The session is + * resolved per span and per replay input event, between which the stored string + * rarely changes: an unchanged one is not parsed and validated again. + */ +let lastParsed: { readonly raw: string; readonly record: SessionRecord | undefined } | undefined export type SessionRotationListener = (previous: SessionRecord, next: SessionRecord) => void const rotationListeners = new Set() @@ -254,9 +260,11 @@ function readRecord(): SessionRecord | undefined { const raw = storage.getItem(STORAGE_KEY) // Cleared under us: minting here would orphan the running recorder and lifecycle. if (!raw) return storage === ephemeralStorage ? ephemeral : undefined + if (lastParsed?.raw !== raw) lastParsed = { raw, record: parseSessionRecord(raw) } // A corrupt record falls back to the in-memory copy, exactly as an // unreadable sessionStorage does — it is the same loss of the durable copy. - return parseSessionRecord(raw) ?? ephemeral + // A copy, so no caller can change what the next read of the same string returns. + return lastParsed.record ? { ...lastParsed.record } : ephemeral } catch { return ephemeral } @@ -439,6 +447,7 @@ function ownedByAnotherTab(record: SessionRecord, now: number): boolean { /** Test seam: drop the in-memory record and its write-failure latch. */ export function resetSessionStorageStateForTests(): void { + lastParsed = undefined ephemeral = undefined ephemeralStorage = undefined writesFailed = false @@ -520,7 +529,8 @@ export function rotateSession(): SessionRecord | undefined { * Resolve the active session, rotating when the stored one has expired, and * persist the activity bump only once it has gone stale by * `ACTIVITY_TOUCH_THROTTLE_MS`. Shared by the two hot-path entry points so a - * chatty page doesn't pay a sessionStorage read *and* write per span/event. + * chatty page doesn't pay a sessionStorage read *and* write per span/event: + * the write is throttled, and an unchanged record is not parsed again. */ function touchSession(now: number): SessionRecord { const existing = readRecord() diff --git a/packages/browser/.npmignore b/packages/browser/.npmignore deleted file mode 100644 index d4abd9992c..0000000000 --- a/packages/browser/.npmignore +++ /dev/null @@ -1,6 +0,0 @@ -src -*.test.ts -tsconfig.json -tsdown.config.ts -.env.local -vitest.config.ts diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 30fa8893a2..57102f0858 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -184,7 +184,7 @@ export function resolveIdentity(config: { let warnedServiceName = false export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { - // Plain JS callers and env-driven configs can pass nothing; every span would then be unattributed. + // Plain JS callers and env-driven configs can leave it out; every span would then be unattributed. if (typeof config.serviceName !== "string" || config.serviceName.trim() === "") { if (!warnedServiceName) { warnedServiceName = true diff --git a/packages/browser/src/init.test.ts b/packages/browser/src/init.test.ts index 42ff0faa68..14c7660ecc 100644 --- a/packages/browser/src/init.test.ts +++ b/packages/browser/src/init.test.ts @@ -28,7 +28,14 @@ vi.mock("@maple/browser-session/replay", () => ({ })) import { resetSinkForTests } from "../../browser-session/src/events/events-sink" -import { identify, init } from "./init" +import type { MapleBrowserConfig } from "./config" +import { identify, init, type MapleBrowserHandle } from "./init" + +/** `init` as plain JS can call it, with no argument: the method form lets the required one stand in. */ +interface PlainJsInit { + init(config?: MapleBrowserConfig): MapleBrowserHandle +} +const plainJs: PlainJsInit = { init } class MemoryStorage { private readonly values = new Map() @@ -165,6 +172,47 @@ describe("browser consent lifecycle", () => { }) }) +describe("init from a broken call site", () => { + it("starts on defaults when called with no config at all", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + stubBrowser() + + const handle = plainJs.init() + + // A live session, not the no-op handle: only the service name is missing. + expect(handle.sessionId).not.toBe("") + expect(warn.mock.calls.map((call) => String(call[0])).join("\n")).toContain("serviceName") + await handle.shutdown() + warn.mockRestore() + }) + + it("returns a handle that does nothing when startup throws, and a later init works", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + stubBrowser() + tracing.setup.mockImplementationOnce(() => { + throw new Error("tracing broke") + }) + const config = { + ingestKey: "public-key", + serviceName: "test-web", + endpoint: "https://collector.test", + replay: { enabled: false }, + } + + const broken = init(config) + + expect(broken.sessionId).toBe("") + await broken.shutdown() + expect(warn.mock.calls.map((call) => String(call[0])).join("\n")).toContain("init() failed") + + // The failed start left nothing behind that blocks the next one. + const handle = init(config) + await vi.waitFor(() => expect(handle.sessionId).not.toBe("")) + await handle.shutdown() + warn.mockRestore() + }) +}) + describe("teardown", () => { it("releases the SDK and stops everything even when a teardown step fails", async () => { stubBrowser() diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 3469ca15a8..335a1c0ab4 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -91,6 +91,11 @@ async function attempt(step: () => Promise | undefined): Promise { } catch {} } +const noOpHandle = (): MapleBrowserHandle => ({ sessionId: "", shutdown: () => Promise.resolve() }) + +/** What a plain-JS `init()` with no argument runs on: every default, and the missing-name warning. */ +const NO_CONFIG: MapleBrowserConfig = { serviceName: "" } + /** Same settings, by value: a re-render passing a fresh but equal object is not a reconfiguration. */ function sameConfig(a: MapleBrowserConfig, b: MapleBrowserConfig): boolean { if (a === b) return true @@ -107,7 +112,8 @@ function sameConfig(a: MapleBrowserConfig, b: MapleBrowserConfig): boolean { * it without flushing, and a later grant starts cleanly again. While a handle * is live, `init()` returns it and ignores the new config. */ -export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { +export function init(config: MapleBrowserConfig): MapleBrowserHandle { + const rawConfig = config ?? NO_CONFIG if (active) { if (activeRawConfig && !warnedReinit && !sameConfig(activeRawConfig, rawConfig)) { warnedReinit = true @@ -117,10 +123,20 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { } return active } - if (typeof window === "undefined") { - return { sessionId: "", shutdown: () => Promise.resolve() } + if (typeof window === "undefined") return noOpHandle() + try { + return start(rawConfig) + } catch (cause) { + // Telemetry must not take the app's entrypoint down with it. No handle is + // live here: a start that got as far as one has already shut it down. + activeConfig = undefined + activeRawConfig = undefined + console.warn("[maple] init() failed; telemetry is off for this page.", cause) + return noOpHandle() } +} +function start(rawConfig: MapleBrowserConfig): MapleBrowserHandle { const config = resolveConfig(rawConfig) // Called after `init` was written, so it is the newer statement of who is here. if (pendingIdentity) config.identity = normalizeIdentity(pendingIdentity.input) @@ -283,15 +299,6 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { const stopReplayTrigger = onErrorRecorded(() => { void runtime?.replay?.trigger() }) - startRuntime() - openExceptionCapture() - if (!ready) { - // The handle is live already; its session starts once the previous one is flushed. - void behind?.then(() => { - ready = true - startRuntime() - }) - } // Consent is page-wide and only tightens: another SDK copy can turn the gate // on after this init. Without a gate the listener never fires. const stopConsentListener = onConsentChange((allowed) => { @@ -358,6 +365,21 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { }, } active = handle + try { + startRuntime() + openExceptionCapture() + } catch (cause) { + // Undo whatever did start; `init` reports it and hands back a handle that does nothing. + void attempt(() => handle.shutdown()) + throw cause + } + if (!ready) { + // The handle is live already; its session starts once the previous one is flushed. + void behind?.then(() => { + ready = true + startRuntime() + }) + } return handle } diff --git a/packages/effect-sdk/src/client/flushable-web-vitals.test.ts b/packages/effect-sdk/src/client/flushable-web-vitals.test.ts index dce37c4e5e..75d3886f91 100644 --- a/packages/effect-sdk/src/client/flushable-web-vitals.test.ts +++ b/packages/effect-sdk/src/client/flushable-web-vitals.test.ts @@ -1,5 +1,11 @@ // SAFETY-FILE: JSON in this test is the OTLP body the SDK under test posted, parsed before its fields are asserted. -import type { EmitLog, SpanLink } from "@maple/sdk-core" +import { + type EmitLog, + leasePageSignalAsOtherCopyForTests, + pageSignalLeasesForTests, + resetPageForTests, + type SpanLink, +} from "@maple/sdk-core" import { Array as Arr, String as Str } from "effect" import { afterEach, describe, expect, it, vi } from "vitest" import { make, type MapleClientFlushableConfig } from "./flushable.js" @@ -66,6 +72,7 @@ const later = () => new Promise((resolve) => setTimeout(resolve, 20)) describe("MapleFlush.make webVitals", () => { afterEach(() => { vi.unstubAllGlobals() + resetPageForTests() }) it("posts a reported vital as a browser.web_vital log event linked to the page load", async () => { @@ -116,6 +123,29 @@ describe("MapleFlush.make webVitals", () => { expect(collector.starts).toHaveLength(0) }) + it("leaves web vitals to another Maple SDK on the page that already reports them", async () => { + setup() + leasePageSignalAsOtherCopyForTests("webVitals") + const collector = fakeCollector() + const telemetry = make( + withIntegration({ ...config, webVitals: true }, { loadWebVitals: collector.load }), + ) + await later() + expect(collector.starts).toHaveLength(0) + await telemetry.dispose() + }) + + it("holds the page's web vitals lease until disposed", async () => { + setup() + const collector = fakeCollector() + const telemetry = make( + withIntegration({ ...config, webVitals: true }, { loadWebVitals: collector.load }), + ) + expect(pageSignalLeasesForTests("webVitals")).toBe(1) + await telemetry.dispose() + expect(pageSignalLeasesForTests("webVitals")).toBe(0) + }) + it("loads nothing by default", async () => { setup() const collector = fakeCollector() diff --git a/packages/effect-sdk/src/client/flushable.test.ts b/packages/effect-sdk/src/client/flushable.test.ts index 5e9d30cedf..51ddef8cc0 100644 --- a/packages/effect-sdk/src/client/flushable.test.ts +++ b/packages/effect-sdk/src/client/flushable.test.ts @@ -1,12 +1,14 @@ // SAFETY-FILE: JSON in this test is emitted by the fixture or unit under test before its fields are asserted. import { assert, describe, it } from "@effect/vitest" import { + configurePrivacy, OTLP_KEEPALIVE_BYTES, OTLP_UNLOAD_TAIL_BYTES, postToIngest, resetConsentForTests, setConsent, } from "@maple/browser-session" +import { markReported, onErrorRecorded, resetPageForTests, type SpanLink, wasReported } from "@maple/sdk-core" import { Deferred, Effect, Fiber, Metric, Option, Array as Arr, Record as Rec, String as Str } from "effect" import * as FetchHttpClient from "effect/http/FetchHttpClient" import * as HttpClient from "effect/http/HttpClient" @@ -166,6 +168,7 @@ describe("MapleFlush.make (client)", () => { vi.restoreAllMocks() setConsent(false) resetConsentForTests() + resetPageForTests() vi.useRealTimers() }) @@ -271,6 +274,21 @@ describe("MapleFlush.make (client)", () => { await telemetry.dispose() }) + it("drops what is captured after another Maple SDK on the page turns the consent gate on", async () => { + const { calls, restore: rf } = setupFetch() + restore = rf + const telemetry = make(baseConfig) + + // `@maple-dev/browser`, initialized later with `privacy.requireConsent`. + configurePrivacy({ requireConsent: true }) + telemetry.captureException(new Error("while denied")) + setConsent(true) + await telemetry.flush() + + expect(spanNames(calls)).toEqual([]) + await telemetry.dispose() + }) + it("still exports metrics when consent simply arrives late", async () => { const { calls, restore: r } = setupFetch() restore = r @@ -483,6 +501,63 @@ describe("MapleFlush.make (client)", () => { await telemetry.dispose() }) + it("skips an error another Maple SDK on the page already recorded", async () => { + const { calls, restore: rf } = setupFetch() + const dom = setupDom() + restore = () => { + rf() + dom.restore() + } + const telemetry = make(baseConfig) + const crash = new TypeError("app bug") + // What `@maple-dev/browser` does when its own handler records the error first. + markReported(crash) + + dom.dispatch("error", { error: crash, filename: "https://app.test/assets/app.js" }) + telemetry.captureException(crash) + await telemetry.flush() + + expect(spanNames(calls)).toEqual([]) + await telemetry.dispose() + }) + + it("claims an error it records page-wide and tells the page's error listeners", async () => { + const { calls, restore: rf } = setupFetch() + const dom = setupDom() + const links: Array = [] + const stopListening = onErrorRecorded((link) => links.push(link)) + restore = () => { + rf() + dom.restore() + stopListening() + } + const telemetry = make(baseConfig) + const crash = new TypeError("app bug") + + dom.dispatch("error", { error: crash, filename: "https://app.test/assets/app.js" }) + await telemetry.flush() + + expect(wasReported(crash)).toBe(true) + const body = Arr.getUnsafe(traceCalls(calls), 0).body as { + resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ traceId: string; spanId: string }> }> }> + } + const span = Arr.getUnsafe(Arr.getUnsafe(Arr.getUnsafe(body.resourceSpans, 0).scopeSpans, 0).spans, 0) + expect(links).toEqual([{ traceId: span.traceId, spanId: span.spanId, traceFlags: 1 }]) + await telemetry.dispose() + }) + + it("leaves an error unclaimed while capture is off, for the other SDK to record", async () => { + const { restore: rf } = setupFetch() + restore = rf + const telemetry = make({ ...baseConfig, privacy: { requireConsent: true } }) + const crash = new TypeError("app bug") + + telemetry.captureException(crash) + + expect(wasReported(crash)).toBe(false) + await telemetry.dispose() + }) + it("redacts credentials from the page URL and script path on uncaught errors", async () => { const { calls, restore: rf } = setupFetch() const dom = setupDom() diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index e34cffc0b8..6016a805c2 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -17,6 +17,7 @@ import { warnIfKeylessMapleIngest, } from "@maple/browser-session" import { isCommitSha } from "@maple/browser-session/commit-sha" +import { claimPageSignal, markReported, notifyErrorRecorded, wasReported } from "@maple/sdk-core" import { SDK_VERSION } from "../version.js" import { Layer, Redacted, String as Str } from "effect" import { @@ -247,10 +248,16 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => metrics.setDisabled(!allowed) } setCaptureAllowed(hasConsent()) - const stopConsentListener = config.privacy?.requireConsent ? onConsentChange(setCaptureAllowed) : () => {} + // Consent is page-wide: another SDK copy can turn the gate on after this one started. + const stopConsentListener = onConsentChange(setCaptureAllowed) let stopWebVitals: (() => void) | undefined let disposed = false - if (config.webVitals === true && browserDocument() !== undefined) { + // Leased page-wide: with `@maple-dev/browser` also on the page, one of the two reports them. + const releaseWebVitals = + config.webVitals === true && browserDocument() !== undefined + ? claimPageSignal("webVitals") + : undefined + if (releaseWebVitals !== undefined) { void (integration.loadWebVitals ?? (() => import("@maple/sdk-core/browser/web-vitals")))() .then(({ startWebVitals }) => { if (disposed) return @@ -361,22 +368,23 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => /** * One error reaching two paths must still be one issue. React rethrows a * boundary-caught error in development, so a boundary that reports it *and* - * `window.onerror` would otherwise fingerprint the same crash twice. + * `window.onerror` would otherwise fingerprint the same crash twice. The + * claim is page-wide, so `@maple-dev/browser` on the same page skips it too. */ - const reported = new WeakSet() const captureException = (error: unknown, options: CaptureExceptionOptions = {}): void => { - if (typeof error === "object" && error !== null) { - if (reported.has(error)) return - reported.add(error) - } + if (wasReported(error)) return const page = globalThis.location?.href - spans.captureException(error, { + const span = spans.captureException(error, { ...options, attributes: { ...(page !== undefined ? { "url.full": scrubUrl(page) } : undefined), ...options.attributes, }, }) + // Claimed only once a span carries it: with capture off, the other SDK may still record it. + if (span === undefined) return + markReported(error) + notifyErrorRecorded({ ...span, traceFlags: 1 }) } const onWindowError = (event: ErrorEvent): void => { @@ -445,6 +453,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => const finalize = async (): Promise => { disposed = true stopWebVitals?.() + releaseWebVitals?.() if (timer !== undefined) { clearInterval(timer) timer = undefined diff --git a/packages/effect-sdk/src/client/layer.test.ts b/packages/effect-sdk/src/client/layer.test.ts index 070558725f..c76aea61d0 100644 --- a/packages/effect-sdk/src/client/layer.test.ts +++ b/packages/effect-sdk/src/client/layer.test.ts @@ -83,4 +83,33 @@ describe("Maple.layer (client) — session linking after refactor", () => { expect(payload).not.toContain('"noise.poll"') expect(payload).toContain("sess-xyz") }) + + it("names the SDK and its version on export requests, with or without an ingest key", async () => { + const hints: Array<{ readonly sdk: string | null; readonly authorization: string | null }> = [] + onFetch = async (request) => { + if (Str.endsWith("/v1/traces")(request.url)) + hints.push({ + sdk: request.headers.get("x-maple-sdk"), + authorization: request.headers.get("authorization"), + }) + return new Response(null, { status: 200 }) + } + restore = () => void (onFetch = ok) + + const run = (ingestKey: string | undefined) => + Effect.runPromise( + Effect.void.pipe( + Effect.withSpan("page-load"), + Effect.provide( + layer({ serviceName: "web-test", endpoint: "https://collector.test", ingestKey }), + ), + ), + ) + await run("secret") + await run(undefined) + + expect(Arr.map(hints, (hint) => hint.authorization)).toEqual(["Bearer secret", null]) + expect(hints).toHaveLength(2) + for (const hint of hints) expect(hint.sdk).toMatch(/^maple-effect-sdk-client\/\d+\.\d+\.\d+$/) + }) }) diff --git a/packages/effect-sdk/src/client/layer.ts b/packages/effect-sdk/src/client/layer.ts index 6f64c67ff7..7966a5c104 100644 --- a/packages/effect-sdk/src/client/layer.ts +++ b/packages/effect-sdk/src/client/layer.ts @@ -10,6 +10,7 @@ import { type ClientReplayConfig, startClientSession } from "./replay-loader.js" import { withSessionLink } from "./session-link.js" import { type IdentifyInput, type PrivacyOptions, type TrackProps, track as trackEvent } from "./track.js" import { clearIdentity as clearIdentityUser, identify as identifyUser } from "./user.js" +import { CLIENT_SDK_HINT } from "../version.js" export interface MapleClientConfig extends MapleSpanOptions { /** The service name reported in traces, logs, and metrics. */ @@ -133,7 +134,11 @@ export const layer = (config: MapleClientConfig) => { serviceVersion: config.serviceVersion, attributes, }, - headers: config.ingestKey ? { Authorization: `Bearer ${config.ingestKey}` } : undefined, + headers: { + // A page cannot set `user-agent`; ingest reads the SDK and its version from this. + "x-maple-sdk": CLIENT_SDK_HINT, + ...(config.ingestKey ? { Authorization: `Bearer ${config.ingestKey}` } : undefined), + }, maxBatchSize: config.maxBatchSize, loggerExportInterval: config.loggerExportInterval, metricsExportInterval: config.metricsExportInterval, diff --git a/packages/effect-sdk/src/client/replay-loader.test.ts b/packages/effect-sdk/src/client/replay-loader.test.ts index 8dd5d7cf1a..6358c85365 100644 --- a/packages/effect-sdk/src/client/replay-loader.test.ts +++ b/packages/effect-sdk/src/client/replay-loader.test.ts @@ -1,7 +1,43 @@ -import { clearSessionSink, resetConsentForTests, setConsent } from "@maple/browser-session" +// TEST-SEAM: the replay chunk is rrweb behind a dynamic import; these tests only ask whether it was started. +import { + clearSessionSink, + configurePrivacy, + getActiveSink, + resetConsentForTests, + scrubUrl, + setConsent, +} from "@maple/browser-session" import { afterEach, describe, expect, it, vi } from "vitest" import { startClientSession } from "./replay-loader.js" +vi.mock("./replay.js", () => ({ + startReplaySession: vi.fn(() => ({ sessionId: "replay-session", shutdown: () => Promise.resolve() })), +})) +const { startReplaySession } = await import("./replay.js") + +/** A page with its own empty storage, enough DOM for the event sink to attach to. */ +const stubPage = ( + userAgent = "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36", +) => { + const store = new Map() + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + sessionStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }, + location: { href: "https://app.example.com/" }, + }), + ) + vi.stubGlobal("document", Object.assign(new EventTarget(), { cookie: "", visibilityState: "visible" })) + vi.stubGlobal("navigator", { userAgent }) + vi.stubGlobal("fetch", async () => new Response(null, { status: 200 })) +} + +const BASE = { endpoint: "https://collector.test", serviceName: "unit-test", emitSessionMeta: false } + const PENDING_KEY = "maple.replay.pending" afterEach(() => { @@ -9,6 +45,7 @@ afterEach(() => { resetConsentForTests() clearSessionSink() vi.unstubAllGlobals() + vi.mocked(startReplaySession).mockClear() }) describe("startClientSession consent", () => { @@ -48,3 +85,53 @@ describe("startClientSession consent", () => { await session.stop() }) }) + +describe("startClientSession replay", () => { + it("records a person's session", async () => { + stubPage() + const session = startClientSession(BASE) + await vi.waitFor(() => expect(startReplaySession).toHaveBeenCalledTimes(1)) + await session.stop() + }) + + it("records no replay for a crawler, which still gets its session events", async () => { + stubPage("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)") + const session = startClientSession(BASE) + // Long enough for the lazy chunk to have resolved, as it does in the test above. + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(startReplaySession).not.toHaveBeenCalled() + expect(getActiveSink()).toBeDefined() + await session.stop() + }) +}) + +describe("startClientSession privacy", () => { + it("removes its sanitizeUrl when stopped, so a remount does not stack another", async () => { + stubPage() + const session = startClientSession({ + ...BASE, + replay: { enabled: false }, + privacy: { sanitizeUrl: (url) => `${url}#seen` }, + }) + expect(scrubUrl("https://app.example.com/")).toBe("https://app.example.com/#seen") + + await session.stop() + + expect(scrubUrl("https://app.example.com/")).toBe("https://app.example.com/") + }) + + it("stops capturing when another Maple SDK on the page turns the consent gate on", async () => { + stubPage() + const session = startClientSession({ ...BASE, replay: { enabled: false } }) + expect(getActiveSink()).toBeDefined() + + // `@maple-dev/browser`, initialized later with `privacy.requireConsent`. + configurePrivacy({ requireConsent: true }) + expect(getActiveSink()).toBeUndefined() + + setConsent(true) + expect(getActiveSink()).toBeDefined() + await session.stop() + }) +}) diff --git a/packages/effect-sdk/src/client/replay-loader.ts b/packages/effect-sdk/src/client/replay-loader.ts index f39e2fc2f4..d89292f2eb 100644 --- a/packages/effect-sdk/src/client/replay-loader.ts +++ b/packages/effect-sdk/src/client/replay-loader.ts @@ -9,6 +9,7 @@ import { getActiveSink, getSession, hasConsent, + isLikelyBot, mayPersistIdentifier, onConsentChange, type PrivacyOptions, @@ -19,6 +20,7 @@ import { type SessionEventSink, type MetadataSessionHandle, } from "@maple/browser-session" +import { browserNavigator } from "./browser-globals.js" import { setupStandaloneSession } from "./standalone-session.js" import { getCurrentIdentity } from "./user.js" import { CLIENT_SDK_HINT } from "../version.js" @@ -68,19 +70,18 @@ interface Runtime { metadata?: MetadataSessionHandle | undefined } -const noOpHandle: ClientSessionHandle = { stop: () => Promise.resolve() } - /** * Own the complete client-session lifecycle. Consent can change after startup: * grant starts capture, revoke synchronously detaches producers and discards * buffers, and a later grant creates a clean runtime. */ export const startClientSession = (config: ClientSessionConfig): ClientSessionHandle => { - configurePrivacy(config.privacy) + // Kept for `stop`: a remount would otherwise add its `sanitizeUrl` on top of the last one. + const removeSanitizer = configurePrivacy(config.privacy) if (!hasConsent()) clearPendingEvents() // React Native exposes window without a browser DOM or Web Crypto. if (typeof window === "undefined" || typeof document === "undefined" || readSessionSink()) - return noOpHandle + return { stop: () => Promise.resolve(removeSanitizer()) } const engineConfig = { endpoint: Str.replace(/\/$/, "")(config.endpoint), @@ -90,7 +91,11 @@ export const startClientSession = (config: ClientSessionConfig): ClientSessionHa maskAllText: config.replay?.maskAllText ?? false, getIdentity: getCurrentIdentity, } - const replayEnabled = (config.replay?.enabled ?? true) && typeof document !== "undefined" + // Crawlers keep their session row and events but record no replay. See `isLikelyBot`. + const replayEnabled = + (config.replay?.enabled ?? true) && + typeof document !== "undefined" && + !isLikelyBot(browserNavigator()?.userAgent ?? "") let runtime: Runtime | undefined let stopped = false let generation = 0 @@ -183,27 +188,28 @@ export const startClientSession = (config: ClientSessionConfig): ClientSessionHa } startRuntime() - const stopConsentListener = config.privacy?.requireConsent - ? onConsentChange((allowed) => { - if (allowed) { - startRuntime() - return - } - rotateOnNextStart = runtime !== undefined - clearPendingEvents() - // Before the teardown: a flush still compressing the chunk then finds nothing to send. - clearPendingChunk() - setVisitorTracking(false) - void stopRuntime(false) - }) - : () => {} + // Consent is page-wide and only tightens: another SDK copy can turn the gate + // on after this start. Without a gate the listener never fires. + const stopConsentListener = onConsentChange((allowed) => { + if (allowed) { + startRuntime() + return + } + rotateOnNextStart = runtime !== undefined + clearPendingEvents() + // Before the teardown: a flush still compressing the chunk then finds nothing to send. + clearPendingChunk() + setVisitorTracking(false) + void stopRuntime(false) + }) return { stop: async (options) => { if (stopped) return stopped = true stopConsentListener() - await stopRuntime(options?.flush ?? true) + // After the last flush, so the rows it sends still go through the app's sanitizer. + await stopRuntime(options?.flush ?? true).finally(removeSanitizer) }, } } diff --git a/packages/effect-sdk/src/server/layer.test.ts b/packages/effect-sdk/src/server/layer.test.ts index 214ed82a51..35a0431a60 100644 --- a/packages/effect-sdk/src/server/layer.test.ts +++ b/packages/effect-sdk/src/server/layer.test.ts @@ -7,6 +7,8 @@ import { layer } from "./layer.js" interface ExportCall { readonly url: string readonly authorization: string | null + readonly sdk: string | null + readonly userAgent: string | null readonly body: ExportBody } interface ExportBody { @@ -36,6 +38,8 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: request.url, authorization: request.headers.get("authorization"), + sdk: request.headers.get("x-maple-sdk"), + userAgent: request.headers.get("user-agent"), body: text ? JSON.parse(text) : {}, }) return new Response(null, { status: 200 }) @@ -142,6 +146,21 @@ describe("Maple.layer span options", () => { expect(Arr.map(exportedSpans(), (span) => span.name)).toEqual(["hello"]) }) + it("names the SDK and its version on export requests, with or without an ingest key", async () => { + await run( + { serviceName: "unit-test", endpoint: "https://collector.test" }, + Effect.void.pipe(Effect.withSpan("hello")), + ) + + const traces = Option.getOrThrow( + Arr.findFirst(calls, (call) => call.url === "https://collector.test/v1/traces"), + ) + expect(traces.authorization).toBeNull() + expect(traces.sdk).toMatch(/^maple-effect-sdk-server\/\d+\.\d+\.\d+$/) + // The exporter keeps its own agent after ours. + expect(traces.userAgent).toMatch(/^maple-effect-sdk-server\/\d+\.\d+\.\d+ /) + }) + // The signal layers share one memoized `layerFlusher`, so its flush drains them all. it("drains traces and logs on a manual flush, before shutdown", async () => { const exportedBeforeShutdown = await Effect.runPromise( diff --git a/packages/effect-sdk/src/server/layer.ts b/packages/effect-sdk/src/server/layer.ts index cefaca0f94..964cc403bd 100644 --- a/packages/effect-sdk/src/server/layer.ts +++ b/packages/effect-sdk/src/server/layer.ts @@ -5,6 +5,10 @@ import { type MapleRegion, warnIfKeylessMapleIngest } from "@maple/browser-sessi import { makeOtlpLayer } from "../shared/otlp-layer.js" import type { MapleSpanOptions } from "../shared/span-options.js" import { type ResolvedResource, resolveResource } from "./resource.js" +import { SDK_VERSION } from "../version.js" + +/** `/`, as the other server presets send it. */ +const SERVER_SDK_HINT = `maple-effect-sdk-server/${SDK_VERSION}` /** * A warning rather than a no-op: disabling export here would silently break @@ -121,9 +125,14 @@ export const layer = (config: MapleConfig = {}) => return makeOtlpLayer({ baseUrl: resolved.endpoint, resource: resolved.resource, - headers: resolved.ingestKey - ? { Authorization: `Bearer ${Redacted.value(resolved.ingestKey)}` } - : undefined, + headers: { + // The exporter appends its own agent to this one. + "user-agent": SERVER_SDK_HINT, + "x-maple-sdk": SERVER_SDK_HINT, + ...(resolved.ingestKey + ? { Authorization: `Bearer ${Redacted.value(resolved.ingestKey)}` } + : undefined), + }, maxBatchSize: config.maxBatchSize, loggerExportInterval: config.loggerExportInterval, metricsExportInterval: config.metricsExportInterval, diff --git a/packages/effect-sdk/src/shared/flushable-tracer.ts b/packages/effect-sdk/src/shared/flushable-tracer.ts index 67c4f05811..c9aa233e90 100644 --- a/packages/effect-sdk/src/shared/flushable-tracer.ts +++ b/packages/effect-sdk/src/shared/flushable-tracer.ts @@ -17,6 +17,12 @@ export interface CaptureExceptionOptions { readonly attributes?: Record | undefined } +/** The span an exception was recorded on. */ +export interface CapturedExceptionSpan { + readonly traceId: string + readonly spanId: string +} + export interface SpanBuffer { readonly tracerLayer: Layer.Layer /** @@ -38,7 +44,10 @@ export interface SpanBuffer { * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw * anything. `Cause.prettyErrors` narrows it on the way into the event. */ - readonly captureException: (error: unknown, options?: CaptureExceptionOptions) => void + readonly captureException: ( + error: unknown, + options?: CaptureExceptionOptions, + ) => CapturedExceptionSpan | undefined readonly drain: () => Array readonly restore: (items: ReadonlyArray) => void readonly setDisabled: (value: boolean) => void @@ -160,8 +169,11 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => { const tracer = makeMapleTracer(filter, exportFn, options.watchConnection) - const captureException = (error: unknown, captureOptions: CaptureExceptionOptions = {}): void => { - if (disabled) return + const captureException = ( + error: unknown, + captureOptions: CaptureExceptionOptions = {}, + ): CapturedExceptionSpan | undefined => { + if (disabled) return undefined const now = BigInt(Date.now()) * 1_000_000n const span = makeSpan({ name: captureOptions.name ?? "exception", @@ -175,6 +187,7 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => { export: exportFn, }) span.end(now, Exit.failCause(Cause.die(error))) + return { traceId: span.traceId, spanId: span.spanId } } return { diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 2aabc64271..b27da9683e 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -22,9 +22,11 @@ export { resolveReplayOptions, resolveSignalOptions } from "./options" export type { PageSignal } from "./page" export { claimPageSignal, + leasePageSignalAsOtherCopyForTests, markReported, notifyErrorRecorded, onErrorRecorded, + pageSignalLeasesForTests, resetPageForTests, wasReported, } from "./page" diff --git a/packages/sdk-core/src/options.ts b/packages/sdk-core/src/options.ts index 18f07ac70d..f5ac1080a6 100644 --- a/packages/sdk-core/src/options.ts +++ b/packages/sdk-core/src/options.ts @@ -1,6 +1,6 @@ -// Configuration groups both SDKs accept with the same names, defaults and -// meaning. Each SDK's config extends these; `resolveSignalOptions` is the one -// place the defaults live. +// The signal and replay option groups of `MapleBrowser.init`, and their +// defaults. Only `@maple-dev/browser` reads them today: the Effect client +// presets declare and default their own options (`webVitals` is off there). import type { ErrorFilterOptions } from "./error-filters" import { type HeaderCapture, type HeaderCaptureOptions, resolveHeaderCapture } from "./http-headers" import { resolveSampleRate } from "./sampling" @@ -53,7 +53,7 @@ export interface ReplayOptions { } } -/** The signal options shared verbatim by `MapleBrowser.init` and the Effect client presets. */ +/** The signal options of `MapleBrowser.init`. The Effect client presets do not take these yet. */ export interface SignalOptions { readonly tracing?: TracingSignalOptions /** Which captured errors to drop, and which HTTP statuses count as errors. */ From a1b87e52f950ace0a4f56049d1d3d4d2972d8700 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 10:43:53 +0100 Subject: [PATCH 2/2] docs(effect-sdk): note that an error claim outlives a span dropped on consent withdrawal --- packages/effect-sdk/src/client/flushable.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index 6016a805c2..57dac6daee 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -382,6 +382,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => }, }) // Claimed only once a span carries it: with capture off, the other SDK may still record it. + // The claim outlives the span if consent is withdrawn before the drain clears the buffer. if (span === undefined) return markReported(error) notifyErrorRecorded({ ...span, traceFlags: 1 })