1.7K App
Session Replay / Browser SDK

Browser SDK

Instrument a website with OpenTelemetry tracing and rrweb session replay using the @maple-dev/browser SDK.

@maple-dev/browser instruments a website with OpenTelemetry tracing and rrweb session replay in a single package. Every span and every replay event is tagged with the same session.id, so a trace can link straight to the replay that produced it — and vice versa — with no clock-skew guessing.

Browsers Beta

Using Effect? @maple-dev/effect-sdk’s browser entry point has the same replay engine built in — spans, sessions, and recordings from one package, with rrweb in a lazy code-split chunk. See Session Replay & Sessions. Run replay from one SDK, not both.

Install

npm install @maple-dev/browser

Quick start

Call MapleBrowser.init once, as early as possible in your app’s entrypoint:

import { MapleBrowser } from "@maple-dev/browser"

MapleBrowser.init({
	ingestKey: "maple_pk_...", // public ingest key
	serviceName: "acme-web",
})

That single call:

  • starts OTel browser tracing, auto-instrumenting fetch, exporting to Maple’s ingest (POST /v1/traces);
  • records the session with rrweb, chunking events (~5s / 100KB windows), gzipping them with the native CompressionStream, and uploading to POST /v1/sessionReplays/blob;
  • writes session metadata at start (active) and on page hide (ended), including the trace ids observed during the session.

The SDK is best-effort: network failures in telemetry never throw into your app.

init() returns a handle — { sessionId, shutdown } — for reading the active session id and tearing telemetry down. See Sessions.

Configuration

Every field accepted by MapleBrowser.init:

OptionTypeDefaultDescription
ingestKeystringRequired. Public ingest key (maple_pk_...).
serviceNamestringRequired. Service name reported on traces and stored on replay sessions.
endpointstringhttps://ingest.maple.devMaple ingest base URL. Override for self-hosted / regional ingest.
serviceNamespacestringLogical group this service belongs to, emitted as the OTel service.namespace resource attribute on traces.
serviceVersionstringService version or commit SHA, attached to traces.
environmentstringDeployment environment, e.g. "production".
userIdstringUser id attached to the replay session for correlation. See Identifying users.
tracing.enabledbooleantrueEnable OTel browser tracing.
tracing.instrumentFetchbooleantrueAuto-instrument fetch() to create network spans. Set false when another tracer (e.g. the Effect client SDK) already instruments requests — those spans feed the session via the published sink, and disabling this avoids duplicate network spans.
replay.enabledbooleantrueEnable rrweb session recording.
replay.sampleRatenumber1Fraction of sessions to record, 01. See Sampling.
privacy.maskAllInputsbooleantrueMask all <input> values in the recording.
privacy.maskAllTextbooleanfalseMask all text in the rrweb recording and omit captured click-target text from session events.
privacy.persistVisitorIdbooleantrueStore a persistent visitor id (localStorage + cookie) so unique visitors and new-vs-returning are measurable. Turning it off also purges any id already stored.
privacy.crossSubdomainCookiebooleantrueScope the visitor-id cookie to the registered domain so sibling subdomains share it. See Linking a marketing site to your app.
privacy.cookieDomainstringprobedExplicit cookie Domain= (no leading dot). "" forces a host-only cookie.
privacy.requireConsentbooleanfalseCapture nothing until MapleBrowser.setConsent(true). See Consent.
privacy.captureUserEmailbooleantrueSend identify()’s email through to the warehouse.
privacy.respectDoNotTrackbooleanfalseTreat navigator.doNotTrack like Global Privacy Control (suppresses the persistent visitor id).

A fully-specified call:

MapleBrowser.init({
	ingestKey: "maple_pk_...",
	serviceName: "acme-web",
	environment: "production",
	serviceVersion: "1.4.2",
	userId: currentUser?.id,
	tracing: { enabled: true, instrumentFetch: true },
	replay: { enabled: true, sampleRate: 1.0 },
	privacy: { maskAllInputs: true, maskAllText: false },
})

Sessions

Every span and replay event the SDK emits is tagged with one session.id (a crypto.randomUUID() v4), minted on the first MapleBrowser.init call. That shared id is what lets a trace jump to the replay that produced it, and vice versa.

Storage & continuity

The session is persisted in sessionStorage under the key maple.session, so it survives reloads within a tab. Because sessionStorage is per-tab, each tab or window gets its own session — sessions are never shared across them. When sessionStorage is unavailable (e.g. some private-browsing modes), the SDK falls back to an in-memory record for the life of the page.

SPA route changes do not start a new session — the SDK tracks no router events, so client-side navigation stays within the same session. Session boundaries are purely time-based (see below).

Rotation

A fresh session.id is minted when either limit is crossed, whichever comes first:

  • 30 minutes idle — no recorded activity for half an hour rotates the session (the same activity-window model PostHog uses).
  • 24 hours old — a hard cap on a single session’s lifetime regardless of activity, so a tab left open for days doesn’t collapse into one giant replay.

While replay is recording, each flushed chunk marks the session active, pushing back the idle deadline — so a continuously-recording session stays whole.

Start & end metadata

The SDK writes a small session-metadata row at two points:

  • an active row when recording starts (and again on each reload), and
  • an ended row on page hide / unload — fired on visibilitychange → hidden (the reliable “leaving” signal on mobile) and pagehide (desktop tab close / navigation).

The ended row carries the session duration, the click count, and the trace ids observed during the session, which is what powers trace↔replay correlation and the user/session columns in Maple’s session list and detail views. The unload write uses keepalive, so it survives the page going away.

Accessing the session id

init() returns a handle whose sessionId is the active session’s id — useful for correlating Maple sessions with your own backend logs:

const { sessionId } = MapleBrowser.init({
	ingestKey: "maple_pk_...",
	serviceName: "acme-web",
})

// e.g. forward it on your own requests for correlation
fetch("/api/checkout", { headers: { "x-maple-session": sessionId } })

init() is idempotent — calling it again returns the same live handle. On the server (SSR / no window) it returns a no-op handle with an empty sessionId.

Teardown

Call shutdown() to flush the final replay chunk and tear down tracing + replay. After it resolves, telemetry is fully stopped and a later init() may start a new session — handy when a single-page app unmounts its telemetry client:

const maple = MapleBrowser.init({ ingestKey: "maple_pk_...", serviceName: "acme-web" })

// later, on teardown
await maple.shutdown()

Identifying users

Pass userId so replays and traces are correlated to a known user — it populates the user column in the Maple session list and detail views, and browser-created spans include user.id.

If you don’t know the user at init time (e.g. the SDK starts before login resolves), omit it; the session begins anonymous. Once you know who the user is, call MapleBrowser.identify(userId) to attach (or replace) the id on the active session. Future session rows read the latest id when they post, and future spans read it when they start.

// after the user signs in
MapleBrowser.identify(user.id)

// after the user signs out
MapleBrowser.identify(null)

identify() also takes an object, which is what fills the rest of the session’s identity columns — the email and name shown on the session, and the company/team the Sessions UI can group by:

MapleBrowser.identify({
	id: "user_123",
	email: "ada@acme.com",
	username: "ada",
	groupId: "org_42",
	groupName: "Acme",
	traits: { plan: "pro", signup_month: "2026-01" },
})

Each call replaces the identity rather than merging it — merging would leak a signed-out user’s email into whoever signs in next on a shared device. Traits are capped (24 keys, 64-char keys, 256-char values) and the identity is never persisted to storage.

Custom events

track(name, props) records a product event against the current session. It lands as a session_events row with Type='custom', so it appears inline in the session transcript next to the clicks and network calls around it, rather than in a separate analytics silo.

MapleBrowser.track("checkout_completed", { plan: "pro", seats: 12 })

Names are capped at 128 chars; props at 32 keys / 64-char keys / 1024-char values / 8KB total. Values are coerced to strings (Date → ISO, objects → JSON; null/undefined/functions are dropped). Calls before init() finishes are queued, and track() never throws.

Every session event — page views and track() calls alike — is stamped with the person it belongs to: the visitor id, plus the id and groupId from identify(). That is what lets a funnel follow one person from an anonymous marketing visit through sign-in, and lets browser events line up with the same user’s server-side events. Identity is resolved when the batch is sent, so an identify() shortly after init() still lands on the first page view. The visitor id is empty when the visitor cookie is off (consent not granted, Global Privacy Control, persistVisitorId: false); events from older SDK builds arrive with no identity.

Linking a marketing site to your app

The visitor id is stored in both localStorage and a cookie scoped to your registered domain, so example.com and app.example.com resolve to the same VisitorId. Initialize the SDK on both and an anonymous visit links to the signed-in sessions it later becomes — filter the Sessions list by visitor id to see the whole journey.

The session id is deliberately not shared: each origin keeps its own session, and VisitorId is the join key between them.

The cookie domain is discovered by probing (no public-suffix list needed). Override it when the default is wrong:

MapleBrowser.init({
	// …
	privacy: {
		crossSubdomainCookie: true, // default; false keeps the cookie host-only
		cookieDomain: "example.com", // explicit override
	},
})

Capture is ungated by default. Set privacy.requireConsent to hold everything until the user agrees, then flip it with setConsent():

MapleBrowser.init({ ingestKey, serviceName, privacy: { requireConsent: true } })

// once the banner is accepted
MapleBrowser.setConsent(true)

Revoking stops capture without flushing, and a later grant starts a fresh session. Global Privacy Control is honored regardless of requireConsent — it suppresses the persistent visitor id (the one cross-session identifier the SDK stores) while leaving session-scoped capture alone. doNotTrack is ignored unless you set privacy.respectDoNotTrack. privacy.persistVisitorId: false turns the visitor id off entirely and purges any already stored; privacy.captureUserEmail: false keeps identify()’s email out of the warehouse.

Privacy & masking

maskAllInputs is on by default, so every <input> value is masked before it leaves the browser. Set maskAllText: true to additionally mask all rendered text.

For finer control, use rrweb’s attribute hooks to block specific elements or subtrees from capture:

  • data-rr-block attribute, or the .rr-block class — block an element and its subtree (rendered as a placeholder).
  • .rr-ignore class — ignore input events on an element.
<div class="rr-block">
	<!-- never captured in the replay -->
	<CreditCardForm />
</div>

Sampling

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.

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

Session size limit

A single session records at most 1 GiB of decompressed rrweb data. Past that the recording is truncated at a chunk boundary — earlier chunks stay playable, later ones are dropped — and the session is still listed with its metadata and linked traces.

Normal sessions are nowhere near this: the ceiling exists to stop a runaway recording, which in practice means canvas capture, unmasked media, or a page whose DOM mutates continuously. If you are hitting it, replay.sampleRate will not help (it drops whole sessions, not bytes) — mask or block the noisy subtree instead.

Framework examples

Plain HTML

<script type="module">
	import { MapleBrowser } from "https://esm.sh/@maple-dev/browser"

	MapleBrowser.init({
		ingestKey: "maple_pk_...",
		serviceName: "acme-web",
	})
</script>

React / Vite / Next.js

Initialize at the top of your client entrypoint (e.g. main.tsx, or a client-only module) so it runs once before the app renders:

// src/maple.ts
import { MapleBrowser } from "@maple-dev/browser"

MapleBrowser.init({
	ingestKey: import.meta.env.VITE_MAPLE_INGEST_KEY,
	serviceName: "acme-web",
	environment: import.meta.env.MODE,
})
// src/main.tsx
import "./maple" // import first, before rendering
import { createRoot } from "react-dom/client"
import { App } from "./App"

createRoot(document.getElementById("root")!).render(<App />)

In Next.js, run the import from a client component mounted high in the tree (e.g. the root layout), since the SDK is browser-only.

Notes

  • Replay event blobs live in object storage; only small, queryable metadata is indexed — playback streams blobs directly via signed URLs.
  • The SDK is browser-only and best-effort: telemetry network failures never surface to your application.