Errors
Lightweight error tracking for the browser and Node that groups uncaught exceptions into issues, without session replay, cookies or request bodies.
Error tracking captures uncaught exceptions from the browser and handled
or uncaught exceptions from the server SDKs. It 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 affects, and whether it came back after being resolved. A server error
and the pageview it broke share a request_id, so an issue can show what
share of visits to a page hit it without extra setup.
It is deliberately small. It does not include source-map upload or
symbolication (stacks show the minified file:line:col plus the function
name when the browser has one), session replay, request or 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 is a separate file, so sites that don't use it don't download 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 errors. A report whose stack frames are all
chrome-extension://,moz-extension://orsafari-extension://is dropped before it's sent, because the bug is in a browser extension. ResizeObserver loop…. A common browser warning that says nothing about your code; it's never sent.- Duplicates. Reports are deduped per page load on error type, message and first stack line. The same failure firing 50 times in a loop is sent once.
- More than 10 reports per page load, so one broken page can't use up the budget for the rest of the site.
- Cross-origin
"Script error."(from a script loaded withoutcrossorigin, which gives the browser no detail) is still sent. Every occurrence groups into one issue that is hidden from the issue list by default, since it tells you nothing you can act on.
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.metrikstack.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 sends a response and always passes the error on.
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 raised again on the next tick:
to your own handler if you have one, or to Node's default crash if you
don't. It never hides an error and never keeps alive a process that would
otherwise have exited.
beforeSend
Runs last, before a report is queued. Use it to scrub or drop reports that contain details 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. The raw IP and full user agent are not stored
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, as for pageviews)
- Any user identity (name, email, account id), unless you put it in
propsyourself - Click, console or fetch breadcrumbs. Only navigation is recorded
Scrubbing
Scrubbing looks for credentials by key name and for card numbers. It doesn't scan free text for anything else. It runs on messages, stacks, props, headers and frame URLs when a report arrives, whatever 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 addresses become
[ip], so no IP is stored in error text either. - 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. Message and stack text is not
searched for e-mail addresses, IBANs or similar. If your own code puts
something sensitive in an error message, it reaches us as written. Use
beforeSend (Node SDK), or keep sensitive details out of messages and
props. 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 the hash in asset names
(main-3f2a.js) are left out, so a redeploy doesn't split one bug into two
issues.
Pass fingerprint: string[] to captureException to group by your own key
instead. This helps when several different stacks are really the same
failure.
Statuses and regressions
An issue is open, resolved or ignored. A resolved issue that
occurs again is shown as regressed. Its stored status is still
resolved, but the dashboard and API list it separately so you notice it
came back. Change the 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 and are not counted toward your monthly pageview volume. The budget grows with the tier:
| Tier | Errors / min |
|---|---|
| 10k, 100k | 60 |
| 200k | 100 |
| 500k | 250 |
| 1M (and the trial) | 500 |
| 2M | 1,000 |
| 5M | 2,500 |
| 10M | 3,000 |
Reports are kept for 90 days, shorter than the 24-month retention for
regular events, because issues are for finding and fixing bugs rather than
long-term reporting. 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.metrikstack.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. It is a separate, much smaller system that doesn't implement Sentry's protocol, so Sentry SDKs can't send to it.