Sitelet https://maple.dev/docs/session-replay/browser-sdk/
Skip to content
Maple Docs
Open app
Browse the docs
On this page

Browser SDK

Instrument a website with OpenTelemetry tracing, logs, Web Vitals, error capture and session replay using the @maple-dev/browser SDK.

@maple-dev/browser adds OpenTelemetry tracing, logs, Web Vitals, error capture and session replay to a website in one package. Everything it sends is OpenTelemetry (OTLP traces and logs), apart from the replay recording itself. Every span and every replay event carries the same session.id, so a trace links to the replay that produced it, and a replay links to its traces.

Browsers Beta

Using Effect? The browser entry point of @maple-dev/effect-sdk has the same replay engine built in, with the recorder in a lazily loaded chunk. See Session Replay & Sessions. Run replay from one SDK, not both.

Install

npm install @maple-dev/browser
pnpm add @maple-dev/browser
bun add @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",
	region: "eu", // only for EU organizations; omit for the US region
})

Use a public ingest key (maple_pk_…) from Settings → Ingestion. It is safe to ship in browser code. Ingest keys belong to one region, so set region: "eu" if your organization is in the EU region.

That single call:

  • starts OpenTelemetry browser tracing, auto-instrumenting fetch and exporting to Maple’s ingest (POST /v1/traces);
  • records uncaught errors and unhandled promise rejections as error spans;
  • records the session with rrweb, in chunks of about 5 seconds or 100 KB, gzipped with the browser’s CompressionStream and uploaded 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. A telemetry network failure never throws 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
serviceNamestringnoneRequired. Service name reported on traces and stored on replay sessions.
ingestKeystringnonePublic ingest key (maple_pk_...), sent as Authorization: Bearer. Leave it unset only when endpoint points at your own proxy that adds the key.
region"us" | "eu""us"Region of your Maple organization. "us" sends to https://ingest.maple.dev, "eu" to https://ingest.eu.maple.dev. Ignored when endpoint is set.
endpointstringfrom regionIngest base URL. Overrides region. Use it for a proxy.
serviceNamespacestringnoneLogical group this service belongs to, sent as the service.namespace resource attribute on traces.
serviceVersionstringnoneService version or commit SHA, attached to traces.
environmentstringnoneDeployment environment, for example "production".
userobjectnoneEnd-user identity: id, email, username, groupId, groupName, traits. Attached to the session and to browser spans. See Identifying users.
userIdstringnoneDeprecated. A bare user id. Use user instead. When both are set, user wins.
tracing.enabledbooleantrueEnable OpenTelemetry browser tracing.
tracing.instrumentFetchbooleantrueCreate spans for fetch() calls. Set false when another tracer (such as the Effect client SDK) already instruments requests, to avoid duplicate network spans.
tracing.instrumentXhrbooleantrueCreate spans for XMLHttpRequest calls (axios and older clients). Turn it off for the same reason as instrumentFetch.
tracing.captureErrorsbooleantrueRecord uncaught errors and unhandled promise rejections as error spans. Turn it off only when another tool owns the page’s global error handlers.
tracing.propagateTraceHeaderCorsUrlsArray<string | RegExp>[]Cross-origin URLs whose fetch() and XHR requests carry the traceparent header. See Connect browser and backend traces.
tracing.sampleRatenumber1Fraction of sessions whose traces are exported, 0 to 1. Error spans are always exported. See Sampling.
tracing.captureHeaders{ request?, response? }noneHeader names to record on fetch/XHR spans. See Request and response detail.
tracing.longFramesbooleanfalseSpan main-thread frames of 100ms or more. See Jank.
tracing.slowInteractionsbooleanfalseSpan interactions of 200ms or more. See Jank.
errorsobjectsee Filtering errorsignore, denyUrls, allowUrls, beforeCapture, defaultFilters and captureHttpStatus.
breadcrumbsbooleantrueKeep the last clicks, inputs, navigations and console lines, and send them with the next error. See Breadcrumbs.
webVitalsbooleantrueReport Core Web Vitals. See Web Vitals.
logs.captureConsoleArray<"debug" | "log" | "info" | "warn" | "error">[]Console levels sent as logs as they happen. See Logs.
reporting.cspbooleantrueContent Security Policy violations as logs. See Browser reports.
reporting.browserReportsbooleanfalseBrowser deprecation and intervention reports as logs.
transport.offlinebooleanfalseKeep batches that could not be sent and send them later. See Offline.
replay.enabledbooleantrueEnable session recording.
replay.sampleRatenumber1Fraction of sessions to record, 0 to 1. See Sampling.
replay.onErrorSampleRatenumber0Fraction of the sessions not recorded that keep the last minute in memory and upload it only if an error happens. See Replay on error.
replay.canvasFpsnumberoffRecord <canvas> content at this many frames per second. Never with privacy.maskAllText.
replay.networkBodies{ urls, maxLength? }noneKeep request and response bodies of these URLs in the replay. See Request and response detail.
privacy.maskAllInputsbooleantrueMask all <input> values in the recording.
privacy.maskAllTextbooleanfalseMask all text in the recording, and omit captured click-target text from session events.
privacy.sanitizeUrl(url: string) => stringnoneRewrite every URL before it leaves the page. See Redacting URLs.
privacy.persistVisitorIdbooleantrueStore a persistent visitor id (localStorage and cookie) so unique and returning visitors can be counted. Turning it off also deletes 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.captureUserEmailbooleantrueStore the email passed to identify().
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",
	user: currentUser ? { id: currentUser.id, email: currentUser.email } : undefined,
	tracing: {
		enabled: true,
		instrumentFetch: true,
		captureErrors: true,
		propagateTraceHeaderCorsUrls: [/^https:\/\/api\.example\.com\//],
	},
	replay: { enabled: true, sampleRate: 1.0 },
	privacy: { maskAllInputs: true, maskAllText: false },
})

Sessions

Every span and replay event the SDK emits carries one session.id (a crypto.randomUUID() v4), created on the first MapleBrowser.init call. That shared id is what links a trace to the replay that produced it.

Storage and continuity

The session is stored in sessionStorage under the key maple.session, so it survives reloads within a tab. sessionStorage is per tab, so each tab or window gets its own session. When sessionStorage is unavailable (for example in some private-browsing modes), the SDK keeps the session in memory for the life of the page.

Client-side route changes in a single-page app do not start a new session. Session boundaries are purely time-based.

Rotation

A new session.id is created when either limit is crossed, whichever comes first:

  • 30 minutes idle. No recorded activity for half an hour rotates the session.
  • 24 hours old. A hard cap on a session’s lifetime regardless of activity, so a tab left open for days does not become one giant replay.

While replay is recording, each uploaded chunk marks the session active and pushes back the idle deadline, so a session that keeps recording stays whole.

Start and end metadata

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

  • an active row when recording starts (and again on each reload);
  • an ended row when the page is hidden or unloaded. It fires on visibilitychange to hidden (the reliable “leaving” signal on mobile) and on pagehide (tab close or navigation on desktop).

The ended row carries the session duration, the click count, and the trace ids observed during the session. Maple uses these to link traces and replays, and to fill the user and session columns on the Replays page. 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. Use it to correlate Maple sessions with your own backend logs:

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

// for example, forward it on your own requests
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 upload the final replay chunk and stop tracing and replay. After it resolves, telemetry is stopped and a later init() may start a new session. Use it 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 user to init() so replays and traces are tied to a known user. It fills the user columns on the Replays page, and browser-created spans include user.id:

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

groupId and groupName are the company or team the Replays page can group by. traits are capped at 24 keys, 64-character keys and 256-character values. The identity is never written to browser storage.

If you do not know the user at init time (for example, the SDK starts before login resolves), leave user out and the session starts anonymous. Call MapleBrowser.identify() once you know who the user is. It takes the same object, or a bare user id:

// after the user signs in
MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })

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

Each call replaces the identity. It does not merge, so a signed-out user’s email never carries over to whoever signs in next on a shared device. Future session rows and spans read the latest identity.

userId still works as a bare user id, but it is deprecated. Use user.

Capturing errors

With tracing.captureErrors on (the default), uncaught errors and unhandled promise rejections are recorded as error spans with status Error, which feed the Errors page.

An error your app catches never reaches those global handlers. Report it with captureException, for example from a framework error boundary:

try {
	await submitOrder()
} catch (error) {
	MapleBrowser.captureException(error, {
		name: "checkout.submit_failed", // span name, default "exception"
		attributes: { "order.step": "payment" },
	})
	showErrorToast()
}

The same error object is recorded once, even if your code reports it and then rethrows it, or traced already recorded it. captureException accepts any thrown value: strings and plain objects are turned into an Error. Calls before init() do nothing.

A cross-origin script that throws shows up in the browser as a bare “Script error.” with no details, and the SDK skips it. Add the crossorigin attribute to the script tag to get the real error.

error.cause chains and the errors inside an AggregateError (up to five) are added to the stack trace as Caused by: blocks, after the error’s own frames.

Filtering errors

Filters run before an error is recorded, so a dropped error costs nothing and never opens an issue. They apply to the global handlers and to captureException.

MapleBrowser.init({
	// ...
	errors: {
		ignore: ["ChunkLoadError", /^AbortError: /], // matched against "Name: message"
		denyUrls: [/widgets\.example\.net/], // matched against the top frame's script URL
		allowUrls: [/^https:\/\/app\.example\.com\//], // errors with no stack frames are kept
		beforeCapture: (error, { source, originalError }) => !error.message.includes("401"),
	},
})

Errors thrown by browser extensions and the harmless ResizeObserver loop notices are dropped by default. Set errors.defaultFilters: false to keep them.

Failed HTTP requests

fetch and XMLHttpRequest spans follow the OpenTelemetry HTTP conventions for client requests: a response with a 4xx or 5xx status marks the span as an error, with error.type set to the status code ("404", "503"), so it opens an issue. Issues group by status code.

To count fewer statuses, narrow the list (the default is [[400, 599]]). A status left out is not an error:

errors: {
	captureHttpStatus: [[500, 599], 429]
} // a 404 from a search box is expected here

A request that gets no response at all (offline, DNS, CORS, or a timeout from AbortSignal.timeout()) is always an error. A request your code aborts with its own AbortController is not.

The SDK keeps the last 50 clicks, inputs, navigations and console lines in memory. Nothing is sent until an error is recorded. Then the trail is sent as OpenTelemetry log records linked to the error, so you see what the user did right before it on the error’s trace. Each breadcrumb is sent once. Clicks and inputs are recorded as a short selector (button#save), never with input values. Turn it off with breadcrumbs: false.

Connect browser and backend traces

For a request to the same origin as the page, the fetch() span sends a W3C traceparent header, and your backend’s span joins the same trace. For a request to another origin, such as https://api.example.com from https://app.example.com, the header is not sent unless you list the URL:

MapleBrowser.init({
	ingestKey: "maple_pk_...",
	serviceName: "acme-web",
	tracing: {
		propagateTraceHeaderCorsUrls: [/^https:\/\/api\.example\.com\//],
	},
})

Your API must also allow the header in its CORS configuration. Add traceparent to Access-Control-Allow-Headers in the preflight response. Without it, the browser blocks the request.

Your backend must be instrumented with OpenTelemetry and read traceparent, which every OpenTelemetry HTTP server instrumentation does.

Logs

MapleBrowser.logger writes OpenTelemetry log records, each linked to the span that was active when it was written and to the session:

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

To send console output as logs, list the levels: logs: { captureConsole: ["warn", "error"] }. Calls before init() are queued.

Web Vitals

LCP, CLS, INP, FCP and TTFB are reported as OpenTelemetry log events named browser.web_vital, with the browser.web_vital.name, value, delta, rating, id and navigation_type attributes from the OpenTelemetry browser conventions, plus url.path. Each is linked to the page load’s trace when you use navigation spans. CLS, INP and LCP are final when the page is hidden, so they arrive then. Turn them off with webVitals: false.

Jank

Two opt-in options show where the main thread got stuck:

tracing: { longFrames: true, slowInteractions: true }
  • longFrames records every frame of 100ms or more as a longAnimationFrame span, with the script that ran longest (file, function and what invoked it, such as BUTTON#save.onclick). Browsers without the Long Animation Frames API record longtask spans instead.
  • slowInteractions records every interaction of 200ms or more as an interaction click (or keydown, …) span, split into input delay, processing and presentation time, with the element that was used.

Both nest under the open navigation span, and include what happened before the SDK finished loading.

Request and response detail

List the headers to record on fetch and XHR spans:

tracing: { captureHeaders: { request: ["x-request-id"], response: ["x-cache", "server-timing"] } }

They are stored as http.request.header.<name> and http.response.header.<name>. authorization, proxy-authorization, cookie and set-cookie are never recorded, even when listed. XHR spans get response headers only, and a cross-origin response only exposes the headers its server lists in Access-Control-Expose-Headers.

Request and response bodies can be kept in the session replay’s network events, for the URLs you list only:

replay: {
	networkBodies: {
		urls: [/^https:\/\/api\.example\.com\/checkout/]
	}
}

Only text and JSON bodies are kept, cut to maxLength characters (1,000 by default, which is also the most Maple stores), and nothing is kept with privacy.maskAllText. Bodies can contain personal data, so list only endpoints whose payloads you are allowed to record.

Browser reports

Content Security Policy violations are sent as maple.browser.csp_violation warning logs, with the directive, the blocked URL and, when known, the script location. Set reporting.browserReports: true to also get the browser’s deprecation and intervention reports. These are logs, not errors, so they never open an issue. Each kind of report is sent once per page.

Three calls turn one click into one trace: a navigation span, the data-loading spans under it, the fetch() spans those make, and the backend spans behind them. Call them from your router’s hooks. The frontend guides show where for each framework.

// the router starts a navigation
MapleBrowser.startNavigation(location.pathname)

// a route's data loading
const project = await MapleBrowser.traced("loader /projects/:id", () => fetchProject(id), {
	isFailure: (error) => !isRedirect(error), // control-flow throws aren't failures
})

// the new route is ready: pass its template, not the concrete URL
MapleBrowser.endNavigation("/projects/:id")
  • startNavigation(path) opens a pageload span on the first call and a navigate span on every later one, with the path as url.path. A navigation still open is ended with app.navigation.interrupted: true. The page load joins the server render’s trace when the document response has a Server-Timing: traceparent;desc="…" entry or the page a <meta name="traceparent"> tag.
  • The page load starts at the browser’s navigation start, and once the page has loaded it gets child spans for fetching the HTML (documentFetch, with dns, connect, request and response under it), domProcessing and loadEvent.
  • endNavigation(route?) renames the span to navigate <route> (or pageload <route>) and ends it. It does nothing when no navigation is open.
  • traced(name, fn, options?) runs fn in a span under the open navigation and returns its result unchanged. A throw is recorded on the span, marks it Error, and is rethrown; isFailure returning false leaves the span Ok. An error traced recorded isn’t reported again by captureException or the global handlers.

Using React Router or TanStack Router? instrumentReactRouter and instrumentTanStackRouter from @maple-dev/browser/react make these calls for you. See React.

The browser has no async context: only requests fn starts before its first await nest under its span. Start independent requests together, with Promise.all.

All three do nothing on the server, before init(), with tracing disabled or before consent is granted; traced then only runs fn. A page load that happened before consent isn’t traced later: the next navigation is a navigate span. Leaving the page or calling shutdown() ends an open navigation as interrupted, so it still exports.

Custom events

track(name, props) records a product event against the current session. It appears inline in the session transcript next to the clicks and network calls around it, and counts as a product event.

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

Names are capped at 128 characters. Props are capped at 32 keys, 64-character keys, 1024-character values and 8 KB in total. Values are converted to strings (Date to ISO, objects to JSON; null, undefined and functions are dropped). Calls before init() finishes are queued, and track() never throws.

Every session event, page views and track() calls alike, carries 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 visitor. Initialize the SDK on both and an anonymous visit links to the signed-in sessions it later becomes. On a replay, the link next to Visitor ID lists every session from that visitor, which shows the whole journey.

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

The SDK finds the cookie domain by probing, without a public-suffix list. 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 on by default. Set privacy.requireConsent to hold everything until the user agrees, then call setConsent():

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

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

Revoking stops capture without uploading what is buffered, and a later grant starts a new session. Global Privacy Control is honored regardless of requireConsent. It suppresses the persistent visitor id (the one cross-session identifier the SDK stores) and leaves session-scoped capture alone. doNotTrack is ignored unless you set privacy.respectDoNotTrack. privacy.persistVisitorId: false turns the visitor id off entirely and deletes any already stored. privacy.captureUserEmail: false keeps the email from identify() out of Maple.

Privacy and masking

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

To block specific elements or subtrees, use rrweb’s attributes:

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

Redacting URLs

The SDK already replaces the values of credential-shaped query and fragment parameters (token, code, access_token, password and similar) in every URL it sends. To redact more, pass privacy.sanitizeUrl. It runs after the built-in redaction, on session entry and exit URLs, event rows, network events, replay metadata and span attributes:

MapleBrowser.init({
	ingestKey: "maple_pk_...",
	serviceName: "acme-web",
	privacy: {
		// hide invite codes in paths like /invite/abc123
		sanitizeUrl: (url) => url.replace(/\/invite\/[^/?#]+/, "/invite/:code"),
	},
})

Sampling

To record only a fraction of sessions, set replay.sampleRate between 0 and 1. For example, 0.1 records about 10% of sessions. Tracing is not affected.

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

A value outside 0 to 1 is clamped, with a console warning.

tracing.sampleRate samples traces the same way. The decision is made once per session, so a sampled session keeps all of its traces and its replay never links to a missing one. Errors reported as their own spans (uncaught errors, unhandled rejections, captureException and failures inside traced()) are always sent. Failed request spans follow the session’s sampling like any other span. Sampled traces carry their sampling rate so request counts in Maple stay accurate.

Replay on error

replay.onErrorSampleRate covers the sessions replay.sampleRate leaves out. Those sessions record into memory only, keeping roughly the last minute, and upload nothing. When an error is recorded, the buffered minute is uploaded and the rest of the session is recorded normally, including its later page loads.

replay: { sampleRate: 0.05, onErrorSampleRate: 1 } // 5% of sessions, plus every session with an error

These sessions download the recorder like recorded ones, and keep up to 4 MB in memory.

Offline

The SDK retries a failed send for a few seconds. With transport: { offline: true }, a batch of spans or logs that still could not be sent is kept in IndexedDB and sent again when the browser is back online or on the next page load. At most 100 batches are kept, for up to 24 hours, and revoking consent deletes them.

Session size limit

A single session records at most 1 GiB of decompressed replay data. Past that, the recording is cut 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 far below this. The limit stops a runaway recording, which in practice means canvas capture, unmasked media, or a page whose DOM changes continuously. replay.sampleRate does not help here, because it drops whole sessions. 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 and Vite

Initialize at the top of your client entry point 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 />)

React

@maple-dev/browser/react adds an error boundary, a handler for React 19’s root error options, and router integrations that record navigations for you:

import { createBrowserRouter, RouterProvider } from "react-router"
import { instrumentReactRouter, mapleReactErrorHandler, MapleErrorBoundary } from "@maple-dev/browser/react"

const router = createBrowserRouter(routes)
instrumentReactRouter(router) // or instrumentTanStackRouter(router)

createRoot(document.getElementById("root")!, {
	onCaughtError: mapleReactErrorHandler(),
	onUncaughtError: mapleReactErrorHandler(),
}).render(
	<MapleErrorBoundary fallback={({ reset }) => <button onClick={reset}>Try again</button>}>
		<RouterProvider router={router} />
	</MapleErrorBoundary>,
)
  • MapleErrorBoundary reports a render error once, with the component stack, and renders fallback.
  • instrumentReactRouter works with data routers (createBrowserRouter). A navigation starts when the router starts loading and ends when its loaders finish, named after the route (navigate /projects/:id).
  • instrumentTanStackRouter does the same, named after the route’s full path (navigate /projects/$projectId). Changing only search params is not a navigation.

With a router integration, don’t call startNavigation and endNavigation yourself.

Next.js

Next.js exposes NEXT_PUBLIC_* variables through process.env, not import.meta.env. Initialize from a client component and render it in the root layout:

// app/maple.tsx
"use client"

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

// init() is a no-op during server rendering, so module scope is safe here.
MapleBrowser.init({
	ingestKey: process.env.NEXT_PUBLIC_MAPLE_INGEST_KEY!,
	serviceName: "acme-web",
	environment: process.env.NODE_ENV,
})

export function Maple() {
	return null
}
// app/layout.tsx
import { Maple } from "./maple"

export default function RootLayout({ children }: { children: React.ReactNode }) {
	return (
		<html lang="en">
			<body>
				<Maple />
				{children}
			</body>
		</html>
	)
}

Verify

Load a page with the SDK installed, click around for a few seconds, then leave the tab.

  1. Open Traces and filter by your serviceName. The page’s fetch() spans appear within about a minute.
  2. Open Replays. The session appears in the list. Open it to play the recording.
  3. If you set propagateTraceHeaderCorsUrls, open a fetch() span in Traces. Your backend’s span is in the same trace.

Troubleshooting

  • Nothing arrives, and requests to the ingest endpoint return 401. The ingest key is wrong, or it belongs to the other region. EU keys need region: "eu".
  • No replay, but traces arrive. Check replay.enabled and replay.sampleRate, and whether requireConsent is on without a setConsent(true) call.
  • Cross-origin API calls fail after adding propagateTraceHeaderCorsUrls. The API’s CORS preflight does not allow traceparent. Add it to Access-Control-Allow-Headers.
  • Browser and backend spans are in separate traces. The API origin is not in propagateTraceHeaderCorsUrls, or the backend does not read traceparent.
  • Each error appears twice. Another tool on the page also handles uncaught errors and reports them to Maple. Set tracing.captureErrors: false so only one of them does.
  • Duplicate network spans. Another tracer, such as the Effect client SDK, also instruments fetch. Set tracing.instrumentFetch: false.

Notes

  • Replay recordings are stored as compressed blobs. Only small, queryable metadata is indexed, and playback streams the blobs through signed URLs.
  • The SDK is browser-only and best-effort. Telemetry network failures never surface to your application.

Next steps