Crossdeck Docs
Dashboard

Capture an error

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

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

SourceWhat it catchesDefault
Global error eventUncaught synchronous exceptions.On
unhandledrejectionPromise rejections nothing awaited or caught.On
fetch() wrapResponses with status ≥ 500, and network failures (status 0). 4xx responses never fire — auth and validation failures are usually expected.On
XMLHttpRequest wrapSame semantics as the fetch wrap, for legacy XHR consumers.On
console.error wrapEach 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

SourceWhat it catchesDefault
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 wrap5xx responses and network failures, Node 18+. Skipped silently when fetch is absent.On
console.error wrapEach 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

SourceWhat it catchesDefault
ErrorUtils.setGlobalHandlerUncaught synchronous errors and unhandled promise rejections — React Native routes both through the same global handler on 0.63+.On
globalThis.fetch wrap5xx 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:

EventEmitted when
error.unhandledAn uncaught exception reached the global handler.
error.unhandledrejectionA promise rejected with nothing to catch it (web and Node; React Native folds these into error.unhandled).
error.handledYour code called captureError().
error.messageYour code called captureMessage(), or a wrapped console.error fired.
error.httpA 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.

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(), 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 <email> and <card>. 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.