Crossdeck Docs
Dashboard

What no SDK can capture

SDKs 8 min read · The boundaries, stated plainly

Error capture has hard edges, and they are mostly not ours to move: the browser's security model, the operating system's process model, and the simple fact that a handler cannot observe an error that fired before the handler existed. This page is the complete list of those edges as they apply to Crossdeck — what the SDK cannot see, why each limit exists, and the mitigation where one exists. A few limits have no mitigation, for us or for anyone; we say so rather than imply otherwise.

The boundaries at a glance

BoundaryWhy it existsWhat you can do
Errors before init()No handler is listening yetInitialize as early as possible
Caught exceptionsYour catch handled them — they never escapeForward with captureError()
Cross-origin script detailsThe browser redacts them for securitycrossorigin="anonymous" + CORS header
Reports blocked by CSPYour policy blocks the request before it leavesAllowlist api.cross-deck.com in connect-src
Readable minified stacksBrowsers run the code you shipped, not your sourceUpload source maps per release
Identity on anonymous errorsidentify() hadn't run yet — attribution, not captureCall identify() at sign-in
Some process-death classesThe OS kills the process without running another instructionPlatform-specific; see below
Events in flight at device deathA device that never comes back can't deliver its queueDurable queues bound the loss; money never rides this path

1 · Errors before the SDK initializes

The SDK's error hooks — the global error listener, the unhandled-rejection listener, the fetch and XHR wraps — are installed when Crossdeck.init() runs. An error thrown before that moment had no handler listening, and no platform replays past errors for handlers registered later. This is a platform fact, and it applies to every error-capture product equally: there is always a blind window between the first instruction of your page or process and the instruction that installs the hooks.

What you control is the size of that window. init() is synchronous and returns immediately, so there is no cost to calling it first: in the <head> for plain HTML, at the top of your provider tree or entry module for bundled apps, before your server registers routes in Node. Initializing early doesn't eliminate the window — nothing can — but it shrinks it to the few milliseconds it takes your bundle to reach the call.

2 · Caught-and-swallowed exceptions

Automatic capture sees errors that escape — the ones that reach the global handler because nothing in your code dealt with them. An exception your own try/catch handles is invisible by definition: the platform delivered it to your code, your code consumed it, and from the runtime's point of view nothing went wrong. No SDK can see inside your catch blocks, and it would be alarming if one could.

The bridge is one call: pass the error along when you want it on the dashboard anyway.

js
try {
  await applyDiscount(cart);
} catch (err) {
  Crossdeck.captureError(err);   // captured as error.handled
  showFallbackPricing(cart);
}

Framework error boundaries are catch blocks too. A React or Vue render crash that your boundary catches never reaches the global handler — forward it from componentDidCatch (or Vue's errorCaptured) with the same call. The manual API exists on every platform: captureError() on Web, Node, and React Native, and captureError(_:handled:) on Swift. See Capture an error for the full API.

3 · Cross-origin scripts: "Script error", blank frames

When a script served from a different origin throws, and that script was loaded without crossorigin="anonymous" and a CORS header, the browser redacts the error before any handler sees it: the message becomes "Script error.", the error object is null, and there is no file, line, or stack. This is the browser's security model working as designed — error details can leak information about cross-origin responses, so the browser withholds them unless the script's origin has explicitly opted in.

Crossdeck does not silently drop these. The SDK recognizes the redaction signature, captures the event with an explicit label — "Cross-origin script error (browser hid details — script needs crossorigin attribute + CORS headers)" — and tags it cross_origin: true so the dashboard groups them as one issue with one known fix. They share a single group deliberately, because a single change fixes them all:

html
<script src="https://cdn.example.com/widget.js" crossorigin="anonymous"></script>
<!-- and on cdn.example.com: Access-Control-Allow-Origin: * (or your origin) -->

Until both halves are in place, the frames stay blank. That is the browser withholding the details, not the SDK losing them — and if the third-party origin is not yours to configure, the details are simply not obtainable, by any vendor. Apps that would rather mute these entirely can add "Script error" back to ignoreErrors in their init config.

4 · Strict CSP without a connect-src entry

If your site ships a Content-Security-Policy whose connect-src does not include Crossdeck's reporting endpoint, the browser blocks every outgoing report before it leaves the page. Nothing arrives — including the report that would have told us something is wrong. From the dashboard this is indistinguishable from silence, which is exactly why it is on this page.

The one place the failure is visible is your own browser console: the browser logs a CSP violation for each blocked request. The fix is one directive:

http
Content-Security-Policy: connect-src 'self' https://api.cross-deck.com
No SDK can report through a policy that blocks it.

CSP is enforced by the browser, below any JavaScript. There is no workaround on our side and there shouldn't be — your policy is the authority. If errors stop arriving from a deployment that just tightened its CSP, check the console for connect-src violations first.

5 · Minified stacks without uploaded source maps

This boundary is about readability, not capture. Production JavaScript is minified; the browser runs — and reports — the code you shipped. So the stack arrives intact but reads as a.b.c is not a function at bundle.min.js:1:48217. The original names exist only in the source maps your build produced, and Crossdeck never asks you to publish those — you upload them privately, keyed to the release:

shell
npx @cross-deck/cli upload-sourcemaps \
  --release "$GIT_SHA" \
  --url-prefix https://app.example.com/assets/ \
  ./dist

The error is captured either way; the upload is what buys readable frames. Until maps for a release are uploaded, that release's stacks stay minified, and the dashboard shows a card naming the missing release rather than leaving you to guess. Wiring this into CI — so every deploy uploads its maps automatically — is covered in Source maps.

6 · Anonymous errors: attribution, not capture

An error that fires before identify() has run for that visitor is captured in full — stack, breadcrumbs, context — but it carries no developer identity. The dashboard shows it as "Developer ID: Not linked". Nothing failed: anonymity is an absence, not an error, so there is nothing for the SDK to flag and nothing for the dashboard to warn about. The SDK cannot attribute an error to a user it hasn't been told about.

The mitigation is to call identify(userId) as soon as you know who the user is — at sign-in, at session restore — and every error after that attaches to them. Errors from genuinely anonymous visitors (pre-signup traffic, logged-out sessions) will always be anonymous, and that is the correct record of what happened.

7 · Process death, by platform

When the operating system kills a process outright, no instruction runs after the kill — including ours. What varies by platform is which death classes give the SDK a final moment, and what survives for the next launch. The scopes below follow each SDK's actual hooks, not a generic claim.

Web

The web SDK hooks window's error and unhandled-rejection events and wraps fetch and XMLHttpRequest. None of those fire when the tab itself dies: a tab killed by the user, a tab discarded under memory pressure, or a browser crash stops JavaScript mid-instruction with no callback. No browser exposes a hook for its own death, so no web SDK can capture it. What does survive: every event already enqueued is persisted to localStorage on enqueue, so errors captured before the death deliver on the next visit.

Node

The Node SDK hooks process.on('uncaughtException') and process.on('unhandledRejection'), so crashes that surface as JavaScript errors are captured. SIGKILL and the kernel's OOM-killer are different: they terminate the process without running another line of JavaScript — no process on any runtime can observe its own SIGKILL. And because the Node queue is in-memory, events buffered at that moment die with the process. The window is small — the queue flushes every 1.5 seconds by default — and a graceful shutdown (await server.shutdown()) drains it to zero. The full bounds are in Event delivery & durability.

Swift (iOS / macOS / watchOS)

The Swift SDK does more here than the generic "SDKs can't capture crashes" claim suggests, and less than a dedicated crash reporter — both halves stated precisely. It does capture uncaught Objective-C exceptions (NSException) via NSSetUncaughtExceptionHandler: the handler snapshots the exception and stack in the few hundred milliseconds before the OS kills the app, writes the event into the disk-persisted queue, and chains to whatever crash reporter you registered first — Crashlytics, Sentry, or Bugsnag still get their crash. The write races process death, so it is best-effort — but because the queue persists to disk and rehydrates on launch, a snapshot that lands delivers on the next launch even when no network send completes before the kill.

It does not capture signal-class deaths: Swift runtime traps (force-unwrapping nil, out-of-bounds, fatalError) raise signals rather than NSExceptions, and the SDK installs no signal or Mach exception handlers. SIGSEGV, SIGABRT, watchdog terminations, and jetsam (OOM) kills fall outside its scope. If you need signal-level crash reporting, run a dedicated crash reporter alongside Crossdeck — the handler chaining above exists precisely so the two coexist without either losing a crash.

React Native

The React Native SDK hooks ErrorUtils.setGlobalHandler, which covers uncaught JavaScript errors — fatal and non-fatal — and chains the prior handler. Captured-but-unsent errors persist in AsyncStorage and deliver after a restart. What it cannot see is a crash in the native layer: the SDK is JavaScript-only, and a crash in iOS or Android native code never reaches the JS runtime. As with Swift, a native crash reporter covers that layer and coexists cleanly.

8 · Events in flight when the device dies

The last boundary is delivery itself. On Web, React Native, and Swift the queue is persisted on every enqueue, so a crash, force-quit, or power loss does not lose captured events — they deliver on the next launch. The genuine loss cases are the ones no queue can survive: a device that never comes back, a browser profile whose storage is cleared before the next visit, a visitor who never returns. The bounds are concrete — each device holds its most recent ~1,000 events, oldest evicted first — and they are documented as numbers, not adjectives, in Event delivery & durability.

None of this can lose money.

Payments, subscriptions, and entitlements never travel through the SDK queue. Money moves on server-side rails — Stripe, Apple, and Google report directly to Crossdeck's backend via signed webhooks, with their own retries, idempotency keys, and reconciliation sweeps. A dead device, a cleared queue, or an uninstalled app cannot lose a payment record or flip an entitlement. Analytics is durable best-effort with honest bounds; money is multiply-guarded. The two never share a failure mode.