Errors
Uncaught exceptions from the browser and your server, grouped into issues, without session replay or request bodies.
Error tracking captures uncaught exceptions from the browser tracker and
handled/uncaught exceptions from the server SDKs, groups occurrences into
issues by a stable fingerprint, and shows where an issue happens (page,
file:line:col, browser/OS, country, release), how often, how many visitors
it hits, and whether it came back after being resolved. Because a server
error and the pageview it broke share a request_id, an issue can show what
share of visits to a page hit it, without any extra wiring.
It is intentionally small. Not included: source-map upload and
symbolication (stacks show minified file:line:col plus the function name
when the browser has one), session replay, request/response bodies, cookies,
click/console/fetch breadcrumbs, or user identification. See the
FAQ on source maps.
Browser setup
Add a second script tag next to the existing one. Error capture ships as its own file so sites that don't want it pay nothing for it:
<script
defer
src="https://cdn.metrikstack.com/js/script.js"
data-site="SITE_ID"
></script>
<script
defer
src="https://cdn.metrikstack.com/js/errors.js"
data-site="SITE_ID"
data-errors
data-release="2026.9.13"
data-env="production"
></script>data-errors turns capture on; data-release and data-env are optional
and attached to every report from this page. With the tag installed,
window.onerror and unhandledrejection are captured automatically. Report
a handled exception yourself with captureException:
<script>
try {
submitOrder(order);
} catch (err) {
window.metrikstack.captureException(err, {
props: { orderId: order.id },
fingerprint: ['checkout', 'submit-order'], // optional grouping override
});
}
</script>props is a flat object, values coerced to strings, at most 20 keys.
fingerprint overrides the automatic grouping (see Grouping and
issues), up to 5 parts.
What the tracker drops
- Extension noise. A report whose stack frames are all
chrome-extension://,moz-extension://orsafari-extension://is dropped before it's sent. That's a browser extension's bug, not the site's. ResizeObserver loop…. A well-known, meaningless browser warning; it's never sent.- Duplicates. Reports are deduped per page load on error type + message + first stack line. The same failure firing 50 times in a loop is still one report.
- A cap of 10 reports per page load, so a broken page can't flood the budget for the rest of the site.
- Cross-origin
"Script error."(a script loaded withoutcrossorigingives the browser no detail) is still sent, but every occurrence groups into one issue that's hidden from the issue list by default. There's nothing actionable in it, but it's there if you want it.
Node setup
@metrikstack/sdk-node reports exceptions to POST /v1/errors, buffered on
the same flush timer as events. See the Node SDK
page for the rest of the SDK; this is the error-specific surface.
import { createMetrikStack } from '@metrikstack/sdk-node';
export const analytics = createMetrikStack({
token: process.env.METRIKSTACK_TOKEN!,
endpoint: 'https://api.example.com',
release: process.env.GIT_SHA,
environment: process.env.NODE_ENV,
beforeSend: (report) =>
report.message.includes('ECONNRESET') ? null : report,
});try {
await charge(order);
} catch (err) {
analytics.captureException(err, {
request: req, // Express request, Hono context, or a Request: joins the error to its pageview
props: { order: order.id },
fingerprint: ['charge', 'card'],
});
throw err;
}Express
app.use(analytics.express());
// … your routes …
app.use(analytics.errorHandler()); // last, before your own error handlererrorHandler() captures with handled: false, takes the status code from
res.statusCode when it's already >= 400 (otherwise 500), and calls
next(err). It never responds and never swallows the error.
Hono
app.onError(analytics.honoOnError());Uncaught errors, opt-in
const uninstall = analytics.installGlobalHandlers();Installs process.on('uncaughtException') and
process.on('unhandledRejection'). Each is captured with handled: false,
the queue is flushed, and then the error is re-raised on the next tick: to
your own handler if you have one, or to Node's default crash if you don't.
It never swallows an error and never keeps a process alive that would
otherwise have died.
beforeSend
The last stop before a report is queued, for scrubbing or dropping reports your own code adds detail to that the built-in filters can't know about:
beforeSend: (report) => {
if (report.message.includes('ECONNRESET')) return null; // drop it
return { ...report, url: undefined }; // or edit it
};What is stored
- Error type, message (≤ 1000 chars) and raw stack (≤ 8000 chars)
- The top stack frame (
file,function,line,col) and up to 50 frames total - Page URL, hostname and path
- Browser/OS and country, derived the same way as for pageviews: from a daily-rotating salted hash, never a raw IP or full user agent
releaseandenvironment, if set- Navigation breadcrumbs (browser only): the last 10 paths visited in the page session, kept in memory, never in storage
- For server errors: HTTP method, status code, and request headers with sensitive ones filtered (see below)
request_id, when available, so an error can be joined to the pageview it broke
What is never stored
- Request or response bodies
- Cookies
- Raw IP addresses (used only to derive country and the visitor hash, then discarded, exactly like an event)
- Any user identity (name, email, account id). Nothing you didn't
explicitly put in
props - Click, console or fetch breadcrumbs. Only navigation is recorded
Scrubbing
Scrubbing filters credentials by key name and card numbers, not free text. It is applied to messages, stacks, props, headers and frame URLs when a report is received, regardless of what an SDK already did:
- A value whose key looks like a credential (
password,token,secret,authorization,api_key,session,cookie, and similar) becomes[Filtered]. This matches keys in props, headers, query parameters, andkey=valuepairs inside free text. - Card-number-looking digit runs (13 to 19 digits, Luhn-valid) become
[Filtered]. - IPv4 and IPv6 literals become
[ip]. We don't store IPs anywhere, error text included. - Request headers
cookie,set-cookie,authorization,proxy-authorizationand any*-api-key/*-token-style header are always[Filtered], whatever their content.
Free text is not scrubbed beyond that. No heuristics for e-mails, IBANs
or the like are applied to arbitrary message and stack text. If your own
code logs something sensitive into an error message, it will reach us as
written. Use beforeSend (Node SDK) or keep sensitive detail out of
messages and props if that matters to you. See
Privacy & data retention for the same list
in context with the rest of what's collected.
Grouping and issues
Occurrences group into an issue by a fingerprint: error type, a normalised
message (numbers and hex ids blanked out, so user 42 not found and
user 43 not found share an issue), and the top 3 stack frames identified by
function name, or by file basename with the build hash stripped when there's
no function name. Line and column numbers and hashed asset names
(main-3f2a.js → the hash is ignored) are deliberately excluded, so a
redeploy doesn't split one bug into two issues.
Pass fingerprint: string[] to captureException to override this and
group by your own key instead. This is useful when several different stacks
are really the same failure.
Statuses and regressions
An issue is open, resolved or ignored. A resolved issue seen again
is shown as regressed. The underlying status is still resolved, but the
dashboard and API surface it separately so you notice it came back. Change
status with PATCH /v1/sites/{id}/errors/{fingerprint}.
Two alert kinds exist for issues: new_error (an issue whose first
occurrence falls in the alert's window, or a resolved issue that regressed)
and error_spike (occurrences or visitors up sharply against the previous
window). See Alerts & webhooks for how alerts are
configured and delivered; both kinds use the same channels, cooldown and
webhook payload as the rest.
Limits and retention
Error reports have their own per-minute budget per site, separate from and not counted against the monthly event quota:
| Plan | Errors / min |
|---|---|
| Free | 60 |
| Pro | 600 |
| Business | 3,000 |
Reports are kept for 90 days, shorter than the 24-month retention for
regular events. Issues are for finding and fixing bugs, not long-term
analytics. POST /v1/error (browser) is capped at 32 kB per report;
POST /v1/errors (server SDKs) at 100 reports per batch.
API
| Route | Purpose |
|---|---|
GET /v1/sites/{id}/errors | Issue list. status = unresolved | open | regressed | resolved | ignored | all; from, to; include_bots; sort = last_seen | first_seen | occurrences | visitors; limit, offset. Returns { issues, total } |
GET /v1/sites/{id}/errors/summary | Totals for an overview tile |
GET /v1/sites/{id}/errors/{fingerprint} | One issue: status, hourly timeline, breakdowns by browser, OS, path, release, country and device type, and the last 50 occurrences |
PATCH /v1/sites/{id}/errors/{fingerprint} | { "status": "open" | "resolved" | "ignored", "note"? } |
Fingerprints are 32-character hex strings. These routes use the same
authentication and scopes as the rest of the API: read for the GET
routes, write for the PATCH. See API.
curl https://api.example.com/v1/sites/$SITE/errors?status=open&sort=occurrences \
-H "authorization: Bearer $KEY"FAQ
Are source maps supported? No. Stacks show the minified
file:line:col plus the function name when the browser provides one, and
the fingerprint is built so a redeploy doesn't open a new issue for the same
bug.
Is this compatible with Sentry's SDK or DSN format? No. This is a separate, much smaller system. It does not implement Sentry's protocol, and Sentry SDKs cannot point at it.