Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
15 changes: 15 additions & 0 deletions .changeset/sdk-audit-capture-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/landing/src/content/docs/sdks/effect-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------- |
Expand Down
57 changes: 56 additions & 1 deletion packages/browser-session/src/events/events-sink.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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, unknown>): 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())

Expand Down
37 changes: 24 additions & 13 deletions packages/browser-session/src/events/events-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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 {}
}
}
Expand Down Expand Up @@ -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<string, unknown> {
const identity = config.getIdentity?.()
Expand All @@ -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 ?? "",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -367,3 +368,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)
}
})
})
Loading
Loading