MetrikStack

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:// or safari-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 without crossorigin gives 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 handler

errorHandler() 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
  • release and environment, 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, and key=value pairs 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-authorization and 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:

PlanErrors / min
Free60
Pro600
Business3,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

RoutePurpose
GET /v1/sites/{id}/errorsIssue 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/summaryTotals 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.

On this page