Browser: web apps

@sentrinel/plugin/browser captures what a web app does wrong β€” uncaught errors, unhandled rejections, failed and slow fetch calls β€” and puts a traceparent on same-origin requests, so a click and the server work it caused land on one trace.

Works in any framework. React, Vue, Svelte, TanStack Start, Next, Remix, or no framework at all.


The one thing to understand first

The browser SDK holds no API key.

Everything in a JavaScript bundle is public. An ingest key shipped to the client is a key any visitor can read out of the page source and use to write into your account, and there is no way to scope an ingest key down after the fact.

So the page posts batches to an endpoint on your own server, and that endpoint forwards them with the key. @sentrinel/plugin/tunnel is that endpoint, in about three lines.

The tunnel also pins appName and env server-side, ignoring whatever the batch claims. Without that, anyone who found the endpoint could write telemetry into a different app in your account.

browser ──POST /api/sentrinel──▢ your server ──X-API-Key──▢ api.sentrinel.dev
   no key                          the key lives here

Install

bun add "@sentrinel/plugin@github:Zaga-ltd/sentinel_packages"

Client

// wherever your app boots β€” before hydration
import { initSentrinelBrowser } from '@sentrinel/plugin/browser';

const sentrinel = initSentrinelBrowser({
  endpoint: '/api/sentrinel',   // default; same origin, so no key here
  release: '2026.8.2',
});

Call it before hydration or first render. An error thrown during hydration is one of the most common real failures, and a handler installed afterwards misses it.

Safe to import from code that also runs on the server: with no window it returns a no-op handle rather than throwing. Calling it twice returns the first handle instead of double-patching fetch, which matters because React StrictMode and hot reload both re-run module init.

Server

Any runtime that speaks Request/Response β€” Bun, Elysia, Hono, Next route handlers, TanStack Start, Remix, Deno, Cloudflare Workers.

import { createSentrinelTunnel } from '@sentrinel/plugin/tunnel';

export const POST = createSentrinelTunnel({
  serverUrl: 'https://api.sentrinel.dev',
  appName: 'admin',
  env: 'prod',
  apiKey: process.env.SENTRINEL_API_KEY!,   // never reaches the client
  release: process.env.GIT_SHA,
});

That is the whole setup.


What gets captured

Signal Source Notes
Uncaught errors window error event Session marked crashed
Unhandled rejections unhandledrejection Session marked crashed
Handled errors captureError() Session marked errored
Requests patched fetch Status, duration, query params
Network failures patched fetch Status 0 β€” nothing reached a server
Breadcrumbs clicks, navigation, requests, yours Last 25, on every error
Sessions one per page load The denominator for crash-free rate
Device context navigator, screen Language, UA, cores, connection type

Handlers are added with addEventListener, not by assigning window.onerror. Assignment replaces whatever the app β€” or another SDK β€” already installed, and silently disabling someone else's error reporting is rude.

The trace join

Same-origin fetch calls get a traceparent header. The backend plugin continues that trace rather than starting its own, so the click, the request and the handler appear on one waterfall. That join is the thing most tools do not do well.

Third-party URLs get nothing by default: sending trace ids to a vendor leaks them, and adding a header turns a simple request into one that needs CORS preflight. Widen it deliberately if you own both ends:

initSentrinelBrowser({
  tracePropagationTargets: [/^\//, 'https://api.myapp.com'],
});

A traceparent you set yourself always wins β€” the SDK only fills in a missing one.

Sessions and release health

One session per page load, feeding the same Release health page as mobile.

Status Meaning
ok Loaded and left cleanly
errored Reported a handled error
crashed An uncaught error or unhandled rejection
abnormal Ended with no clean shutdown and no error

Status only ever rises. A handled error after a crash does not downgrade the session β€” the release's number should not depend on the order two events happened to arrive in.

Sessions are sent on pagehide via sendBeacon, the only transport a browser guarantees to complete once the page is going away. unload is unreliable on mobile Safari and blocks the back/forward cache.

Without release, every build reads as unknown and a regression is invisible.


Session replay

Records the DOM and uploads only the seconds leading up to an error.

initSentrinelBrowser({
  endpoint: "/api/_sentrinel",
  replay: {
    enabled: true,
    bufferSeconds: 30,      // how much history to keep in memory
    tailSeconds: 5,         // how long to keep recording after the error
    sessionSampleRate: 0,   // 0 = errors only; raise to also record clean sessions
  },
});

Install the recorder alongside the SDK β€” it is imported dynamically, so leaving replay off costs nothing in your bundle:

bun add rrweb

Why a buffer, not a stream

Uploading continuously is megabytes per session for recordings almost nobody watches. Buffering means the cost is paid only when something breaks, and what you get is the part you wanted: the thirty seconds before the failure, including the click that caused it. A session that never errors sends nothing at all.

Everything is masked by default

Text Masked β€” every string renders as blocks
Inputs Masked β€” values are never recorded
Canvas Not recorded
Fonts Not collected

Nobody enables replay expecting to ship customer names, addresses and order totals to a vendor, so visibility is opt-in:

<!-- This element's text is readable in the replay. Nothing else is. -->
<div data-sentrinel-unmask>Order #1042 β€” shipped</div>

<!-- Not recorded at all, not even its layout. -->
<div data-sentrinel-block><iframe src="https://bank.example"></iframe></div>

Change the selectors with unmaskSelector / blockSelector if those attributes clash with your markup.

Requirements and limits

Recordings appear under Session replay in the dashboard, linked to the session they came from.


Options

initSentrinelBrowser({
  endpoint: '/api/sentrinel',       // where batches go
  release: '2026.8.2',              // per-release crash-free rate

  captureErrors: true,              // window errors + rejections
  captureRequests: true,            // patch fetch
  captureBreadcrumbs: true,         // clicks + navigation
  trackSessions: true,              // one per page load

  sampleRate: 1,                    // successful requests kept
  slowRequestThresholdMs: 2_000,    // above this, always kept
  flushInterval: 10_000,
  maxBatch: 100,

  tracePropagationTargets: [/^\//], // same-origin only, by default
  ignoreErrors: ['AbortError'],     // on top of the built-in noise list

  beforeSend: (record) => record,   // last stop before the wire; null drops it
  debug: import.meta.env.DEV,
});

Errors and slow requests are never sampled out regardless of sampleRate. Lowering it only ever loses the healthy baseline you need to tell "slow" from "normal".

Noise dropped by default

Reported by browsers, never actionable:

Redacting in the browser

beforeSend runs before a value is ever put on the wire, which is the only place that helps for data that should not leave the device at all.

beforeSend: (record) => {
  if (record.kind === 'request' && record.queryParams?.token) {
    record.queryParams.token = '[redacted]';
  }
  return record;   // return null to drop it entirely
}

A beforeSend that throws does not swallow the record it was inspecting.


Tunnel options

createSentrinelTunnel({
  serverUrl: 'https://api.sentrinel.dev',
  appName: 'admin',                 // pinned; the batch cannot override it
  env: 'prod',                      // pinned
  apiKey: process.env.SENTRINEL_API_KEY!,
  release: process.env.GIT_SHA,

  maxBodyBytes: 256 * 1024,         // an open endpoint that buffers anything
                                    // is a memory-exhaustion target
  consumerIdentifier: (request) => userIdFrom(request),
  beforeForward: (batch, request) => batch,   // null drops the batch
});

consumerIdentifier is read from the request server-side and overrides whatever the browser claimed. An identity a page asserts about itself is a claim, not a fact. This is what makes errors group under a person on the Consumers page.

The tunnel always answers 202 once a batch parses, even if forwarding fails. The page can do nothing useful with a delivery failure, and an error response would appear in its console as a failed request β€” noise about monitoring, in place of the app's own signal.


API

sentrinel.captureError(err, { orderId });   // handled error, with context
sentrinel.addBreadcrumb('tapped pay', { category: 'ui', data: { amount: 42 } });
sentrinel.setUser('user_123');              // or null on sign-out
sentrinel.setContext({ tier: 'pro' });      // attached to everything after
await sentrinel.flush();                    // send now
await sentrinel.close();                    // unpatch, end session, send

Breadcrumbs fill themselves from clicks, navigation and requests. Add your own for business events β€” a trail you have to remember to fill is empty in exactly the session that just broke.


Not covered


See also