MetrikStack

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:// or safari-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 without crossorigin, 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 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 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
  • 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, as for pageviews)
  • Any user identity (name, email, account id), unless you put it in props yourself
  • 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, and key=value pairs 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-authorization and 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:

TierErrors / min
10k, 100k60
200k100
500k250
1M (and the trial)500
2M1,000
5M2,500
10M3,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

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.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.

On this page