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
4 changes: 4 additions & 0 deletions bun.lock

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

25 changes: 25 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Every field accepted by `MapleBrowser.init`:
| `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.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). |
| `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. |
| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). |
| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `<input>` values in the recording. |
Expand Down Expand Up @@ -241,6 +242,21 @@ Cross-origin scripts report a bare `"Script error."` with no stack or filename.
since they all fingerprint to one empty issue. Add `crossorigin` to the script tag to get the real
error.

## Logs

`MapleBrowser.logger` writes OpenTelemetry log records. Each one is linked to the span active when
it was logged and carries `session.id` (and `user.id` once known), so it shows up on the trace, in
the logs explorer, and next to the session.

```ts
MapleBrowser.logger.info("checkout started", { "cart.items": 3 })
MapleBrowser.logger.error("payment declined", { "payment.provider": "card" })
```

Levels are `debug`, `info`, `warn` and `error`. Attribute values are strings, numbers or booleans.
Calls before `init()` are queued. The logs SDK loads in a separate chunk right after `init()`, so it
stays out of the bundle every page load has to parse first.

## Tracing across origins

`fetch` spans send the W3C `traceparent` header to same-origin requests only. When your API lives
Expand Down Expand Up @@ -327,14 +343,23 @@ privacy: {

To record only a fraction of sessions, set `replay.sampleRate` between `0` and `1`. For example, `0.1` records ~10% of sessions. Tracing is unaffected by this setting.

`tracing.sampleRate` does the same for traces. The decision is made once per session (a hash of
`session.id`), so a sampled session keeps every one of its traces and its replay never links to a
dropped one. Spans that record an error are always exported, whatever the rate.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```ts
MapleBrowser.init({
ingestKey: "maple_pk_...",
serviceName: "acme-web",
tracing: { sampleRate: 0.25 },
replay: { sampleRate: 0.1 },
})
```

Sampled traces carry the W3C `tracestate` threshold (`ot=th:…`), so Maple weights each one by the
inverse of the rate and request counts stay realistic. A trace joined from a server-rendered
`traceparent` follows the server's decision instead.

## Framework examples

### Plain HTML
Expand Down
19 changes: 17 additions & 2 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ Bundled, minified and gzipped, as your bundler would ship it:

| | gzipped | what it is |
| ---------------- | ------- | --------------------------------------------------------- |
| **eager** | ~36 kB | every page load, before any sampling decision |
| ↳ our code alone | ~13 kB | the marginal cost if your app already ships OpenTelemetry |
| **eager** | ~40 kB | every page load, before any sampling decision |
| ↳ our code alone | ~16 kB | the marginal cost if your app already ships OpenTelemetry |
| **deferred** | ~5 kB | the OTel logs SDK, fetched right after `init()` |
| **lazy** | ~61 kB | rrweb — downloaded only by sessions sampled into replay |

The eager figure is ~90% OpenTelemetry. If your app already uses the OTel web
Expand Down Expand Up @@ -118,6 +119,20 @@ a separate analytics silo. Calls before `init()` finishes are queued.
MapleBrowser.track("checkout_completed", { plan: "pro", seats: 12 })
```

## Logs

`MapleBrowser.logger` writes OpenTelemetry log records, linked to the active span and the session.
Calls before `init()` are queued.

```ts
MapleBrowser.logger.info("checkout started", { "cart.items": 3 })
```

## Trace sampling

`tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per
session, so a sampled session keeps all of its traces. Error spans are always exported.

## Regions

Maple runs separate US and EU instances, and an ingest key only works in the
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/instrumentation": "^0.222.0",
"@opentelemetry/instrumentation-fetch": "^0.222.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-trace-base": "^2.10.0",
"@opentelemetry/sdk-trace-web": "^2.10.0",
"@opentelemetry/semantic-conventions": "^1.43.0",
Expand Down
43 changes: 30 additions & 13 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
* unminified and with every dependency left external, so reading it tells you
* almost nothing. What a visitor downloads is the *bundled, minified, gzipped*
* graph their bundler produces, OpenTelemetry and rrweb included. This builds
* exactly that and splits it two ways:
* exactly that and splits it three ways:
*
* eager — the entry plus everything statically reachable from it. Paid by
* every visitor on every page load, before any sampling decision.
* lazy — reachable only through `import()`. Paid only by visitors sampled
* into replay, which is the entire point of the code split.
* eager — the entry plus everything statically reachable from it. Paid by
* every visitor on every page load, before any sampling decision.
* deferred — the `./deferred` chunk `init()` imports right away. Paid by every
* visitor too, but after `init()` and off the critical path.
* lazy — the rrweb chunk. Paid only by visitors sampled into replay,
* which is the entire point of the code split.
*
* A regression in `eager` is the expensive kind: it hits 100% of page loads.
* The budgets below fail CI so that cost has to be argued for in review rather
Expand All @@ -21,8 +23,14 @@ import { gzipSync } from "node:zlib"

/** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */
const BUDGET = {
/** 38 since 2026-09: navigation spans took it to ~37.3 kB; see `firstParty`. */
eager: 38,
/**
* 41 since 2026-09: 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: 41,
/** Every page load, after `init()`: the OTel logs SDK and exporter. */
deferred: 8,
lazy: 68,
/**
* Our own eager code, with OpenTelemetry and rrweb left external.
Expand All @@ -45,8 +53,11 @@ const BUDGET = {
* 14.5 since 2026-09: navigation and data-loading spans (`startNavigation`,
* `endNavigation`, `traced`) added ~0.6 kB. Apps used to copy the same code
* into their own bundle, so for them this is a move rather than a cost.
*
* 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue
* (~0.5 kB), both needed before the deferred chunk lands.
*/
firstParty: 14.5,
firstParty: 16,
}

/** How close to a ceiling counts as worth warning about. */
Expand Down Expand Up @@ -119,7 +130,12 @@ const eagerChunks = (group: Chunk[]): Chunk[] => {

const eager = eagerChunks(chunks)
const eagerNames = new Set(eager.map((chunk) => chunk.name))
const lazy = chunks.filter((chunk) => !eagerNames.has(chunk.name))
const notEager = chunks.filter((chunk) => !eagerNames.has(chunk.name))
// The rrweb chunk is the one carrying the recorder; every other `import()`
// target is deferred work that every page load fetches.
const isReplay = (chunk: Chunk): boolean => chunk.text.includes("rrweb")
const lazy = notEager.filter(isReplay)
const deferred = notEager.filter((chunk) => !isReplay(chunk))
const total = (group: Chunk[]): number => group.reduce((sum, chunk) => sum + chunk.gzip, 0)

// Same entry, dependencies left external: what a host app that already ships
Expand All @@ -145,11 +161,12 @@ const report = (label: string, group: Chunk[], budget: number): boolean => {
}

console.log("@maple-dev/browser — bundled, minified, gzipped")
const eagerOk = report("eager every page load ", eager, BUDGET.eager)
const lazyOk = report("lazy sampled sessions", lazy, BUDGET.lazy)
const firstPartyOk = report("ours eager, deps external", firstParty, BUDGET.firstParty)
const eagerOk = report("eager every page load ", eager, BUDGET.eager)
const deferredOk = report("deferred every page load, after init", deferred, BUDGET.deferred)
const lazyOk = report("lazy sampled sessions", lazy, BUDGET.lazy)
const firstPartyOk = report("ours eager, deps external", firstParty, BUDGET.firstParty)

if (!eagerOk || !lazyOk || !firstPartyOk) {
if (!eagerOk || !deferredOk || !lazyOk || !firstPartyOk) {
console.error("\nbundle size exceeds budget — raise it in scripts/size.ts if the cost is intended")
process.exit(1)
}
18 changes: 12 additions & 6 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export interface MapleBrowserConfig {
* `traceparent` header in CORS. Example: `[/^https:\/\/api\.example\.com\//]`.
*/
readonly propagateTraceHeaderCorsUrls?: ReadonlyArray<string | RegExp>
/**
* Fraction of sessions whose traces are exported, 0–1. Default 1. Decided
* once per session, so a sampled session keeps every trace. Error spans are
* always exported.
*/
readonly sampleRate?: number
}
readonly replay?: {
/** Default true. */
Expand Down Expand Up @@ -131,6 +137,7 @@ export interface ResolvedConfig {
readonly tracingInstrumentFetch: boolean
readonly tracingCaptureErrors: boolean
readonly propagateTraceHeaderCorsUrls: ReadonlyArray<string | RegExp>
readonly tracingSampleRate: number
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly maskAllInputs: boolean
Expand Down Expand Up @@ -159,17 +166,15 @@ export function resolveIdentity(config: {
* A sample rate outside 0–1 (or not a number) is a typo, not a policy. Clamp it
* and say so, rather than recording everyone or no one without a word.
*/
function resolveSampleRate(raw: number | undefined): number {
function resolveSampleRate(option: string, raw: number | undefined): number {
if (raw === undefined) return 1
if (typeof raw !== "number" || Number.isNaN(raw)) {
console.warn(
`[maple] replay.sampleRate must be a number between 0 and 1; got ${String(raw)}. Using 1.`,
)
console.warn(`[maple] ${option} must be a number between 0 and 1; got ${String(raw)}. Using 1.`)
return 1
}
if (raw < 0 || raw > 1) {
const clamped = Math.min(1, Math.max(0, raw))
console.warn(`[maple] replay.sampleRate must be between 0 and 1; got ${raw}. Using ${clamped}.`)
console.warn(`[maple] ${option} must be between 0 and 1; got ${raw}. Using ${clamped}.`)
return clamped
}
return raw
Expand All @@ -195,8 +200,9 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
tracingCaptureErrors: config.tracing?.captureErrors ?? true,
propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [],
tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate),
replayEnabled: config.replay?.enabled ?? true,
replaySampleRate: resolveSampleRate(config.replay?.sampleRate),
replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate),
maskAllInputs: config.privacy?.maskAllInputs ?? true,
maskAllText: config.privacy?.maskAllText ?? false,
persistVisitorId: config.privacy?.persistVisitorId ?? true,
Expand Down
11 changes: 11 additions & 0 deletions packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// 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 { startLogs } from "./logs"

export function startDeferred(config: ResolvedConfig): () => Promise<void> {
const stops = [startLogs(config)]
return async () => {
await Promise.all(stops.map((stop) => stop()))
}
}
73 changes: 73 additions & 0 deletions packages/browser/src/deferred/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { consentAllowedSince, hasConsent, ingestHeaders, sdkHint } from "@maple/browser-session"
import { ROOT_CONTEXT, trace } from "@opentelemetry/api"
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"
import { resourceFromAttributes } from "@opentelemetry/resources"
import {
BatchLogRecordProcessor,
LoggerProvider,
type LogRecordExporter,
type ReadableLogRecord,
} from "@opentelemetry/sdk-logs"
import type { ResolvedConfig } from "../config"
import { attachLogSink, detachLogSink } from "../logs"
import { resourceAttributes } from "../tracing"
import { SDK_NAME, SDK_VERSION } from "../version"

/** Same rule as spans: drop records made while consent was absent, even if it is granted by flush time. */
class ConsentLogExporter implements LogRecordExporter {
constructor(private readonly inner: LogRecordExporter) {}

export(logs: ReadableLogRecord[], callback: (result: { code: number; error?: Error }) => void): void {
const since = consentAllowedSince()
const eligible =
hasConsent() && Number.isFinite(since)
? logs.filter((log) => log.hrTime[0] * 1_000 + log.hrTime[1] / 1_000_000 >= since)
: []
if (eligible.length === 0) {
callback({ code: 0 })
return
}
this.inner.export(eligible, callback)
}

forceFlush(): Promise<void> {
return this.inner.forceFlush?.() ?? Promise.resolve()
}

shutdown(): Promise<void> {
return this.inner.shutdown()
}
}

/** Start the OTel logs pipeline and drain the eager queue into it. Returns a shutdown. */
export function startLogs(config: ResolvedConfig): () => Promise<void> {
const exporter = new ConsentLogExporter(
new OTLPLogExporter({
url: `${config.endpoint}/v1/logs`,
headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
}),
)
// The browser processor flushes on `visibilitychange → hidden` and `pagehide` itself.
const provider = new LoggerProvider({
resource: resourceFromAttributes(resourceAttributes(config)),
processors: [new BatchLogRecordProcessor({ exporter, scheduledDelayMillis: 2_000 })],
})
const logger = provider.getLogger(SDK_NAME, SDK_VERSION)
attachLogSink((record) =>
logger.emit({
eventName: record.eventName,
severityNumber: record.severityNumber,
severityText: record.severityText,
body: record.body,
attributes: record.attributes,
timestamp: record.timestamp,
context: record.spanContext
? trace.setSpanContext(ROOT_CONTEXT, record.spanContext)
: ROOT_CONTEXT,
}),
)
return async () => {
detachLogSink()
await provider.shutdown()
}
}
21 changes: 13 additions & 8 deletions packages/browser/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
// Error. That is the shape `error_events_mv` fingerprints on, so these arrive in
// error tracking beside server-side errors rather than in a separate silo.
import { scrubUrl } from "@maple/browser-session"
import { type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api"
import { context, type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api"
import { keepContext } from "./sampling"
import { mapleTracer } from "./tracing"
import { SDK_NAME, SDK_VERSION } from "./version"

Expand Down Expand Up @@ -71,15 +72,19 @@ export function recordFailure(span: Span, error: unknown): void {
span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message })
}

/** Record `error` on a one-off span. */
/** Record `error` on a one-off span, exported whatever the session's trace sampling. */
function recordException(error: unknown, options: CaptureExceptionOptions): void {
const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", {
kind: SpanKind.INTERNAL,
attributes: {
...(typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : undefined),
...options.attributes,
const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan(
options.name ?? "exception",
{
kind: SpanKind.INTERNAL,
attributes: {
...(typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : undefined),
...options.attributes,
},
},
})
keepContext(context.active()),
)
recordFailure(span, error)
span.end()
}
Expand Down
Loading
Loading