# Capture an error

> Source: https://cross-deck.com/docs/capture-an-error/ · Crossdeck developer docs (generated from the published page).

Errors
9 min read
·
Automatic capture, manual capture, and every control in between

Crossdeck captures most errors without a single line of code beyond `init()`: uncaught exceptions, unhandled promise rejections, and failed HTTP requests are hooked at the platform level on web, Node, iOS, and React Native. Errors you catch yourself — in a `try/catch` or a framework error boundary — are yours to report with `captureError`. Every report ships through the same durable queue as analytics, carries the identity, tags, and breadcrumbs known at the moment it fired, and passes through your `beforeSend` hook before it leaves the device. This page is the complete inventory: what is collected automatically on each platform, what needs a manual call, and every control you have over the pipeline.

## TL;DR

- **Uncaught means automatic.** Global exception handlers, unhandled-rejection handlers, and a `fetch` wrap (plus XHR on web) install at init. HTTP capture fires only on 5xx and network failures — a 4xx is never auto-reported.

- **Caught means manual.** Anything your code catches is invisible to the global handlers. Report it with `captureError(err)`; report non-exception signals with `captureMessage(msg)`.

- **Three levels:** `error`, `warning`, `info`. The server silently coerces anything else (Sentry's `fatal`, `debug`, `log`) to `error` — migrating code keeps working.

- **Context rides along:** tags, named context blocks, the last 50 breadcrumbs, and the identity known at capture time. Call `identify()` first or the error is attributed to the anonymous ID.

- **You hold the last word:** a `beforeSend` hook can rewrite or drop any report; the `errors` consent dimension gates capture entirely; emails and card numbers are scrubbed by default.

- **The pipeline defends itself:** 5 reports per fingerprint per minute, 100 per session, and the SDK never reports failures of its own API calls.

## Captured automatically

Error capture installs during `init()` and is on by default on every platform that supports automatic hooks. Each source can be disabled individually; on web, `autoTrack: { errors: false }` turns the whole tracker off. The hooks differ per runtime, so here is exactly what each SDK listens to.

### Web

Source What it catches Default

Global error event Uncaught synchronous exceptions. On
unhandledrejection Promise rejections nothing awaited or caught. On
fetch() wrap Responses with status ≥ 500, and network failures (status 0). 4xx responses never fire — auth and validation failures are usually expected. On
XMLHttpRequest wrap Same semantics as the fetch wrap, for legacy XHR consumers. On
console.error wrap Each call captured as a message-level report. Off (noisy)

Two web-specific behaviours worth knowing. First, cross-origin script errors — where the browser strips the message down to `"Script error."` — are not dropped. They are captured with an explicit message naming the fix (add `crossorigin="anonymous"` to the script tag plus CORS headers on the script's origin), tagged `cross_origin: "true"`, and grouped under a single fingerprint, since one CORS change fixes all of them. Second, status-0 fetch failures are filtered for client-environment noise before they report: aborted requests, requests made while the browser is offline, and same-origin status-0 failures (the page itself loaded from that origin, so a blocked request there is an ad blocker, not an outage) are skipped. Cross-origin status-0 failures — a third-party API genuinely unreachable — still report.

### Node

Source What it catches Default

process.on("uncaughtException") Uncaught synchronous errors. On
process.on("unhandledRejection") Unhandled promise rejections — the rejection reason can be any value, not just an Error . On
globalThis.fetch wrap 5xx responses and network failures, Node 18+. Skipped silently when fetch is absent. On
console.error wrap Each call captured as a message-level report. Off (noisy)

There is no XHR wrap (no XHR in Node), and errors whose top stack frame resolves inside `node_modules/@cross-deck/node` are dropped by default — the SDK does not report its own bugs as yours. Uncaught errors that arrive without per-request identity are attributed to a process-stable pseudo-anonymous ID, so reports from the same process correlate.

### Swift (iOS, macOS)

The Swift SDK takes a deliberately different posture, because `NSSetUncaughtExceptionHandler` is a process-wide singleton that Crashlytics, Sentry, and Bugsnag all compete for. Manual `captureError` always works. The global uncaught-exception hook is **off by default** — enable it with `captureUncaughtExceptions: true` only if Crossdeck is your primary error tracker. When enabled, Crossdeck chains: it records whatever handler was registered before it and invokes that handler after taking its own snapshot, so an existing crash reporter never loses a crash.

Fatal crashes survive the restart. The exception handler snapshots the exception — type, reason, parsed stack, fingerprint — and hands it to the event queue, which persists every enqueued event to disk immediately. If the process dies before the report ships, the next launch rehydrates the on-disk queue and delivers it. The same persistence covers force-quits and backgrounding: the SDK persists the queue on resign-active and will-terminate notifications.

Separately, opting in to `enablePerformanceMonitoring` turns on MetricKit-backed diagnostics on iOS 14+, including `perf.crash_diagnostic`, `perf.hang`, and `perf.cpu_exception` events. That is a distinct, off-by-default channel from error capture.

### React Native

Source What it catches Default

ErrorUtils.setGlobalHandler Uncaught synchronous errors and unhandled promise rejections — React Native routes both through the same global handler on 0.63+. On
globalThis.fetch wrap 5xx responses and network failures. Same semantics as web. On

The handler chains in front of the prior one, so the red-box developer overlay and any OS crash reporter still fire. React Native passes an `isFatal` flag with each uncaught error; Crossdeck maps fatal errors to level `error` and non-fatal ones to `warning`.

## The events on the wire

On web, Node, and React Native, each captured error ships as a named Crossdeck event, so a dashboard query for `name LIKE 'error.%'` returns all of them:

Event Emitted when

error.unhandled An uncaught exception reached the global handler.
error.unhandledrejection A promise rejected with nothing to catch it (web and Node; React Native folds these into error.unhandled ).
error.handled Your code called captureError() .
error.message Your code called captureMessage() , or a wrapped console.error fired.
error.http A wrapped fetch or XHR request returned 5xx or failed at the network level. Carries the URL, method, and status.

Each event's properties include the message (capped at 1,024 characters), the error class name when known (`TypeError`, `ReferenceError`, a custom class), parsed stack frames plus the raw stack string, a fingerprint that groups identical errors, and the breadcrumbs, tags, and context described below. Thrown values that are not `Error` instances — strings, numbers, plain objects, `null` — are coerced rather than collapsed into "Unknown error": the SDK extracts a message, a type, and any useful own properties (`Error.cause` chains up to 5 levels deep, `code`/`status`/`response` fields, and on Node, `AggregateError.errors`).

The Swift SDK emits a single event named `$error` instead, with the same substance in namespaced properties: `error.type`, `error.message`, `error.fingerprint`, `error.handled` (a boolean distinguishing manual capture from an uncaught exception), `error.stack`, `error.tags`, `error.context`, and `error.breadcrumbs`.

## What you capture yourself

The global handlers see only what escapes. The moment you catch an error — a `try/catch`, a rejected promise you handle, a React error boundary — it is handled, and reporting it is your call:

```
try {
  await checkout(cart);
} catch (err) {
  Crossdeck.captureError(err, {
    tags: { flow: "checkout" },
    context: { plan: "pro" },
  });
  showRetryUI();
}
```

`captureError` accepts any thrown value, not just `Error` instances, and never throws itself — a failure inside the SDK's reporting path is swallowed rather than crashing your error handler. The optional second argument merges per-call `tags` and `context` on top of the globally registered ones, and on web, Node, and React Native accepts a `level` override (default `error`). The Swift signature is `captureError(_ error: Error, handled: Bool = true)`.

For signals that are not exceptions — a deprecated code path was hit, a fallback engaged — use `captureMessage`:

```
Crossdeck.captureMessage("Legacy v1 payments path hit", "warning");
```

It defaults to level `info`, ships as `error.message` with no stack, and fingerprints on the message text so repeated calls group. On Swift, `captureMessage` wraps the string in a synthetic `CrossdeckError` (code `captured_message`, `handled: true`) and additionally drops a breadcrumb carrying the message, so a later crash has it in its trail.

Each SDK fails the way its language expects.

The capture surface itself never throws, on any platform. But the SDKs around it follow their language's idiom: the TypeScript SDKs surface a typed `CrossdeckError` from APIs with real failure modes, while the Swift SDK drops invalid input with a debug-channel log rather than crashing a shipped app — and logs through `debugLogger` when consent denies an error event. Don't expect an exception from a denied capture; watch the debug channel instead.

## Tell Crossdeck when a function fails

Crossdeck no longer ranks errors by how loud they are. It organises around **what a user was trying to do** — the *function attempt* — and whether it completed. An error that broke no attempt is a symptom with no disease: it stays captured and searchable, but it never pages you. An error captured *during a failed attempt* is what surfaces, and what can wake you up.

Two kinds of outcome Crossdeck sees for free, with no code from you: a **charge** on your money rail (it knows the payment succeeded or failed), and an **identity** event your integration already sends (`identify()` / sign-in). For everything else — publish, save, send, connect-an-integration — Crossdeck can see the attempt begin (the click, the request) but can't always see how it ended. Report the outcome on the one line that already knows:

```
try {
  await publishPage();
  Crossdeck.track("publish", { outcome: "succeeded" });
} catch (err) {
  Crossdeck.track("publish", { outcome: "failed", reasonCode: "PUBLISH_API_REJECTED" });
}
```

That's it — one line at the branch that already knows the result. The SDK auto-stamps the identity and session; the error you capture separately is associated as the evidence. A `failed` outcome makes the attempt a **confirmed failure** — authoritative, real at a single occurrence — so a paying customer's broken publish surfaces on the errors tab, on their journey at the point it stopped them, and in one facts-led alert. This is not "wrap everything": it's one line on the short list of make-or-break functions whose outcome doesn't fire naturally.

Send a reasonCode , never the raw error message.

Pass a short, controlled code you choose — `PUBLISH_API_REJECTED`, `QUOTA_EXCEEDED` — not `err.message`. A raw message can carry tokens, IDs, or a customer's own data; a `reasonCode` is a clean enum that groups cleanly and leaks nothing. The separately-captured, sanitised error already holds the technical detail as evidence.

## Levels

Every report carries one of three levels: `error`, `warning`, or `info`. Automatic captures are `error` (with the React Native non-fatal exception noted above), `captureError` defaults to `error`, and `captureMessage` defaults to `info`.

Migrating from Sentry? Your levels keep working.

Sentry's enum has five values; Crossdeck's has three. Rather than rejecting events that say `fatal`, `debug`, or `log`, the ingestion API silently coerces any unrecognised level to `error` and accepts the event. The coercion is recorded as a soft warning in the ingest response, never a 400 — one event with an odd level can't take down a batch.

## Context that rides along

A stack trace tells you where; context tells you who and what. Four kinds of context attach to every report, all read at the moment the error fires.

### Tags

Flat string key/value pairs, surfaced as search facets in the dashboard:

```
Crossdeck.setTag("flow", "checkout");
Crossdeck.setTags({ ab_variant: "b", region: "eu" });
```

On web, Node, and React Native, `setTags` merges into the existing tag bag. On Swift, `setTags` replaces the whole map atomically — pass `[:]` to clear. The server caps tags at 32 keys per event; entries beyond the cap are dropped with a soft warning, not a rejection.

### Context blocks

Named bags of structured data, where tags would be too flat:

```
Crossdeck.setContext("cart", { items: 3, total: 42.99 });
```

On Swift the values are string-to-string dictionaries, and passing an empty dictionary removes the block.

### Breadcrumbs

A rolling ring buffer of the last **50** things that happened — page views, clicks, HTTP requests, custom markers — snapshotted onto each error at the moment it fires. Old entries evict as new ones arrive. The buffer fills automatically from the same auto-tracking sources as analytics (which already skip password fields and `cd-noTrack` subtrees), and the fetch wrap adds an `http` crumb for every outbound request. Add your own for domain-meaningful moments:

```
Crossdeck.addBreadcrumb({
  timestamp: Date.now(),
  category: "custom",
  message: "User opened paywall",
});
```

The capacity is 50 by default everywhere and configurable on Node (`breadcrumbsMaxSize`) and Swift (`breadcrumbCapacity`). One omission by design: the SDK's own requests to the Crossdeck API never become breadcrumbs — a crumb trail full of `POST /v1/events` entries would be noise about the SDK, not your app. The buffer clears on `reset()` and `forget()`.

### Identity

Errors attach to whatever identity the SDK holds when they fire. Before [identify()](https://cross-deck.com/docs/identify-users/index.html), that is the anonymous ID — the error still lands, grouped and searchable, but attributed to an anonymous visitor. After `identify()`, reports carry your user ID and resolve to the customer record, which is what lets the dashboard answer "is the user hitting this error a paying customer". If errors during your login flow matter to you, call `identify()` as early in the session as you can.

## Control and privacy

### The beforeSend hook

Your last word on every report. The hook receives the fully built error — message, frames, breadcrumbs, tags, context — and returns a modified report to send, or `null` to drop it:

```
Crossdeck.setErrorBeforeSend((err) => {
  if (err.message.includes("Known harmless")) return null; // drop
  err.message = err.message.replace(/token=\S+/, "token=[redacted]");
  return err;
});
```

The hook takes effect the instant it is installed — including for errors captured after `init()` but before your setup code ran — and can be replaced or removed (pass `null`) at any time. On Swift, seed it at startup with `CrossdeckOptions.beforeSendError` or rotate it later with `setErrorBeforeSend`; returning `nil` drops the event. One asymmetry to know: on web and Node, a `beforeSend` that throws does not lose the report — the SDK falls back to sending the original, on the principle that a buggy hook should not silently swallow your error stream.

### Consent

Error capture is gated by its own consent dimension, `errors`, independent of `analytics` — you can run crash reporting with analytics off, or the reverse:

```
Crossdeck.consent({ analytics: false, errors: true });
```

The check runs at capture time on web, React Native, and Swift, so flipping consent takes effect on the very next error; while `consent.errors` is denied, nothing is captured or queued. The Node SDK has no consent manager — on a server, the operator's decision to install the SDK is the consent model, and error capture is controlled through the `errorCapture` config instead (set it to `false` to disable, in CI for example).

### PII scrubbing

On by default on every platform (`scrubPii: false` opts out). Before any event leaves the device, string values are scanned for email-shaped and card-number-shaped substrings and replaced with the sentinels ` ` and ` `. The scrub is recursive on the TypeScript SDKs — nested objects and arrays inside frames, breadcrumbs, context, and HTTP payloads are walked, so `{ user: { email: … } }` does not slip through. On Swift, the error message, stack symbols, and every breadcrumb message and data value pass through the same scrubber. The sentinel tokens match the backend's defence-in-depth scrub, so the same error arriving via either path aggregates as one. The scrubber is pattern-based and deliberately narrow; app-specific secrets — auth tokens in URLs, internal IDs — are what `beforeSend` is for.

## Built-in protections

An error tracker that can flood is an error tracker you turn off. These limits ship in the defaults; all of the configurable ones can be tuned at init.

- **5 reports per fingerprint per minute.** A runaway loop — an error inside `setInterval`, a failing per-request middleware — produces at most 5 events a minute for that fingerprint, not thousands.

- **100 reports per session.** A hard cap regardless of fingerprint, after which the SDK stops reporting until the next session (web, React Native) or process restart (Node). The dashboard sees one unique error, not a million rows.

- **Deterministic sampling.** `sampleRate` (default 1.0) samples per fingerprint, not per event — a given install either always sends a given error or never does, so counts stay interpretable.

- **Self-skip.** Requests to the SDK's own backend host (derived from your configured `baseUrl`, exact-hostname match) never trigger HTTP capture or breadcrumbs. Without this, a Crossdeck-side outage would recurse: capture the failure, queue it, fail to send it, capture that failure. The Swift SDK applies the same skip to manually captured `URLError`s targeting the ingest host.

- **Noise filters.** Web drops errors originating from browser-extension URLs (`chrome-extension://` and friends) by default — you cannot ship a fix for someone else's extension — and ignores known browser boilerplate like ResizeObserver loop warnings. React Native ignores Hermes's unactionable promise-rejection wrapper message. All of these lists (`ignoreErrors`, plus `allowUrls`/`denyUrls` on web and `allowPaths`/`denyPaths` on Node) are configurable.

- **The handler never throws.** Every capture path is wrapped; a recursion guard stops the SDK from reporting its own reporting. If Crossdeck's error code fails, your app does not notice.

## Related

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/index.html) — full API surface, init options, and the error-capture configuration block.

- [Node SDK reference](https://cross-deck.com/docs/node-sdk/index.html) — server-side setup, per-request identity, and `errorCapture` config.

- [Swift SDK reference](https://cross-deck.com/docs/swift-sdk/index.html) — iOS setup, `captureUncaughtExceptions`, and crash-reporter coexistence.

- [Source maps](https://cross-deck.com/docs/source-maps/index.html) — upload maps so minified web stack traces resolve to your original source.

- [Error codes](https://cross-deck.com/docs/web-sdk/error-codes/index.html) — the typed errors the SDK itself can return, distinct from the errors it captures.
