Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ MapleBrowser.init({

That call:

- starts OTel browser tracing, auto-instrumenting `fetch` and exporting to Maple's ingest (`POST /v1/traces`);
- starts OTel browser tracing, auto-instrumenting `fetch` and `XMLHttpRequest` and exporting to Maple's ingest (`POST /v1/traces`);
- captures uncaught errors and unhandled promise rejections as error spans (see [Errors](#errors));
- records the session with rrweb, chunks events into ~5s / 100KB windows, gzips them with the native `CompressionStream`, and uploads them to `POST /v1/sessionReplays/blob`;
- writes session metadata to `POST /v1/sessionReplays/meta`: an `active` row at start, a heartbeat every 60s, and an `ended` row on page hide, which includes the trace ids observed during the session.
Expand All @@ -51,6 +51,7 @@ Every field accepted by `MapleBrowser.init`:
| `userId` | `string` | none | **Deprecated**, use `user`. User id attached to the replay session and future browser spans. |
| `tracing.enabled` | `boolean` | `true` | Enable OTel browser tracing. |
| `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. |
| `tracing.instrumentXhr` | `boolean` | `true` | Auto-instrument `XMLHttpRequest` (axios and older clients) like `fetch`. |
| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). |
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). |
| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; error spans are always exported. See [Sampling](#sampling). |
Expand Down Expand Up @@ -286,9 +287,23 @@ to `exception.stacktrace` as `Caused by:` blocks after the error's own frames. I
fingerprinted on the top frames, so adding a cause does not split an existing issue unless the
error's own stack has fewer than three frames.

## Page load timing

When your router calls `MapleBrowser.startNavigation` (see the package README), the first call opens
a `pageload` span that starts at the browser's navigation start, not when your JavaScript got to
run. Once the page has loaded, the SDK adds child spans from the Navigation Timing entry:

| Span | Covers |
| --------------- | --------------------------------------------------------------------------- |
| `documentFetch` | fetching the HTML, with `dns`, `connect`, `request` and `response` under it |
| `domProcessing` | the response end until the DOM is complete |
| `loadEvent` | the page's `load` handlers |

Phases that didn't happen (a reused connection has no `dns` or `connect`) are skipped.

## Tracing across origins

`fetch` spans send the W3C `traceparent` header to same-origin requests only. When your API lives
`fetch` and `XMLHttpRequest` spans send the W3C `traceparent` header to same-origin requests only. When your API lives
on another origin, list it so browser and backend spans join one trace, and allow the `traceparent`
header in the API's CORS policy:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from "vitest"
import { noteStartedTraceId } from "../../events/trace-id"
import type { SessionEvent } from "../../events/events-sink"
import { installNetworkCapture } from "./network"

const originalOpen = XMLHttpRequest.prototype.open
let uninstall: (() => void) | undefined

afterEach(() => {
uninstall?.()
uninstall = undefined
XMLHttpRequest.prototype.open = originalOpen
})

const request = (xhr: XMLHttpRequest): Promise<void> =>
new Promise((resolve) => {
// A macrotask later, so the capture's own `loadend` listener has run.
xhr.addEventListener("loadend", () => setTimeout(resolve, 0))
xhr.send()
})

describe("installNetworkCapture", () => {
it("links an XHR to a span its tracer started in open()", async () => {
// Stands in for a tracing instrumentation installed first, which starts its span in `open`.
const tracedOpen = originalOpen
XMLHttpRequest.prototype.open = function (
this: XMLHttpRequest,
method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null,
) {
noteStartedTraceId("0af7651916cd43dd8448eb211c80319c")
tracedOpen.call(this, method, url, async, username, password)
}
const events: SessionEvent[] = []
uninstall = installNetworkCapture(
(event) => events.push(event),
() => false,
)

const xhr = new XMLHttpRequest()
xhr.open("GET", "/")
await request(xhr)

const network = events.find((event) => event.type === "network")
expect(network?.traceId).toBe("0af7651916cd43dd8448eb211c80319c")
expect(network?.net?.method).toBe("GET")
})
})
8 changes: 6 additions & 2 deletions packages/browser-session/src/replay/capture/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo
) {
;(this as XhrMeta).__mapleMethod = String(method).toUpperCase()
;(this as XhrMeta).__mapleUrl = typeof url === "string" ? url : url.href
return origOpen.apply(this, [method, url, ...rest] as never)
// Some XHR instrumentations start their span in `open`, not `send`.
const call = withStartedTraceId(() => origOpen.apply(this, [method, url, ...rest] as never))
;(this as XhrMeta).__mapleTraceId = call.traceId
return call.result
}
XHR.prototype.send = function (this: XMLHttpRequest, ...args: unknown[]) {
const meta = this as XhrMeta
const start = performance.now()
let traceId = activeTraceId()
let traceId = meta.__mapleTraceId ?? activeTraceId()
this.addEventListener("loadend", () => {
record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId)
})
Expand All @@ -86,6 +89,7 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo
interface XhrMeta extends XMLHttpRequest {
__mapleMethod?: string
__mapleUrl?: string
__mapleTraceId?: string | undefined
}

function requestUrl(input: RequestInfo | URL): string {
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template
render's trace from a `Server-Timing: traceparent;desc="…"` entry or a
`<meta name="traceparent">` tag; later calls open `navigate` spans, and end
one still open as `app.navigation.interrupted`.
- The document's `pageload` starts at navigation start, and gets child spans
from the Navigation Timing entry once the page has loaded: `documentFetch`
(with `dns`, `connect`, `request` and `response` under it), `domProcessing`
and `loadEvent`.
- `traced` returns `fn`'s result and rethrows its error unchanged. Only requests
started before `fn`'s first `await` nest under its span. An error it recorded
isn't reported again by `captureException` or the global handlers.
Expand Down
1 change: 1 addition & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/instrumentation": "^0.222.0",
"@opentelemetry/instrumentation-fetch": "^0.222.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.222.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-trace-base": "^2.10.0",
Expand Down
8 changes: 5 additions & 3 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import { gzipSync } from "node:zlib"
/** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */
const BUDGET = {
/**
* 42 since 2026-09: error filters and cause chains (~0.8 kB). 41 before that:
* 43 since 2026-09: XHR spans and the HTTP status policy, which must patch
* before the app's first request (~1.5 kB). Document timing went to the
* deferred chunk instead. 42: error filters and cause chains. 41 before that:
* per-session trace sampling and the `logger` queue added ~2.4 kB (~1.2 kB
* code, the rest chunk-split overhead now that a second chunk shares the OTel
* core). Was 38 for navigation spans.
*/
eager: 42,
/** Every page load, after `init()`: the OTel logs SDK and exporter. */
eager: 43,
/** Every page load, after `init()`: the OTel logs SDK and exporter, document timing. */
deferred: 8,
lazy: 68,
/**
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface MapleBrowserConfig {
* sink, and disabling this avoids redundant duplicate network spans.
*/
readonly instrumentFetch?: boolean
/**
* Auto-instrument `XMLHttpRequest` (axios and older clients) the same way.
* Default true. Turn it off for the same reason as `instrumentFetch`.
*/
readonly instrumentXhr?: boolean
/**
* Capture uncaught errors and unhandled promise rejections as error
* spans. Default true. Turn off only when another tracker already owns
Expand Down Expand Up @@ -138,6 +143,7 @@ export interface ResolvedConfig {
identity: ResolvedIdentity | undefined
readonly tracingEnabled: boolean
readonly tracingInstrumentFetch: boolean
readonly tracingInstrumentXhr: boolean
readonly tracingCaptureErrors: boolean
readonly propagateTraceHeaderCorsUrls: ReadonlyArray<string | RegExp>
readonly tracingSampleRate: number
Expand Down Expand Up @@ -202,6 +208,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
identity: resolveIdentity(config),
tracingEnabled: config.tracing?.enabled ?? true,
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
tracingInstrumentXhr: config.tracing?.instrumentXhr ?? true,
tracingCaptureErrors: config.tracing?.captureErrors ?? true,
propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [],
tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate),
Expand Down
79 changes: 79 additions & 0 deletions packages/browser/src/deferred/document-timing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// The document's own load, as child spans of the `pageload` navigation: the
// response for the HTML (and its network phases), DOM processing, and the load
// event. Read from the Navigation Timing entry once the page has loaded.
import { scrubUrl } from "@maple/browser-session"
import { context, type Span, type Tracer, trace, TraceFlags } from "@opentelemetry/api"

type Mark = (entry: PerformanceNavigationTiming) => number
type Phase = readonly [name: string, start: Mark, end: Mark]

const FETCH_PHASES: ReadonlyArray<Phase> = [
["dns", (e) => e.domainLookupStart, (e) => e.domainLookupEnd],
["connect", (e) => e.connectStart, (e) => e.connectEnd],
["request", (e) => e.requestStart, (e) => e.responseStart],
["response", (e) => e.responseStart, (e) => e.responseEnd],
]

const PAGE_PHASES: ReadonlyArray<Phase> = [
["domProcessing", (e) => e.responseEnd, (e) => e.domComplete],
["loadEvent", (e) => e.loadEventStart, (e) => e.loadEventEnd],
]

/** Epoch ms for a Navigation Timing offset. */
const at = (offset: number): number => performance.timeOrigin + offset

function spanPhases(
tracer: Tracer,
parent: Span,
entry: PerformanceNavigationTiming,
phases: ReadonlyArray<Phase>,
): void {
const ctx = trace.setSpan(context.active(), parent)
for (const [name, start, end] of phases) {
const from = start(entry)
const to = end(entry)
// Zero marks are phases that did not happen: a reused connection has no dns or connect.
if (from <= 0 || to <= from) continue
tracer.startSpan(name, { startTime: at(from) }, ctx).end(at(to))
}
}

function record(tracer: Tracer, pageload: Span): void {
const [entry] = performance.getEntriesByType("navigation")
if (!(entry instanceof PerformanceNavigationTiming) || entry.responseEnd <= 0) return
const fetch = tracer.startSpan(
"documentFetch",
{
startTime: at(entry.fetchStart),
attributes: {
"url.full": scrubUrl(entry.name),
...(entry.responseStatus > 0
? { "http.response.status_code": entry.responseStatus }
: undefined),
...(entry.encodedBodySize > 0
? { "http.response.body.size": entry.encodedBodySize }
: undefined),
},
},
trace.setSpan(context.active(), pageload),
)
spanPhases(tracer, fetch, entry, FETCH_PHASES)
fetch.end(at(entry.responseEnd))
spanPhases(tracer, pageload, entry, PAGE_PHASES)
}

/** Span the document's load under `pageload`, now or once the `load` event has finished. */
export function recordDocumentTiming(tracer: Tracer, pageload: Span): void {
// Sampled, not recording: the app has usually ended the span by the time this chunk lands.
const sampled = (pageload.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0
if (!sampled || typeof performance.getEntriesByType !== "function") return
// A task after `load`, so `loadEventEnd` is set. Timing is best-effort: never throw into the page.
const run = (): void =>
void setTimeout(() => {
try {
record(tracer, pageload)
} catch {}
}, 0)
if (document.readyState === "complete") run()
else window.addEventListener("load", run, { once: true })
}
5 changes: 4 additions & 1 deletion packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
// Everything that can start a moment after `init()` without losing data lives
// behind this chunk, so it stays off the eager bundle every page load pays for.
import type { ResolvedConfig } from "../config"
import { onDocumentPageload } from "../navigation"
import { recordDocumentTiming } from "./document-timing"
import { startLogs } from "./logs"

export function startDeferred(config: ResolvedConfig): () => Promise<void> {
const stops = [startLogs(config)]
onDocumentPageload(recordDocumentTiming)
const stops = [startLogs(config), async () => onDocumentPageload(undefined)]
return async () => {
await Promise.all(stops.map((stop) => stop()))
}
Expand Down
62 changes: 62 additions & 0 deletions packages/browser/src/http-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { SpanKind, SpanStatusCode } from "@opentelemetry/api"
import {
BasicTracerProvider,
InMemorySpanExporter,
type ReadableSpan,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base"
import { describe, expect, it } from "vitest"
import { HttpStatusExporter } from "./http-status"

const exported = new InMemorySpanExporter()
const tracer = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(new HttpStatusExporter(exported))],
}).getTracer("test")

const finish = (
build: (span: ReturnType<typeof tracer.startSpan>) => void,
kind = SpanKind.CLIENT,
): ReadableSpan => {
exported.reset()
const span = tracer.startSpan("GET", { kind })
build(span)
span.end()
const [result] = exported.getFinishedSpans()
if (!result) throw new Error("nothing exported")
return result
}

describe("HttpStatusExporter", () => {
it("clears an Error set only because of the response status", () => {
const span = finish((s) => {
s.setAttribute("http.response.status_code", 404)
s.setAttribute("error.type", "404")
s.setStatus({ code: SpanStatusCode.ERROR })
})
expect(span.status.code).toBe(SpanStatusCode.UNSET)
expect(span.attributes["error.type"]).toBeUndefined()
expect(span.attributes["http.response.status_code"]).toBe(404)
expect(span.spanContext().spanId).toMatch(/^[0-9a-f]{16}$/)
})

it("keeps network failures, recorded exceptions, and non-client spans", () => {
const network = finish((s) => {
s.setAttribute("error.type", "timeout")
s.setStatus({ code: SpanStatusCode.ERROR, message: "timeout" })
})
expect(network.status.code).toBe(SpanStatusCode.ERROR)

const withException = finish((s) => {
s.setAttribute("error.type", "500")
s.recordException(new Error("boom"))
s.setStatus({ code: SpanStatusCode.ERROR })
})
expect(withException.status.code).toBe(SpanStatusCode.ERROR)

const internal = finish((s) => {
s.setAttribute("error.type", "500")
s.setStatus({ code: SpanStatusCode.ERROR })
}, SpanKind.INTERNAL)
expect(internal.status.code).toBe(SpanStatusCode.ERROR)
})
})
Loading
Loading