Crossdeck Docs
Dashboard

Track custom events — turn raw signals into business decisions

Guide Applies to @cross-deck/web and @cross-deck/node · ~15 min read · Updated May 15, 2026

Auto-capture gives you page.viewed, session.started, element.clicked, Web Vitals, and fetch.completed / xhr.completed network timing for free. Those answer "is anyone there?" The dashboard becomes genuinely useful — funnels, cohorts, retention curves, revenue attribution — the moment you start firing the dozen domain events that describe your business: signup, trial conversion, paywall, churn signal. This guide is the prescriptive spec for those events: the track() contract, the canonical taxonomy, naming, properties, and the failure modes you need to avoid.

TL;DR

The track() contract

Signature

function track(name: string, properties?: Record<string, unknown>): void;

Synchronous. Returns void. The HTTP round-trip happens later, on the next batched flush. The function never returns a Promise — there is nothing to await.

// Correct — fire and forget
Crossdeck.track("trial_started", { plan: "pro", trial_length_days: 14 });

// Incorrect — track() is not async; the await does nothing and
// turns a non-blocking call into a misleading no-op.
await Crossdeck.track("trial_started", { plan: "pro" });

Durable queue

Events land in an in-memory queue, get persisted to localStorage under crossdeck:queue.v1 on every enqueue, and flush in batches. A terminal flush fires on pagehide / beforeunload / visibilitychange→hidden with keepalive: true so the final batch lands even when the tab is being torn down. Failed flushes retry with exponential backoff, every batch carries an Idempotency-Key header, and the queue survives crashes and reloads. You don't have to think about delivery.

What happens if called before init()

It throws. The web SDK's track() calls requireStarted() which raises a CrossdeckError with code not_initialized if Crossdeck.init({ appId, publicKey, environment }) hasn't run yet. There is no pre-init buffer — the SDK doesn't queue events for a hypothetical future init().

Wrap your first event behind init.

In React, the standard pattern is to call Crossdeck.init() inside a useEffect in your CrossdeckProvider on mount. Any component below that provider can safely call Crossdeck.track() from its own effects. If you fire a track() in the same module-load tick as the provider mounts, you'll race init — guard it with a flag or fire from a useEffect instead.

What track() guarantees

Canonical event taxonomy

This is the Crossdeck-recommended taxonomy for any SaaS-shaped product. Adopting it means the dashboard's pre-built funnels (signup → activation, trial → conversion, paywall → purchase, churn signal → save), cohorts, and retention reports work out of the box. Diverge with intent, not by accident — every name and property below maps to a dashboard surface.

Required properties must be sent on every fire. Optional properties are recommended where applicable; missing them won't break anything, but the funnels lose resolution.

Coming from Segment / Mixpanel / Amplitude?

Most teams arrive with an existing taxonomy. The shortest path: keep your existing event firing where it is and rename to Crossdeck's snake_case equivalent — Trial Startedtrial_started, Purchase Completedpurchase_completed, Checkout Startedcheckout_started. (Pageviews are already auto-captured as page.viewed — don't remap your own event onto that reserved name.) A side-by-side rename guide (Segment → Crossdeck, Mixpanel → Crossdeck, Amplitude → Crossdeck) is planned.

Lifecycle & activation

EventWhen to fireRequired propertiesOptional properties
signup_started User submits the signup form (before account creation succeeds). source — UTM campaign name, or "organic" when no UTM is present. landing_page — pathname of the page they landed on first.
signup_completed Account creation succeeds and the user has a verified identity in your auth provider. method — one of "email", "google", "apple", "github". signup_duration_ms — wall-clock ms from signup_started to here.
email_verified User clicks the email-verification link and the backend confirms. — (use identify()'s email trait, not a property)
onboarding_step_completed Each step in your onboarding flow finishes (named questions, picker screens, profile setup). step_name, step_index (number). time_in_step_ms — ms spent on the step.
onboarding_completed User exits the onboarding flow successfully — the "activation" anchor.

Trial & conversion

EventWhen to fireRequired propertiesOptional properties
trial_started Backend creates a trial subscription (Stripe checkout success with trial, or equivalent). plan, trial_length_days (number). source — the UTM campaign or referrer that drove trial start.
trial_converted Trial transitions to a paid subscription (first paid invoice). plan, mrr (number, USD cents preferred for consistency). original_trial_source — preserved from trial_started.source.
trial_expired Trial ends without conversion to paid. plan, converted (always false).

Paywall & purchase

EventWhen to fireRequired propertiesOptional properties
paywall_viewed Paywall modal / page renders and becomes visible to the user. paywall_name, plans_shown (array of plan slugs), triggered_by — the feature or limit that surfaced it.
paywall_dismissed User closes the paywall without converting. paywall_name, dismissed_reason — one of "close_button", "backdrop_click", "escape_key", "navigated_away".
purchase_started User clicks "Buy" / "Subscribe" and a checkout session is created. plan, mrr, paywall_name.
purchase_completed Payment confirmed and entitlement granted. Fire server-side from a Stripe webhook handler — see Server-side track(). plan, mrr, payment_method — one of "card", "apple_pay", "google_pay", "sepa", etc.
purchase_failed Checkout returns an error or the subsequent payment intent fails. plan, failure_code — Stripe decline code or your own taxonomy, failure_message.

Plan changes & churn

EventWhen to fireRequired propertiesOptional properties
plan_upgraded Subscription moves to a higher-tier plan. from_plan, to_plan, mrr_delta (positive number).
plan_downgraded Subscription moves to a lower-tier plan (still paid). from_plan, to_plan, mrr_delta (negative number).
subscription_cancelled User schedules a cancellation or it cancels immediately. plan, cancel_at_period_end (boolean). cancellation_reason — pick from a small enum your save-flow surfaces.
churn_signal You detect a soft signal that a user is likely to churn (decreased usage, billing failure, support escalation). signal_type — short slug describing the signal, severity — one of "low", "medium", "high".

Engagement & quality

EventWhen to fireRequired propertiesOptional properties
feature_used User completes a meaningful interaction with a named feature. Fire once per session per feature unless usage frequency is itself the metric. feature_name, feature_category.
error_recovered An error surfaced to the user, they took a recovery action, and it succeeded. error_code, recovery_action — what the user did (e.g. "retry", "sign_in_again").
support_contacted User initiates a support conversation (chat opened, ticket submitted, email sent). channel"chat" / "email" / "phone", topic.
Treat this taxonomy as the default, not a buffet.

The dashboard's funnels, cohorts, and revenue widgets are built against these exact names. You're free to add domain events on top (document_exported, collab_invited, etc.) but the lifecycle / trial / paywall / churn events above should fire exactly as named. If you have a strong reason to diverge, document the mapping in your team's analytics README so the dashboard editors aren't guessing.

Event naming conventions

Crossdeck uses snake_case with underscores between words and a past-tense verb. Pick one casing style for your whole codebase and never mix.

You may group related events under a dotted namespacebilling.invoice_paid, dogfood.contract_failed — as long as the prefix isn't one of the reserved system prefixes listed below. The snake_case + past-tense rule still applies to each segment; the dot just buys you a namespace. Crossdeck's own dogfood project uses exactly this pattern (dogfood.contract_failed).

RuleExampleBad example
Lowercase only.trial_startedTrialStarted, TRIAL_STARTED
Underscores between words.paywall_viewedpaywallviewed, paywall-viewed
Past-tense verb at the end.plan_upgradedupgrade_plan, upgrading
Noun first, verb last.onboarding_completedcompleted_onboarding
One event = one name. Encode variation as a property.feature_used with feature_name: "export"export_feature_used, import_feature_used, share_feature_used (each as separate names)

Reserved prefixes — never use these for custom events

The SDK's auto-tracker owns the following name prefixes. The dispatcher inside track() applies different consent gates and breadcrumb categories depending on the prefix — colliding with them silently changes the routing of your event and corrupts auto-capture funnels.

Reserved prefixOwned byExamples
page.Auto-tracker page-view emitter.page.viewed
session.Auto-tracker session emitter.session.started, session.ended
element.Auto-tracker click autocapture.element.clicked
error.Error-capture pillar — gated on consent.errors, not consent.analytics.error.unhandled, error.unhandledrejection (auto-fired); error.handled, error.http, error.message (via captureError() / captureMessage())
webvitals.Web Vitals tracker — also gated on consent.errors.webvitals.lcp, webvitals.inp, webvitals.cls
user.Identity lifecycle (Crossdeck.reset() emits user.signed_out).user.signed_out
sdk.SDK-internal signals (web: debug-mode console only; server: sdk.boot telemetry).sdk.configured, sdk.boot
cd.Reserved for Crossdeck-internal events. Filtered out of your custom-event feed, breakdowns and alerts.cd.*
Don't manually fire page.viewed, session.started, or error.*.

The auto-tracker emits these. Firing them manually doubles every count and corrupts every funnel that joins on them. If you've turned auto-capture off via the autoTrack init option, you still shouldn't reuse the reserved names — emit a custom equivalent under your own namespace.

Property design

Allowed value types

What gets coerced or dropped

The validator at the SDK boundary (validateEventProperties, see below) accepts almost anything and rewrites it into JSON-safe shapes. You don't have to pre-format your data, but knowing the coercions lets you write properties in the shape the dashboard will actually receive:

You passWire seesWarning emitted
new Date()ISO 8601 stringcoerced_date
123n (BigInt)String "123"coerced_bigint
new Error("oops"){ name, message, stack }coerced_error
new Map([["a", 1]]){ a: 1 }coerced_map
new Set(["a"])["a"]coerced_set
String > 1024 charsTruncated with suffixtruncated_string
FunctionDroppeddropped_function
undefinedDroppeddropped_undefined
Circular object"[circular]"circular_reference
Object nested > 5 levels deep"[depth-exceeded]"depth_exceeded
Properties totalling > 8 KBLargest fields dropped, __truncated: true appendedsize_cap_exceeded

Property count

No hard cap on property count exists in the validator — the limit is the 8 KB total-bag size. Keep events to ≤30 properties as a recommendation. Above that, you're almost certainly modelling state that should live on the user (via identify() traits) or as a super property, not on every event.

Property naming

What gets PII-scrubbed automatically

Right before the event hits the queue, every string property value (and every string inside a top-level array) is run through scrubPii:

This is defensive scrubbing, not the primary defence. It catches the common case where an email or PAN ended up inside a URL (/users/[email protected]/) or an auto-attached referrer. Don't rely on it to redact intentional PII — don't pass PII as property values. Use Crossdeck.identify(userId, { traits: { email } }) to attach PII to the user record, where access controls and right-to-be-forgotten apply.

To disable the scrub (for pipelines that do their own redaction):

Crossdeck.init({
  appId: "app_web_xxx",
  publicKey: "cd_pub_test_…",
  environment: "sandbox",
  scrubPii: false,
});

Validation at the SDK boundary

The validator in event-validation.ts is a pure function — no I/O, no console — and runs synchronously inside every track() call before the event reaches the queue. It exists for one reason: one bad property must never be able to crash JSON.stringify at flush time and poison the entire batch indefinitely.

What the validator does

What the validator does not reject

The validator only sanitises property values. The event name is checked in track() itself with a single guard: if (!name) throws missing_event_name synchronously. There is no length cap on names enforced in code today, and no type check beyond the empty / truthy gate. Treat the 64-character soft cap as a convention — the dashboard tab listings start to wrap above that — but the SDK itself won't stop you.

track("") throws synchronously.

Empty or missing event names raise CrossdeckError with code missing_event_name. This is intentional: a silently-dropped untracked event is worse than a loud failure. Wrap in try/catch at the call site if you're constructing names dynamically and there's any chance of an empty string slipping through.

How to see validation warnings

Set debug: true in init() (or call Crossdeck.setDebugMode(true) at runtime) to surface every coercion / drop as a console.info line tagged sdk.property_coerced. The same hook also emits sdk.sensitive_property_warning for PII-shaped property names. In production, leave debug off and trust the validator — every coercion is benign by design.

Super properties

Super properties are key/value pairs you register once and the SDK auto-attaches to every subsequent event of that SDK instance. They persist across reloads via localStorage and clear automatically on Crossdeck.reset() (logout).

// Set once, typically right after identify().
Crossdeck.register({
  plan: "pro",
  app_version: "4.12.0",
  org_id: "org_acme",
  cohort: "2026_q2_launch",
});

// Every subsequent event automatically includes plan / app_version / org_id / cohort:
Crossdeck.track("paywall_viewed", { paywall_name: "upgrade_modal" });
// → wire payload includes plan, app_version, org_id, cohort, paywall_name, plus all auto-enrichment.

Right tool for super properties

Wrong tool for super properties

API surface

MethodBehaviour
Crossdeck.register({ key: value })Merges keys into the bag. value: null deletes the key (explicit "stop tracking").
Crossdeck.unregister("key")Removes one key. Idempotent.
Crossdeck.reset()Clears the entire bag (called on logout). Groups clear too.

On every track(), super properties merge after auto-enrichment (device, session, acquisition) and before caller-supplied properties. So:

Sampling

Sampling isn't exposed in the SDK yet — every track() call ships.

There is no sample-rate option in @cross-deck/web today: every event you track() is delivered, subject only to consent and the auto-scrub. Sampling is on the roadmap for very high-volume projects (roughly 5M+ events/month), where trading fidelity on high-frequency autocapture for cost starts to make sense. Below that, ship everything — which is the default and needs no configuration.

When sampling lands, conversions, errors, page views, sessions, and Web Vitals will stay at 100% by design — a sampled funnel with missing conversions is worse than no funnel — and only high-frequency autocapture and high-volume custom events will be eligible. We'll document the exact API here the moment it ships; there is nothing to wire today.

For the per-event price and the included monthly volume, see Pricing. The math worth carrying in your head: events bill as one unit each at every tier — the same number whether you're at 5M or 50M — so sampling is a straight cost lever.

Server-side track()

The Node SDK exposes a parity track() with the same enrichment / validation / breadcrumb pipeline as the web SDK. The signature differs in one way: the server takes a single ServerEvent object so the caller can attach an identity hint per call (the server doesn't have a long-lived per-user instance the way a browser does).

import { CrossdeckServer } from "@cross-deck/node";

const server = new CrossdeckServer({
  appId: "app_srv_xxx",
  secretKey: "cd_sk_test_…",
  environment: "sandbox",
});

server.track({
  name: "purchase_completed",
  developerUserId: "user_847",
  properties: {
    plan: "pro",
    mrr: 1900,
    payment_method: "card",
  },
});

When to fire server-side instead of client

The same canonical taxonomy applies. The dashboard joins client-side and server-side events on identity, so the funnel sees a single timeline regardless of which side emitted each step.

See the Node SDK reference for the full server-side API.

Testing locally

The web SDK auto-detects localhost and short-circuits every API call to a no-op. You'll see this line in your browser console at boot:

[crossdeck] Localhost detected — running in dev mode (no network calls).

This is intentional: it prevents accidentally polluting your sandbox project with developer-machine noise (every refresh = a session, every dev login = an identify). Events you call track() on still go through validation, enrichment, the breadcrumb buffer, and the durable queue — they just never hit the wire.

To test against the real sandbox

To verify a single event landed

  1. Hit a real (non-localhost) URL.
  2. Fire the event from devtools: Crossdeck.track("test_event", { source: "manual" }).
  3. Either wait up to 5 seconds for the batched flush, or call Crossdeck.flush() to force it immediately.
  4. Open the Crossdeck dashboard's Live Events view. Your event appears within seconds.

To enable verbose diagnostics in any environment (including localhost), pass debug: true to init(). The SDK emits a stable vocabulary of signals to console.infosdk.configured, sdk.first_event_sent, sdk.property_coerced, sdk.consent_denied. See the Web SDK Diagnostics section for the full list.

Anti-patterns

The most common ways to make the dashboard less useful, in roughly the order they show up in code reviews.

Tracking on every render

Calling Crossdeck.track() from a React component's render function (or a Vue setup(), or a Svelte reactive statement) fires the event every time the component re-renders. A paywall component that re-renders three times during a single user view emits paywall_viewed three times, and the funnel conversion rate drops by 3× overnight with no real change in user behaviour.

Always fire from an effect / lifecycle hook tied to the meaningful state transition:

function Paywall({ visible, name }) {
  useEffect(() => {
    if (visible) {
      Crossdeck.track("paywall_viewed", { paywall_name: name });
    }
  }, [visible, name]);
  // …render
}

Tracking PII as property values

Don't:

Crossdeck.track("signup_completed", {
  email: user.email,         // PII — flagged in debug, scrubbed on the wire
  phone: user.phone,         // same
  card_last4: card.last4,    // payment data — never
});

Do:

// Identify the user once with traits — PII goes on the user record,
// where access controls and right-to-be-forgotten apply.
Crossdeck.identify(user.id, {
  traits: { email: user.email, phone: user.phone },
});

// The event carries the action, not the identifying data.
Crossdeck.track("signup_completed", { method: "email" });

Using events for state the dashboard should derive

Don't emit plan_changed_to_pro, plan_changed_to_team, plan_changed_to_enterprise as separate events to represent the user's current plan. The dashboard derives "current plan" from the latest plan_upgraded / plan_downgraded event for each user, or — better — from the entitlement state. State belongs on the user record (via identify() traits) or as a super property (via register()); events describe changes to state, not state itself.

High-cardinality identifiers as event names

Don't:

Crossdeck.track(`feature_${featureName}_used`);
// → exports the event names: feature_export_used, feature_import_used,
//   feature_share_used, … one event name per feature you'll ever add.

Do:

Crossdeck.track("feature_used", { feature_name: featureName });

The dashboard treats event names as a low-cardinality enum: they show up in dropdowns, drive autocomplete, and gate access to pre-built funnels. Stamping a high-cardinality value into the name explodes those dropdowns and makes the data un-queryable. Push variation into properties instead.

Awaiting track()

track() is synchronous and returns void. await Crossdeck.track(...) is a no-op that gives the false impression you're waiting for delivery — you aren't. Delivery happens in a background flush you have no reason to block on. The only time you ever need to wait is at page-tear-down, and the SDK already handles that with a pagehide-triggered terminal flush with keepalive: true. If you have a custom unload path, call await Crossdeck.flush({ keepalive: true }) explicitly there — not on every event.

Re-emitting auto-tracked events

Don't fire track("page.viewed") manually after a route change in your SPA. The auto-tracker hooks pushState / replaceState / popstate and emits the event with its own 250 ms dedup window. Manually re-emitting doubles the count and breaks every funnel that joins on pageviewId.

Double-firing the same event from client and server

One of the most expensive bugs in event analytics: the same lifecycle moment ends up firing from both sides. The browser calls Crossdeck.track("purchase_started") on the Buy click; the Stripe webhook handler calls server.track({ name: "purchase_started" }) a few seconds later when checkout.session.completed arrives. Two events per real purchase. Conversion math now reads 2× the truth and every funnel that joins on this event is silently wrong.

The rule: each event has exactly one owner — client OR server, not both. Pick the side where the source of truth lives.

If both sides genuinely need to know, the safer pattern is to fire different events. The client emits purchase_started (intent), the server emits purchase_completed (truth). The funnel joins on identity, not on a single duplicated event.

Firing one event per loop iteration

If you find yourself calling track() inside a for loop or a .map() callback, you're almost certainly conflating "a thing happened to a list" with "this user did N things". Aggregate first:

// Wrong — 47 events for a single user action
selected.forEach(item => Crossdeck.track("item_deleted", { id: item.id }));

// Right — one event, count as a property
Crossdeck.track("items_deleted", {
  count: selected.length,
  source: "bulk_action",
});

Per-item tracking is fine when each item-touch is a separate user decision (one click, one delete confirm). It's wrong when one user action affects N items.

Seeing events in the dashboard

Once your SDK is firing track(), four dashboard surfaces consume that stream. None of them require additional setup — they are scoped to the same app(s) the SDK is sending to and read straight from the event pipeline.

Live event feed

Dashboard → Analytics → Events is a streaming feed of every Crossdeck.track() call as it lands. Auto-refreshes every ~3 seconds; tap any row to inspect the full property payload, anonymous / developer / Crossdeck customer ID, SDK + version, and session anchor.

Counts over time, grouped by any property

Dashboard → Analytics → Breakdown answers "how often did event X fire in the last 24h / 7d / 30d, broken down by any property?"

Behind the scenes the breakdown reads straight from your event stream in Firestore — each event carries its own properties, aggregated at query time, so there's no property schema to declare in advance. Fire Crossdeck.track() with new property keys and they appear in the autocomplete on the next refresh.

Schema discovery

Both the events feed and the breakdown chart read the property schema dynamically from the events themselves. There is no admin step to register properties, no JSON-schema config to maintain, no manual whitelist. The trade-off is that a high-cardinality property (e.g. userId, requestId) shows up in the autocomplete with a cardinality hint — grouping by it will still work but the legend will be dominated by (other).

The dashboard treats any property your SDK emits as a filterable dimension. Practically: design property names with the dashboard in mind (see Property design), and they become first-class analytics knobs the moment they hit the ingest.

Alerts

Dashboard → Analytics → Alerts turns the breakdown engine into a paging surface. Create a rule like:

{
  "name": "Contract failures (Android)",
  "event": "dogfood.contract_failed",
  "propertyFilters": [
    { "key": "sdk_platform", "equals": "android", "valueType": "string" }
  ],
  "threshold": 1,
  "windowSec": 300,
  "cooldownSec": 900,
  "channels": {
    "slack": true,
    "email": { "enabled": true, "recipients": ["[email protected]"] },
    "webhook": { "enabled": true, "url": "https://your.app/crossdeck-alerts" }
  }
}

The evaluator runs every minute. When the rule's event fires threshold times in windowSec seconds, dispatch goes to every enabled channel:

cooldownSec suppresses re-fires for the configured duration so a runaway event doesn't page-storm. The cooldown gate is enforced in a Firestore transaction; two concurrent evaluator runs cannot double-page.

Every fire writes a row to projects/{projectId}/alertEvents, surfaced on the same page as "Recent fires" with the channel-delivery breakdown — you see at a glance whether Slack succeeded but the webhook failed, etc.

Cross-app comparison

The workspace switcher in the sidebar has a Compare toggle in its popover. Turn it on and check additional apps to scope the events feed and breakdown chart across multiple apps in one view — the canonical use case is the dogfood project, where Android / iOS / Web each have their own appId and you want one chart of dogfood.contract_failed across all platforms.

Cross-app scope respects authorisation: the backend membership-checks every app id on every call, so you can never aggregate across an app you're not a member of. The maximum scope per query is 25 apps.

Alerts deliberately stay single-app — a rule belongs to one project so its cooldown / fire history have unambiguous ownership. To page on a cross-app condition, create the same rule on each app.


Last updated against @cross-deck/[email protected] and @cross-deck/[email protected]. The canonical taxonomy is the dashboard's contract — changes to event names or required properties are versioned with the SDK.