@unhandled/browser
v0.11.6
Published
Unhandled browser SDK: errors, network spans, logs and analytics
Readme
@unhandled/browser
Browser SDK for Unhandled. It records fetch and XHR calls as spans, reports uncaught errors, sends logs and counts page views and custom events. Its runtime dependencies are @unhandled/protocol, web-vitals (loaded only when you add webVitals()), @rrweb/record and fflate (loaded only when a sampled sessionReplay() starts), and @unhandled/cli, which only the Vite plugin runs, at build time, to upload source maps. It exports OTLP/HTTP JSON straight to the ingest endpoint in your DSN.
Install
pnpm add @unhandled/browserRegister
Call initUnhandled() once in your entry file and export the client.
import { initUnhandled } from '@unhandled/browser'
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
})The DSN ships in your bundle, so restrict the service to your own origins in the service settings. Never put an API key in browser code. During server-side rendering initUnhandled() returns an inactive client that sends nothing. Calling it again with the same DSN returns the live client, so a script that re-runs (Astro's ClientRouter re-executes inline scripts) adds no second set of handlers. A different DSN shuts the old client down and starts a new one.
Vite
// vite.config.ts
import { unhandled } from '@unhandled/browser/vite'
export default { plugins: [unhandled()] }The plugin picks the release from UNHANDLED_VERSION, then platform variables (Vercel, Render, Railway, Heroku, Coolify, Dokku, Cloud Run), then CI variables (GitHub Actions, GitLab, CircleCI, Bitbucket, Buildkite, Cloudflare Workers Builds), then the local .git commit. It injects that value as the SDK's serviceVersion default during vite build and vite dev. It makes no network calls unless sourcemaps is set. Without Vite, pass serviceVersion yourself.
| Plugin option | Default | Effect |
| ------------- | ------- | -------------------------------------------------- |
| release | detect | Fixed release string. false turns detection off. |
| inject | true | false detects without injecting. |
| enabled | true | false makes the plugin a no-op. |
| verbose | false | Logs the detected release and where it came from. |
| sourcemaps | none | { upload?: boolean, keep?: boolean }. See below. |
With sourcemaps: {} the browser build gets build.sourcemap = 'hidden'. When the build ends, the plugin runs unhandled sourcemaps upload on the client output directory under the detected release, then deletes the .map files, so no .assetsignore or extra script is needed. It reads UNHANDLED_API_KEY, and the DSN from UNHANDLED_DSN, PUBLIC_UNHANDLED_DSN or VITE_UNHANDLED_DSN. Without a key it prints one warning and still deletes the maps. A failed upload never fails the build. keep: true leaves the maps on disk; upload: false only hides them. Server and SSR environments keep their own sourcemap setting. With Astro, add the plugin under vite.plugins in astro.config; it uploads dist/client with the Node or Cloudflare adapter.
Options
| Option | Default | Effect |
| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ |
| dsn | required | Endpoint, API key and service id. |
| environment | required | production, staging or development. Sent as x-unhandled-env. |
| serviceVersion | release from the plugin | An explicit value wins unless it is a placeholder such as 0.0.0 or dev. |
| integrations | [network(), errors()] | A list replaces the defaults. Each name may appear once. [] leaves only manual capture. |
| beforeSendLog | none | Return the record, a changed copy or null. Runs for logger.* and consoleLogs() only. |
| beforeSendException | none | Return the report with changed tags and extra, or null to drop it. Skips captureMessage(). |
| ignoreErrors | [] | Rules { type, message, url } (string equals, RegExp tests) or (event, { error }) => boolean. |
| defaultIgnoreErrors | true | false reports the built-in noise (Script error., ResizeObserver, extensions, in-app browsers). |
| normalizeUrl | none | Rewrites collected URLs. null or a throw drops the span or page view. |
| sanitizeUrls | true | Replaces secret path segments (JWTs, emails, known key prefixes, random 32+ char tokens, ids right after reset/verify/invite/token/session/share/oauth/key/hook/download segments, Slack webhook secrets) with REDACTED and keeps ids. 'strict' keeps those rules and also replaces UUIDs and opaque ids, false keeps everything. Query and fragment are always dropped. |
Integrations
| Integration | Captures |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| network({ tracePropagationTargets, ignoreUrls }) | fetch and XHR spans. Adds traceparent to same-origin calls by default. |
| errors() | error and unhandledrejection events, with burst limits. |
| consoleLogs({ levels }) | console.debug/info/log/warn/error as logs. Originals still print. |
| analytics({ autoPageviews, route, ignorePaths, trackAutomatedBrowsers }) | Page views on path changes, track() events and one engagement record. |
| webVitals({ sampleRate, route }) | LCP, INP, CLS, FCP and TTFB, per page load and per soft navigation. |
| pageLoads({ route, ignoreUrls, idleTimeoutMs, maxDurationMs }) | A span per page load and route change, parent of its requests. |
| clickSignals({ route }) | Rage, dead and error clicks with the clicked element's CSS selector. |
| feedbackWidget({ button, askName, askEmail, colorScheme, locale, labels }) | Opt-in feedback button and dialog. See User feedback. |
| sessionReplay({ autoStart, sampleRate, unmaskSelector, blockSelector }) | Opt-in, masked session replay. See Session replay. |
analytics() also sends one engagement record per page load, at the first wheel or trackpad scroll, key press, touch, or pointer press or movement while the page is visible. Only input the browser marks trusted counts, so events a script dispatches and navigator.userActivation alone, which automation tools set without input, do not. A scroll event alone does not count either: window.scrollTo and scrollIntoView fire one the browser marks trusted, so a crawler that scrolls a listing would pass. Every way a person scrolls starts with one of the inputs above, and dragging the scrollbar starts with a pointer press on it. It says only that a person was there (event id, time and site, nothing about the input), so the server can tell a bounce from a crawler that loads pages and does nothing. It is not an event and is not counted or billed.
identify(), setUserProperties() and reset() add about 0.3 KB gzipped to the always-loaded client. trackRevenue() adds about 0.5 KB gzipped (its validation) to the bundle with analytics(). getVariant() and trackExposure() add about 0.1 KB gzipped to the client and 0.7 KB (variant hashing and validation) to the bundle with analytics().
trackRevenue({ amount, currency, id?, product?, subscription?, properties? }) records a purchase, renewal or refund. amount is an integer in the currency's minor unit (1999 is $19.99), negative for a refund; currency is a 3-letter code. subscription is { id, interval: 'day' | 'week' | 'month' | 'year', intervalCount? } and feeds MRR. It follows the track() rules for properties, ignored paths and automated browsers, and invalid input sends nothing. Pass the payment provider's charge id as id so a retried call counts once. The event name revenue is reserved for trackRevenue, so track({ name: 'revenue' }) sends nothing. Anyone who loads the page can call trackRevenue with any amount, so browser-side revenue can be forged; record real payments from the server.
getVariant(experiment, variants) returns the identified user's variant in an experiment and records an experiment_exposure event once per variant per page load. variants is a list of keys or { key, weight }, the control first. The same user gets the same variant on every call and in every SDK, with no request to the server. Before identify(), without analytics() or with invalid input it returns the first variant and records nothing, so a visitor can move to their own variant when they sign in. trackExposure(experiment, variant) records a variant your app assigned itself, and does nothing before identify().
webVitals() uses the web-vitals attribution build with reportSoftNavs, so Chromium 151+ reports separate values for client-side navigations such as Astro's ClientRouter; other browsers report per full load. sampleRate (default 1) is decided once per page load. It adds about 4 KB gzipped, and nothing when unused.
A page view's route template comes from analytics({ route }) when it returns a string, then from <meta name="unhandled-route" content="/blog/[slug]"> when the page has one, then from grouping identifier-like path segments (/users/:id). webVitals() and clickSignals() use the same order. Analytics records, errors and messages send url.template only for a route from the first two; without one the server groups the path itself, so every SDK version lands on the same route. The tag is read on every page view, so it follows a router that swaps <head>. In Astro, put <meta name="unhandled-route" content={Astro.routePattern} /> in the layout.
clickSignals() is off by default. A rage click is 3+ clicks within 1 s and 30 px of the first; a dead click is a link or button click with no DOM change, request or navigation within 1 s; an error click is an exception within 1 s of a click, which also tags that exception with click.selector and click.route. For a dead click, only a DOM change inside or replacing the clicked element, inside, mounting or unmounting what its aria-controls names, or an element added directly under <body> counts, so a ticking clock elsewhere does not hide one, and a click that only changes a table it doesn't contain, a header badge or a toast mounted in an app container can read as dead. Requests come from network() or, without it, from resource timing, minus the SDK's own uploads to the DSN's OTLP paths; a resource timing entry appears only when its response ends, so a request slower than 1 s is missed. Selectors use only the tag, stable id, up to 2 stable classes and a data-testid-style attribute; aria-controls is read but never sent, and text, values and other attributes are never read. Ids, classes and test ids built from user data (id="profile-jane") are not filtered, so keep user data out of them. A page view sends at most 20 signals. It adds about 1.9 KB gzipped.
Route templates from analytics(), webVitals(), pageLoads() and clickSignals() go through the same path redaction as page URLs, so an email, secret token or JWT segment reads REDACTED while ids such as UUIDs stay.
Cross-origin APIs need tracePropagationTargets: ['https://api.example.com'] and must allow the traceparent header in CORS.
A custom integration is { name, setup({ client }) => ({ instrumentations: [{ start, stop }] }) }. setup must be synchronous. stop runs on shutdown().
Client
captureException(error, { tags, extra, user, traceContext }), captureMessage(message, level | { level, tags }), captureFeedback({ message, name, email }), logger.trace/debug/info/warn/error/fatal(message, attributes), setUser(user | null), identify(userId, properties), setUserProperties(properties), reset(), setTags(tags), setTag(key, value), pageview(), track({ name, properties }), flush() and shutdown(). Tags from setTags() and setTag() go on every later error, message and log, and capture tags win over them. Capture methods never throw. shutdown() restores every patched global and flushes.
Identify users
identify(userId, properties?) is the opt-in for user-level analytics. Page views and events are anonymous until you call it; setUser() never links them. Call it on every load while the person is signed in:
client.identify(user.id, { plan: 'pro', email: user.email })
client.setUserProperties({ seats: 3 })
client.reset() // on sign-outidentify() sends a profile update, makes { id } the user on later errors (an existing setUser() with the same id keeps its email and name), and adds user.id to every later page view and event. It works without the analytics() integration, which is only needed for page views and events. setUserProperties() does nothing before identify(). reset() forgets the id and clears the error user. The id is kept in memory only, never in a cookie, localStorage or sessionStorage, so it is gone on reload. Invalid input is dropped silently: the id must be non-empty, at most 256 bytes and free of control characters, and properties follow the event limits (20 keys, keys up to 64 bytes, strings up to 256 bytes, finite numbers, booleans). Only the unhandled. key prefix is reserved, so email and name are allowed. Pass only data you are willing to store about the person.
User feedback
client.captureFeedback({ message, name?, email?, occurrenceId?, traceId? }) sends a note from the person using your app to POST <dsn origin>/v1/feedback and resolves to { ok: true, id } or { ok: false, error, message, retryAfterSeconds?, field?, problem? }, where error is invalid, rate_limited, rejected, network or inactive. For a local invalid failure, field names the input (message, name, email, ...) and problem is required, type, too_long, html, control or format; message stays English. It never throws.
messageis required, 1 to 5000 characters, and may not contain HTML (<followed by a letter,/,!or?) or control characters other than tab and newline.nameis at most 100 characters andemailmust be a valid address of at most 254. The same checks run in the SDK, so a bad submission is refused without a request and with the receiver's wording.occurrenceIdis a UUID or 32 hex characters without dashes.occurrenceIddefaults to the last exception this client reported in the past hour, andtraceIdto that exception's trace, else the open page load's. Pass either to attach the feedback to something else.- Queued telemetry is flushed first, so the exception reaches Unhandled before the feedback that mentions it.
- The page URL (sanitised like error reports) and the service version go along.
const result = await unhandled.captureFeedback({ message: 'Checkout failed', email: '[email protected]' })
if (!result.ok) showError(result.message)feedbackWidget() is an opt-in integration that renders a floating button and a dialog in a Shadow DOM on document.body. Nothing renders unless you add it.
import { errors, feedbackWidget, initUnhandled, network } from '@unhandled/browser'
const feedback = feedbackWidget({ colorScheme: 'system' })
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
integrations: [network(), errors(), feedback],
})
// feedback.open() and feedback.close() drive it from your own button| Option | Default | Meaning |
| ------------- | ---------- | ----------------------------------------------------------------------------------------- |
| button | true | Floating trigger, bottom right. false renders none; call open() yourself. |
| askName | true | Show the optional name field. |
| askEmail | true | Show the optional email field. |
| colorScheme | 'system' | 'light', 'dark' or follow prefers-color-scheme. |
| labels | Locale text | Overrides any of trigger, title, message, messagePlaceholder, name, email, submit, cancel, sending, success, close, errorRequired, errorTooLong, errorFormat, errorHtml, errorInvalid, errorRateLimited, errorNetwork, errorRejected. |
| locale | navigator.language | Language of the dialog: en, de, es, fr, it, pt, nl or ja. A tag such as pt-BR uses its base language; anything else shows English. Sets lang on the widget. |
The widget never shows the receiver's English text: it picks the error message from the result's error, problem and field. labels win over the locale table. The message counter stays numeric.
The dialog has role="dialog" and aria-modal, moves focus to the message field, closes on Escape, keeps Tab inside, and returns focus to the trigger. Text is set as text, never as HTML. Styles live inside the shadow root; a strict style-src CSP is satisfied through constructable stylesheets where the browser has them.
Privacy: name and email are sent only when the person types them (widget) or your code passes them. They are never filled from setUser(). Feedback goes only to your Unhandled endpoint, without cookies.
Session replay
sessionReplay() records the page with rrweb and uploads it to POST <dsn origin>/v1/replay. Nothing records unless you add it.
import { errors, initUnhandled, network, sessionReplay } from '@unhandled/browser'
const replay = sessionReplay({ autoStart: false, sampleRate: 0.2, unmaskSelector: '[data-unmask]' })
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
integrations: [network(), errors(), replay],
})
onConsent(() => replay.start()) // replay.stop() ends it and forgets the replay id| Option | Default | Meaning |
| ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| autoStart | true | false waits for replay.start(), for a consent banner. |
| sampleRate | 1 | Share of replays recorded, decided once per replay. |
| unmaskSelector | none | Text, input values, checkbox and radio state and attribute text inside matching elements are recorded as is. Password inputs stay masked. |
| blockSelector | none | More elements replaced by an empty placeholder, on top of the media defaults. |
| maxDurationMs | 60 minutes | A replay longer than this (and at most 60 minutes) continues as a new one. |
| persistence | 'session' | 'memory' keeps the replay id off sessionStorage, so every page load is its own replay. |
- Masked by default:
- every text node becomes
*per character (spaces kept), shadow DOM included;<style>text is kept so the page renders; - every input,
textareaandselectvalue becomes*per character, whatever the inputtype(hidden,file,submitandbuttonincluded), and checkbox and radio inputs are recorded as unchecked with masked values; - attribute text becomes
*per character:title,aria-label,placeholder,alt,name,value,content,data-*and every other attribute outside a short structural list (id,class,style,type,role,width,height,lang,dir,tabindex,hidden,disabled, table spans,aria-hidden/-expanded/-selected, SVG geometry and paint); - an
id, or a singleclasstoken, that looks like user data becomes*per character, so CSS still matches the rest: one containing@, a run of 4 or more digits, a hex or base64-like token of 16 or more characters, or more than 40 characters ([email protected],order-482913).
- every text node becomes
- Blocked (an empty placeholder of the same size):
img,picture,video,audio,canvas,iframe,object,embed, SVGimageandfeImage, andinput[type=hidden]. Fonts, canvas and cross-origin iframes are not recorded. - Recorded URLs (the page,
href,src,action,srcset) go through the same pipeline as error reports: query and fragment dropped, secret segmentsREDACTEDunlesssanitizeUrls: false, then yournormalizeUrl. - Network requests and bodies, console output and cookies are never recorded.
- With
persistence: 'session',sessionStorageholds one key,unhandled.replay, with only a random replay id, a random page token, a chunk counter and timestamps, so a multi-page visit in one tab is one replay. A tab idle for 30 minutes records nothing until the visitor returns, which starts a new replay. - A duplicated tab (or a
window.openthat copiessessionStorage) starts its own replay while the original tab is still recording it. Each page load asks the other pages of the origin over aBroadcastChannel(unhandled.replay) whether one is recording the stored replay, and waits up to 100 ms for an answer; silence means it was this tab's previous page, so the replay continues. WithoutBroadcastChannelthe replay always continues. - Exceptions reported while recording carry the tag
replay.id, and spans the attributereplay.id, so an issue or trace links to its replay.replay.replayId()returns the current id ornull. - Events go up as gzip JSON chunks every 5 seconds, at about 256 KB, and when the tab is hidden. A
401,403, or a429that outlasts three attempts stops the replay for the page; a413drops that chunk.
The recorder (@rrweb/record) and gzip (fflate) are loaded only when a sampled replay starts. Measured with Vite (minified, gzipped): the integration adds about 4.4 KB to the main bundle (13.5 KB to 17.9 KB with errors()), and a recording page then loads about 22.2 KB for the recorder and 3.3 KB for gzip. sampleRate: 0, autoStart: false without start(), and server rendering load neither.
What gets sent
A batch that gets a 429, a 5xx, a timeout or a network error is sent up to 4 times in all, with jittered exponential backoff, honoring Retry-After (seconds or HTTP date). Nothing is retried on unload, and client.flush() sends a waiting batch once more. Spans, analytics events and other logs each keep 100 of the 1,000 queue slots. Pages under automation (navigator.webdriver === true) send no analytics unless trackAutomatedBrowsers is set.
Every request goes to <dsn origin>/v1/traces or /v1/logs with x-api-key, x-unhandled-service-id, x-unhandled-env and content-type: application/json. When the tab is hidden the queue is flushed with keepalive. Resource attributes are service.version, telemetry.sdk.language=webjs, unhandled.sdk.name and unhandled.sdk.version.
- Network spans: kind CLIENT, named
GET https://api.example.com/users/42, withhttp.request.method,url.full,server.address,http.response.status_code, and error status pluserror.typeon network failure or 5xx. - Errors and messages: log records with
exception.type,exception.message,exception.stacktrace,unhandled.exception.values(frames and mechanism),unhandled.log.source=exception_reporter,unhandled.message.level,unhandled.tag.*,unhandled.extra.*,user.*, the pageurl.fulland, whenanalytics({ route })or theunhandled-routemeta tag names one, the page'surl.template. - Logs: log records with severity and primitive attributes. Console logs add
unhandled.log.source=console. - Web vitals: info log records with event name and body
browser.web_vital, and attributesbrowser.web_vital.name,.value,.delta,.id,.rating,.navigation_type,url.templateand, for LCP, INP and CLS,browser.web_vital.target(the element selector). - Click signals: info log records with event name and body
browser.click, and attributesbrowser.click.kind(rage,deadorerror),browser.click.selector,browser.click.count,browser.click.site(the page's hostname),url.templateand, afteridentify(),user.id, plus the trace id of a request the click started. - Analytics: log records under scope
unhandled.analyticswith body{ event_id, occurred_at_ms, kind, name, site, path, referrer, properties }and attributesunhandled.analytics.version,url.template(only when the app declared the route),unhandled.analytics.utm_*and, afteridentify(),user.id. A page view'snameis its route template, such as/users/:id. A profile update has bodykind: "identify"(noname, emptypathandreferrer), the properties, and theuser.idattribute.
Request and response bodies, headers, cookies and page content are never read. URLs lose credentials, query and hash before they leave the page. Nothing is stored in cookies or local storage; only sessionReplay() uses sessionStorage, as described above.
For AI agents
A coding agent can install this package, check that its data arrives and fix the errors it reports through the Unhandled MCP server at https://unhandled.net/mcp. In Claude Code, the Unhandled plugin adds that server plus skills for instrumenting a service, fixing a production error with a failing test first and verifying a deploy:
claude plugin marketplace add treboryx/unhandled
claude plugin install unhandled@unhandledCodex, Cursor, Gemini CLI and other agents connect to the same URL. Coding agents has the setup for each one and an AGENTS.md section to add to your repository.
