Browser SDK
Install @unhandled/browser to send network spans, errors, logs, page views and custom events from a web frontend.
@unhandled/browser reports what runs in your users' browsers. It records each fetch and XMLHttpRequest call as a span, plus uncaught errors, unhandled rejections, logs, page views and custom events. Its runtime dependency is @unhandled/protocol, plus web-vitals when you add webVitals(), and it posts OTLP/HTTP JSON to the ingest endpoint in your DSN. It works in single-page apps and server-rendered sites.
Install
Add the package
npm install @unhandled/browserpnpm add @unhandled/browseryarn add @unhandled/browserCreate the client
Call
initUnhandled()once in your entry file. Export the client so other modules can import it.import { initUnhandled } from '@unhandled/browser' export const unhandled = initUnhandled({ dsn: import.meta.env.VITE_UNHANDLED_DSN, environment: 'production', })With no other options, the client records network spans and uncaught errors.
Add the Vite plugin
Optional. The Vite plugin detects the release at build time, so every event carries
service.versionwithout extra wiring.
dsn and environment are required. environment is production, staging or development, and the SDK sends it in the x-unhandled-env header. A missing or malformed option throws an UnhandledConfigError. Its code is one of missing_dsn, invalid_dsn, invalid_environment, duplicate_integration or async_integration_setup.
During server-side rendering there is no window. initUnhandled() then checks the options and returns an inactive client whose active is false and whose methods do nothing. A second initUnhandled() call with the same DSN returns the live client and ignores the new options, so a script that runs again, such as an inline script under Astro's <ClientRouter />, adds no second set of handlers. A different DSN shuts the previous client down and starts a new one.
Options
dsnstringrequiredIngest endpoint, API key and service id.
environmentproduction | staging | developmentrequiredSent as
x-unhandled-env.serviceVersionstringdefaultinjected by the Vite pluginSent as
service.version. An explicit value wins unless it is a placeholder.integrationsBrowserIntegration[]default[network(), errors()]A list replaces the defaults. Each integration name may appear once.
[]leaves only manual capture.beforeSendLogfunctionReceives each log record. Return it, a changed copy, or
nullto drop it.ignoreErrorsIgnoreErrorEntry[]default[]Rules or functions that drop matching errors. See Ignoring errors.
defaultIgnoreErrorsbooleandefaulttruefalseturns off the built-in noise filters.normalizeUrlfunctionRewrites every collected URL. Returning
nulldrops the span or page view. See Normalizing URLs.sanitizeUrlsbooleandefaulttrueReplaces token-like path segments with
REDACTED.falsekeeps them. Query strings and fragments are dropped either way. See Normalizing URLs.
The placeholder versions are 0.0.0, 0.0.1, 1.0.0, unknown, dev, latest and the empty string. If you pass one and the Vite plugin injected a real release, the injected release wins.
Vite plugin
import { defineConfig } from 'vite'
import { unhandled } from '@unhandled/browser/vite'
export default defineConfig({
plugins: [unhandled()],
})During vite build and vite dev the plugin looks for a release in this order:
UNHANDLED_VERSION.- Platform variables:
VERCEL_GIT_COMMIT_SHA,RENDER_GIT_COMMIT,RAILWAY_GIT_COMMIT_SHA,HEROKU_SLUG_COMMIT,SOURCE_VERSION,SOURCE_COMMIT,GIT_REV,K_REVISION. - CI variables:
GITHUB_SHA,CI_COMMIT_SHA,CIRCLE_SHA1,BITBUCKET_COMMIT,BUILDKITE_COMMIT,WORKERS_CI_COMMIT_SHA. - The commit in the local
.gitdirectory.
It skips placeholder values and makes no network calls unless sourcemaps is set. Without Vite, pass serviceVersion yourself.
releasestring | falsedefaultdetectedA fixed release string.
falseturns detection off.injectbooleandefaulttruefalsedetects the release but does not inject it.enabledbooleandefaulttruefalsemakes the plugin do nothing.verbosebooleandefaultfalseLogs the detected release and its source.
sourcemapsobject{ upload?: boolean, keep?: boolean }. Builds the client withbuild.sourcemap = 'hidden', uploads the maps under the detected release when the build ends, then deletes them.upload: falseonly hides them.keep: trueleaves them on disk.
With sourcemaps set, the upload reads a project deploy key from UNHANDLED_API_KEY and the DSN from UNHANDLED_DSN, PUBLIC_UNHANDLED_DSN or VITE_UNHANDLED_DSN in the build's environment. Without a key the plugin prints one warning and still deletes the maps, so they never ship. A failed upload never fails the build. Server and SSR environments keep their own sourcemap setting. With Astro, the plugin goes under vite.plugins and uploads dist/client; see Astro.
Integrations
network()integrationdefaultonfetchandXMLHttpRequestcalls as spans. Options:tracePropagationTargets,ignoreUrls.errors()integrationdefaultonerrorandunhandledrejectionevents.consoleLogs()integrationdefaultoffconsole.debug,info,log,warnanderrorcalls as log records. Option:levels.analytics()integrationdefaultoffPage views and
track()events. Options:autoPageviews,route,ignorePaths,trackAutomatedBrowsers.webVitals()integrationdefaultoffLCP, INP, CLS, FCP and TTFB as log records. Options:
sampleRate,route.
Passing integrations replaces the defaults. List network() and errors() again to keep them:
import { analytics, consoleLogs, errors, initUnhandled, network } from '@unhandled/browser'
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
integrations: [
network({ tracePropagationTargets: [window.location.origin, 'https://api.shop.test'] }),
errors(),
consoleLogs({ levels: ['warn', 'error'] }),
analytics(),
],
})Network
Each request becomes a CLIENT span named <METHOD> <url>, such as GET https://api.shop.test/orders/42. The span carries http.request.method, url.full, server.address and http.response.status_code. A network failure or a status of 500 and up sets ERROR status and error.type, which holds the error name or the status code. The SDK never records its own requests to the ingest paths.
The integration adds a W3C traceparent header so your backend continues the trace.
tracePropagationTargets(string | RegExp)[]defaultthe page originWhere to add
traceparent. A string matches URLs with the same origin. A RegExp is tested against the URL without query or hash. Setting the option replaces the default, so list your own origin if you still need it.[]records spans and never adds the header.ignoreUrlsUrlMatcher[]Strings (substring match), RegExps or functions, tested against the absolute URL without query or hash. An ignored request still runs, with no span and no header. A function that throws counts as a match.
A cross-origin API has to allow the traceparent header in its CORS config.
Errors
errors() listens for error and unhandledrejection. Each report has a mechanism of onerror or onunhandledrejection. Manual captures use manual. When a cross-origin script fails without an error object, the SDK rebuilds one from the event's message, file, line and column.
A burst limit protects your quota during error loops. For each type and message pair, the first 10 reports go out, then at most one every 10 seconds. Across all errors, the first 100 go out, then at most one every 5 seconds. A page reload resets the counters. The limit covers errors() only, never captureException().
Ignoring errors
ignoreErrors applies to uncaught errors, unhandled rejections and captureException(). It skips captureMessage(). The SDK drops an error when any entry matches.
import { initUnhandled } from '@unhandled/browser'
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
ignoreErrors: [
{ type: 'AbortError' },
{ message: /^Loading chunk \d+ failed/ },
(event) => event.mechanism === 'onunhandledrejection' && event.message === 'cancelled',
],
})- A rule object matches when every field it sets matches. The fields are
type(the error name),message, andurl(the file of the innermost stack frame). A string must be equal, and a RegExp is tested. A rule with no fields never matches. - A function receives
(event, { error }), whereeventhastype,message,framesandmechanism. Returntrueto drop the error. A function that throws keeps it.
After your rules, the default filters drop known noise:
Script error.and both ResizeObserver loop messages.- Ad and tag-manager globals:
googletag,gtag,adsbygoogle,_gaq,__tcfapi. - Wallet extension conflicts on
window.solanaandethereum. - Scripts injected by in-app browsers and webviews:
__firefox__,__gCrWeb,_AutofillCallbackHandler. - Errors whose innermost frame is a
chrome-extension:,moz-extension:,safari-extension:,safari-web-extension:orms-browser-extension:URL. - A plain
Errorwith an empty message, no stack frames and no cause.
Set defaultIgnoreErrors: false to report these too.
Console logs
consoleLogs() sends console calls as log records and still calls the original method first, so output still shows in the console. console.log maps to the info level. levels limits which methods get patched. shutdown() restores the originals.
Each message is cut at 8,192 characters, and an object is serialized only as far as that limit reaches, so logging a large store costs about as much as the part that is kept. After a burst of 100 calls, one call every 5 seconds is sent. Every call is still printed.
Analytics
analytics() records page views and named events. It sets no cookies, writes nothing to local storage and attaches no user identity.
autoPageviewsbooleandefaulttrueRecords the first page load and each path change through
history.pushState,history.replaceStateor back and forward navigation. Changes to only the query or hash are not new page views.routefunction(path) => template. Returns the route template a page view is grouped under.:id,$id,[id]and{id}all normalize to:id. Returningnullfalls back to the default grouping.ignorePathsPathMatcher | PathMatcher[]A string (exact path), a RegExp or a function, matched against the real path before
normalizeUrl. Excluded pages send nothing. A function that throws excludes the page.trackAutomatedBrowsersbooleandefaultfalseBrowsers under automation (
navigator.webdriver === true, as in Playwright, Puppeteer and Selenium) send no page views or events. Settrueto count them, for example when a synthetic monitor should show up.
To call unhandled.pageview() yourself, set autoPageviews: false. Doing both counts each page twice.
A page view's route comes from route when it returns a string, then from a <meta name="unhandled-route" content="/blog/[slug]"> tag in the page, read again on every page view. Without either, the SDK names the page view after its path, with identifier-like segments replaced by :id. Numbers, UUIDs, long hex strings and long tokens containing digits count as identifiers, so /orders/8f3a1c92e4b7 groups as /orders/:id.
The SDK reads utm_source, utm_medium, utm_campaign, utm_term and utm_content from the landing URL and attaches them to every page view and event of that page load. It drops a value over 128 bytes, or one that looks like an email, a phone number or an IP address.
track() sends a custom event:
unhandled.track({ name: 'checkout_completed', properties: { plan: 'pro', items: 3 } })The SDK silently drops the whole event when any of these holds:
- The name is empty, longer than 100 bytes, or
pageview. - There are more than 20 properties.
- A key is empty or longer than 64 bytes.
- A key is reserved (
email,user_id,userid,distinct_id,anonymous_id,session_id,ip), or starts withuser,trace,spanorunhandledfollowed by.,_or-. - A value is not a string of at most 256 bytes, a finite number or a boolean.
Without the analytics() integration, pageview() and track() do nothing.
Web vitals
webVitals() reports each metric as a log record named browser.web_vital, with browser.web_vital.name, value, delta, id, rating, navigation_type and target (the element the attribution build blames), plus url.template. The route follows the same order as page views. Chromium 151 and later also report client-side navigations, such as Astro's <ClientRouter />, as their own soft navigations. Other browsers report once per full load. sampleRate (default 1) is decided once per page load. The integration adds about 4 KB gzipped, and nothing when unused.
Client methods
captureException(error, context)Promise<void>Reports an error.
contexttakestags,extra,userandtraceContext.captureMessage(message, levelOrContext)Promise<void>Reports a message. Levels are
debug,info,log,warning,errorandfatal. An unknown level becomesinfo.logger.trace / debug / info / warn / error / fatalvoidSends a log record with attributes. Writes nothing to the console.
setUser(user)voidSets
{ id, email, name }for later events.nullclears it.pageview(), track(event)voidAnalytics. See Analytics.
flush()Promise<void>Sends everything queued and waits for requests in flight.
shutdown()Promise<void>Restores every patched global, removes listeners and flushes.
import { unhandled } from './unhandled'
import { submitOrder, type Cart } from './cart'
export const checkout = async (cart: Cart) => {
try {
await submitOrder(cart)
} catch (error) {
await unhandled.captureException(error, {
tags: { step: 'payment' },
extra: { itemCount: cart.items.length },
})
}
}Capture methods never throw. They resolve once the event is queued. The SDK reports a given Error object once, whether you captured it or errors() caught it. context.user replaces the current user for that one event. traceContext: { traceId, spanId } attaches the event to an existing trace when both ids are lowercase hex of 32 and 16 characters.
setUser() keeps the user in memory. Errors, messages and logs sent afterwards carry it as user.id, user.email and user.name. Analytics events never carry it.
Logger attributes must be strings, numbers or booleans, and the SDK drops other values. beforeSendLog runs for logger.* calls and consoleLogs() only, never for capture methods. It receives a copy with source (logger or console), level, message and attributes. A callback that throws drops the record.
Normalizing URLs
normalizeUrl receives an absolute URL already stripped of credentials, query and hash, with token-like path segments replaced by REDACTED. A segment counts as a token when it is a UUID, a JWT, an email address, or 16 or more letters, digits, _ or - that include a digit. A segment with a hyphen counts only when it mixes upper- and lowercase letters, so slugs such as release-notes-2024 stay. A ;jsessionid= value is replaced too. The rule is a heuristic: it misses short codes and replaces some readable names that contain a digit. sanitizeUrls: false keeps every segment. Use normalizeUrl to collapse identifiers in your routes or to hide internal hosts.
import { initUnhandled } from '@unhandled/browser'
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
normalizeUrl: (url) => url.replace(/\/invoices\/INV-\d+/, '/invoices/:number'),
})The result feeds the network span URL, name and server.address, the page url.full on errors and messages, and the analytics path and route.
A referrer from another site is sent as its origin only, such as https://news.example.com.
Upgrading from an earlier version: a fetch to /reset/9f86d081884c7d659a2feaa0c55ad015 used to be recorded with the full path and is now recorded as /reset/REDACTED, in span URLs, page URLs and analytics paths. Analytics routes group REDACTED segments as :id, as they did the original ids, so routes do not change. Set sanitizeUrls: false to keep the old paths.
Returning null, or throwing, drops the network span or the page view. The request itself still runs, without traceparent. Errors still go out, without a page URL. The SDK never rewrites stack traces, messages or user data.
The deprecated network({ normalizeUrl }) and analytics({ normalizePath }) options still work and run before the client-level option.
Custom integrations
An integration is an object with a unique name and a synchronous setup({ client }) that returns { instrumentations: [{ start, stop }] }. Each start runs after every integration has finished setup. Each stop runs on shutdown(), in reverse order. A setup that returns a promise throws async_integration_setup. Import the types from @unhandled/browser/types, and the analytics types from @unhandled/browser/analytics/types.
import type { BrowserIntegration } from '@unhandled/browser/types'
export const offlineLogger = (): BrowserIntegration => ({
name: 'offlineLogger',
setup: ({ client }) => {
const onOffline = () => client.logger.warn('browser went offline')
return {
instrumentations: [
{
start: () => window.addEventListener('offline', onOffline),
stop: () => window.removeEventListener('offline', onOffline),
},
],
}
},
})Delivery and privacy
The SDK batches records and sends a batch after 1 second, once 50 are queued or once about 30 KB are queued. When the tab turns hidden, or on pagehide, it flushes what is left. Browsers allow about 64 KB of keepalive requests at a time, so the first 60 KB go with keepalive, which the browser finishes after the page is gone, and the rest as ordinary requests, which finish if the page stays open. Nothing persists across reloads.
When the ingest is slow or down, at most 4 requests wait at a time, each for up to 10 seconds, and at most 1,000 records queue behind them. Past that, records are dropped and the console shows one warning. Spans, analytics events and other logs each keep 100 of those slots for themselves, so a burst of spans cannot push out analytics.
A batch that gets a 429, a 5xx response, a timeout or a network error is sent again after a jittered exponential wait (about 1, 2 and 4 seconds), or after the Retry-After the server sent, as seconds or an HTTP date. A batch is tried at most 4 times, and one whose Retry-After is over 60 seconds is dropped. Waiting batches count against the 1,000 records. The SDK waits while navigator.onLine is false. A batch the ingest refuses with another 4xx is dropped. Nothing is retried once the tab turns hidden or closes. The first time a batch is finally dropped, the console shows one warning. Performance and safety lists every limit.
Each request goes to /v1/traces or /v1/logs on the DSN origin, with x-api-key, x-unhandled-service-id, x-unhandled-env and content-type: application/json, and without cookies. The resource attributes are service.version, telemetry.sdk.language=webjs, unhandled.sdk.name and unhandled.sdk.version. Unhandled uses telemetry.sdk.language=webjs to recognize browser services, so it does not flag their root network spans as background spans.
The SDK never reads request or response bodies, headers, cookies or page content. It strips credentials, query and hash from URLs before they leave the page.
Common questions
traceparent goes only to URLs in tracePropagationTargets, which defaults to the page's own origin. Add your API origin to the list, and allow the traceparent header in that API's CORS config.
autoPageviews is on and your code also calls pageview(). Keep one of the two.
No. Without window, initUnhandled() returns an inactive client whose methods do nothing.
Web Vitals, yes: add webVitals(). Session replays and custom spans, not yet. Network spans are the only spans it creates.