Track custom events — turn raw signals into business decisions
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
- Shape.
Crossdeck.track(name, properties?)— synchronous, returnsvoid, fire-and-forget. Neverawaitit. Delivery never throws (it's always backgrounded and safe to ignore);track()throws synchronously in exactly two programmer-error cases — calling beforeinit()(not_initialized) and an empty name (missing_event_name). - When to fire. User crosses a meaningful threshold — signed up, started a trial, hit a paywall, upgraded, churned. Not on every component render or state change.
- Naming.
snake_case, past-tense verb (trial_started, notStartTrial). Reserved prefixes (page.,session.,element.,error.,webvitals.,user.,sdk.,cd.) belong to the SDK — never use them. Your own dotted namespace (billing.invoice_paid) is fine; just not those. - Validation. Property values are sanitised at the SDK boundary (functions dropped, Dates coerced to ISO, circular refs replaced, >1024-char strings truncated, total bag capped at 8 KB). One bad property can never poison the batch.
- Super properties.
Crossdeck.register({ plan: "pro" })auto-attaches to every subsequent event. Right tool for plan tier, org ID, app version, cohort. Wrong tool for per-event data. - PII. Email-shaped and card-number-shaped substrings inside string property values are auto-scrubbed before the event hits the queue. Don't rely on it — design events without PII as values to begin with.
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().
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
- Sync enqueue. The event is in the queue before
track()returns. - Validation. The property bag is sanitised through
validateEventPropertiesbefore enqueue — see Validation at the boundary. - Enrichment. Device info,
sessionId,pageviewId, acquisition (UTMs, referrer, click IDs), super properties, and$groupsmemberships are auto-merged. Caller-supplied properties win on key collision. - PII scrub. Email-shaped and card-number-shaped substrings inside string property values are replaced with
[email]/[card]as the last step before enqueue (unlessscrubPii: falsewas passed toinit()). - Breadcrumb. The event is added to the in-memory breadcrumb buffer so a subsequent error report carries "the last N things the user did" automatically.
- Consent gate. If
Crossdeck.consent({ analytics: false })has been set, the event drops silently — no network call, no exception.
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.
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 Started → trial_started, Purchase Completed → purchase_completed, Checkout Started → checkout_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
| Event | When to fire | Required properties | Optional 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
| Event | When to fire | Required properties | Optional 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
| Event | When to fire | Required properties | Optional 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
| Event | When to fire | Required properties | Optional 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
| Event | When to fire | Required properties | Optional 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. |
— |
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 namespace — billing.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).
| Rule | Example | Bad example |
|---|---|---|
| Lowercase only. | trial_started | TrialStarted, TRIAL_STARTED |
| Underscores between words. | paywall_viewed | paywallviewed, paywall-viewed |
| Past-tense verb at the end. | plan_upgraded | upgrade_plan, upgrading |
| Noun first, verb last. | onboarding_completed | completed_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 prefix | Owned by | Examples |
|---|---|---|
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.* |
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
- string — short, low-cardinality slugs preferred. Free-text titles and URLs are fine, but they're cardinality bombs in cohort breakouts.
- number — integers and finite floats.
NaN,Infinity, and-Infinitybecomenullwith a warning at validation time. - boolean — straight
true/false. - array — flat arrays of primitives are fine (
plans_shown: ["pro", "team"]). Avoid arrays of objects. - nested object — allowed up to depth 5, but don't go beyond one level of nesting in practice. The dashboard's column-pickers flatten the first level; deeper paths require manual SQL.
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 pass | Wire sees | Warning emitted |
|---|---|---|
new Date() | ISO 8601 string | coerced_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 chars | Truncated with … suffix | truncated_string |
| Function | Dropped | dropped_function |
undefined | Dropped | dropped_undefined |
| Circular object | "[circular]" | circular_reference |
| Object nested > 5 levels deep | "[depth-exceeded]" | depth_exceeded |
| Properties totalling > 8 KB | Largest fields dropped, __truncated: true appended | size_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
snake_case, lowercase, matching the event name style.- Don't prefix with
$— that's reserved (Mixpanel-style) for SDK-attached internal fields like$groups. - Don't use property names that look like PII (
email,password,token,secret,card,phone). In debug mode the SDK emits ansdk.sensitive_property_warningsignal when it sees these — that's the SDK's polite cough at a leak in progress. The field isn't stripped (that's your call) but the warning shows up. - For currency values, use cents as integer not
"$19.99"as string. Pick a single currency per app or include an explicitcurrencyproperty.
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:
- Email-shaped substrings (RFC 5322 obs-local-part common case) are replaced with
[email]. - Card-number-shaped substrings (13–19 digits with optional space / hyphen separators) are replaced with
[card].
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
- Walks the property bag depth-first, visiting each key.
- Drops functions, symbols, and
undefinedvalues. - Coerces
Date,BigInt,Error,Map,Setinto JSON-safe shapes (see the coercion table). - Truncates oversized strings (default cap: 1024 chars).
- Replaces circular references with
"[circular]"via aWeakSettracker. - Coerces depth > 5 to
"[depth-exceeded]". - Enforces an 8 KB total bag size, dropping the largest fields first and marking the result with
__truncated: true. - Returns a brand-new object — the caller's input is never mutated.
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
- Plan tier — every event needs to know whether the user is on
free/pro/team/enterprise. - App version — so a regression to one build can be isolated in cohort analysis.
- Org / team / workspace ID — for B2B; combined with
Crossdeck.group()for multi-tenant pivots. - Cohort / experiment bucket — A/B test variant, signup cohort, release channel.
- Feature flag state at session start — if a flag's value won't change mid-session and you need every event tagged with it.
Wrong tool for super properties
- Per-event variation —
paywall_name,step_index,planon a purchase event. Pass those ontrack()directly. - Anything PII — same auto-scrub applies, but a super property leak is amplified across every subsequent event.
- Anything that changes within a single page load — super properties are read at every
track()call but writing them is alocalStorageround-trip; don't churn them.
API surface
| Method | Behaviour |
|---|---|
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:
- You can't accidentally override
sessionIdorutm_sourceviaregister()— the auto-enrichment wins on shared keys. - The per-call
track()properties win over super properties on shared keys (register({ plan: "pro" })thentrack("downgrade", { plan: "free" })shipsplan: "free"for that one event).
Sampling
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
- Stripe / Apple / Google webhook receivers —
purchase_completed,trial_converted,subscription_cancelled. The payment provider is the source of truth; firing client-side races the webhook and a closed tab loses the event entirely. - Cron jobs and scheduled tasks —
trial_expired, daily churn-signal scoring, weekly digest events. - Backend conversion events — anything where the final state change happens in a backend service after the user has already navigated away.
- Sensitive properties you don't want on the client — server-side
track()uses your secret key and never has its property values exposed to the user's devtools.
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
- Vercel / Netlify preview deploys — push a branch, hit the preview URL, events flow into your sandbox project. The fastest path.
- ngrok —
ngrok http 3000, hit the public ngrok URL instead oflocalhost:3000. Useful for testing in a real mobile browser too. - Custom dev hostname — add an entry to
/etc/hostsmappingdev.yourdomain.comto127.0.0.1, run your dev server on port 80, hit the hostname. The localhost detector matches against the bare stringslocalhostand127.0.0.1(plus a few standard variants) — a different hostname bypasses it.
To verify a single event landed
- Hit a real (non-localhost) URL.
- Fire the event from devtools:
Crossdeck.track("test_event", { source: "manual" }). - Either wait up to 5 seconds for the batched flush, or call
Crossdeck.flush()to force it immediately. - 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.info — sdk.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.
- User-visible moments (paywall viewed, modal opened, button clicked, intent captured) — fire from the client. The server doesn't observe these.
- Payment / subscription lifecycle (
purchase_completed,trial_converted,subscription_cancelled) — fire from the server. The rail webhook is canonical; the client can race or lose the event on tab-close. - Mixed cases (
signup_completed— the client knows the moment, the server knows the durable record) — pick one and document it. The team rule that scales: "the side that holds the row writes the event."
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.
- Filter by event name — typing in the search box autocompletes against the actual event names observed in the last 7 days. No prior registration of event names required.
- Filter by property — the property-key autocomplete is driven by the same schema discovery that powers the breakdown chart (see below). Pick a key, then a value (also autocompleted from observed values).
- Pause — pauses the auto-refresh without losing the buffered rows. Useful when triaging a fast-moving event.
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?"
- Pick an event from the autocomplete (24h / 7d / 30d windows; auto-bucketed to 5-min / hourly / daily as appropriate).
- Optionally pick a property to group by. The autocomplete lists every key observed on that event in the chosen window, with its value type and cardinality.
- The chart stacks one series per group value (top-12 by count; the rest collapse into
(other)so the total still ties out). - Deep-linkable:
/dashboard/event-breakdown/?event=dogfood.contract_failedopens directly on that event.
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:
- Slack — uses the workspace's existing Slack integration (no per-rule connection).
- Email — Resend-backed transactional email to a comma-separated recipient list.
- Webhook — HTTPS-only signed POST. Each rule has its own HMAC-SHA256 secret (minted at rule creation, stored in Secret Manager). Verify the
x-crossdeck-signature: sha256=…header against the raw body.
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.
Related
- Identify users — set the user ID and attach traits before firing track() so events join correctly across sessions and devices.
- Web SDK reference — the full
@cross-deck/webAPI including init options, auto-capture config, and the React hooks. - Node SDK reference — server-side
track(), theServerEventshape, and webhook-receiver patterns for purchase / subscription events. - Entitlements — the gating side of the same SDK; the dashboard joins entitlement state with event streams for plan-aware cohorts.
- Error codes — every
CrossdeckErrorcodetrack()can throw (today: justmissing_event_nameandnot_initialized), with retryable flag and resolution. - Events explorer, Breakdown chart, Alerts — the four dashboard surfaces that consume custom-event traffic.
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.