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
- ClickHouse. Recordings are far too large for the Postgres telemetry
store, so
TELEMETRY_STORE=clickhouseis required. Ingest answers501rather than accepting and dropping them. - Retention is separate and shorter than telemetry β
RETENTION_REPLAY_DAYS, default 14. - A chunk is capped at 4MB. Larger is refused with
413. - A recording that only fails at the very last moment may be lost. The
upload needs the page to still be alive;
sendBeaconcannot carry megabytes.
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:
Script error.β a cross-origin script failure with nocrossoriginattribute. No message, no file, no line.ResizeObserver loop limit exceeded/β¦undelivered notificationsβ fired by the spec itself, benign in almost every case.- Anything originating in
chrome-extension://ormoz-extension://β someone else's code, not yours.
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
- Source maps. A minified stack stays minified; there is no upload pipeline yet. Ship readable stacks or expect to read column numbers.
- Web vitals (LCP, CLS, INP) β not collected.
- XHR. Only
fetchis patched. Libraries built onXMLHttpRequest(older axios builds) are not recorded.
See also
- FULL_CONFIG.md β every option across every SDK
- PLUGIN.md β the backend plugin the trace joins up with
- MOBILE.md β Flutter and Dart
- TROUBLESHOOTING.md β nothing showing up?