Events
What `track()` actually does, the event schema, where events live, and how they get aggregated into metrics.
An event is the raw signal that metrics are computed from. You log it with track(). ShipEasy
collects the event and rolls it up into a metric value during the daily analysis window.
This page is the reference for what track() accepts, what the platform does with it, and what to log (and what not to).
Logging events
// server (Node, edge, anywhere with a user_id)
import { flags } from "@shipeasy/sdk/server";
flags.track(user_id, "purchase", { value: 49.99, sku: "SHIRT-L-BLUE" });
// browser
import { client } from "@shipeasy/sdk/client";
client.track("checkout_viewed", { source: "nav" });track() is fire and forget — it never throws, never blocks, never returns a promise you need to await. If the network is unavailable, the browser SDK queues to memory; if the worker is down, the event is dropped (events store has its own buffering, so this is rarer than you'd think).
The browser SDK batches events and flushes:
- After a small batch fills (default
20events). - On a 5-second timer.
- On
pagehidevianavigator.sendBeaconso unloads don't lose data. - Immediately on
flags.identify()andclient.alias()to make sure stitching has the freshest data.
You can tune the batcher:
configureShipeasy({
apiKey,
batch: { size: 50, intervalMs: 10_000, useBeacon: true },
});Event schema
| Field | Type | Required | Notes |
|---|---|---|---|
event | string | yes | The event name. Use snake_case: purchase, signup_complete. |
user_id | string | one of these | Pass on the server. |
anonymous_id | string | one of these | Auto-set by the browser SDK; rotates on client.reset(). |
timestamp | number | no | Defaults to "now". Pass a unix-ms timestamp to backfill. |
value | number | no | Convenience for sum/mean metrics. Equivalent to properties.value. |
properties | object | no | Any string/number/boolean keys. Flat, ≤4 KB serialised. |
context | object | no | Auto-populated: locale, user agent, page URL, viewport. |
A properties payload should be:
- Flat — no nested objects. Cardinality on nested keys is unindexable.
- Bounded in size (≤ 4 KB serialised). Larger payloads are rejected at the worker.
- Stable in keys — the platform indexes property keys for filtering and segmentation. An unbounded set of keys (e.g. one key per dynamic value) hurts query performance and balloons the schema cache.
Picking event names
Stable, low-cardinality event names. Snake_case. Past tense for things that happened, present continuous for state changes. Good and bad:
| Good | Bad | Why |
|---|---|---|
purchase | User Purchased Thing!! | Casing, punctuation, instability. |
signup_complete | signup-complete | Convention. Pick one and stick to it. |
cart_item_added | cart_item_added_${sku} | Cardinality lives in properties, not the event name. |
page_view | viewed_/products/123 | Same. |
subscription_started | subscriptionStarted | Snake_case, by convention. |
Reserved event names
Some event names are populated automatically and should not be logged manually. They start with $ to make accidental collisions obvious:
| Event | Source |
|---|---|
$exposure | Logged by experiments.assign() the first time a user enters an experiment. |
$page_view | Browser SDK auto-tracks unless disabled. |
$identify | Logged by client.identify() and flags.identify(). |
$alias | Logged when stitching anon → user. |
$session | Browser SDK rolls a new session id every 30 min of inactivity. |
You can opt out of auto-tracking in the SDK options:
configureShipeasy({ apiKey, autoTrack: false });Or selectively:
configureShipeasy({
apiKey,
autoTrack: { pageView: true, webVitals: true, errors: false, fetch: false },
});Retention
Raw events are kept for a window that depends on your plan. The aggregates computed by daily analysis are persisted indefinitely:
| Plan | Raw event retention | Aggregate retention |
|---|---|---|
| Free | 30 days | unlimited |
| Pro | 90 days | unlimited |
| Enterprise | 365 days | unlimited |
If raw events age out, your past metric values are not affected — they were already collapsed into per-(experiment, metric, group, day) rows.
How aggregation works
Once a day, ShipEasy joins each exposure to events that happened after the user was exposed to the experiment (events before exposure don't count — that's what makes the analysis causal). The result is one metric value per (user, group, day) which then feeds the statistical test.
You don't write any of this — define the metric, log the events, read the results. See How analysis works for the full pipeline.
Identity & stitching
Most apps have anonymous users who later log in. The flow is:
// before login: browser SDK auto-assigns an anonymous_id
client.track("page_view");
// at login:
client.identify(user_id); // future events use user_id
client.alias(anonymous_id, user_id); // stitch past anon events to user_idalias tells ShipEasy that two ids are the same person, so the daily analysis attributes events
tagged with anonymous_id = a to user_id = u. An exposure that happened anonymously can still be
matched to a conversion that happens after login — the user who looked at the variant is the same
user who bought.
Pulling raw events
For debugging, the dashboard's Events tab streams the most recent events for the project. You can filter by event name, user, or property.
Programmatically:
shipeasy events tail --event purchase --since 1h
shipeasy events query --metric purchase_conversion --user u_4f2a
shipeasy events query --event purchase --where 'value>100' --since 24hRaw events are not the place to do analytics — they're for "is the wiring correct?" and "what did this one user actually do?" checks. Use metrics for analysis. The platform's aggregates already handle all the joins, the deduplication, and the stats; rebuilding any of that from raw events is reinventing what we already give you.
Costs
Events are billed per million per month. Rough orders of magnitude:
- 10 events/user/day × 100k DAU ≈ 30M events/month → comfortably in Pro.
- 100 events/user/day × 1M DAU ≈ 3B events/month → Enterprise; talk to us.
- Tracking every React render gets you to 10B events/month and a phone call from us.
The browser SDK includes basic web-vitals auto-tracking by default. For high-traffic sites, disable what you don't use:
configureShipeasy({
apiKey,
autoTrack: { pageView: true, webVitals: true, errors: false, fetch: false },
});Sampling
For very high-volume events (page_view on a busy site), sampling at the SDK is cheaper than rejecting at the worker:
client.track("page_view", {}, { sample: 0.1 }); // keep ~10%The platform compensates for the sampling rate when computing metrics — count and sum are scaled by 1/sample. conversion is unaffected (it's binary per user). Sample rates below 0.01 are rejected: at that point you're not measuring anything reliably.
PII rules
Don't put personally identifiable information in event properties. Specifically:
- Never log raw email addresses, phone numbers, full names, addresses, or government IDs as property values. Use a hashed user id instead. The dashboard will refuse to render properties that look like emails.
- Don't put credit card numbers or auth tokens in properties, ever. AE is not PCI-DSS scope and we are not interested in becoming so on your behalf.
- Free-text user input (chat messages, search queries) is fine in aggregate but think twice about per-event logging — it's a regulatory liability and the cost adds up.
If you need to log something sensitive for debugging, log a hash (sha256(value).slice(0, 16)) and keep the mapping table on your own system.
Idempotency
track() is not idempotent — calling it twice logs two events. If a request might retry, gate the call:
const eventId = `purchase:${orderId}`;
if (!alreadyTracked(eventId)) {
flags.track(user_id, "purchase", { value: orderTotal });
markTracked(eventId);
}For the daily-aggregation case this almost never matters — conversion collapses any number of purchase events into a single 1 per user. For count and sum, double-counting matters; for billing-grade event streams, keep your own dedup ledger.
API · flags.track
anonymous_id from the browser SDK.timestamp for backfill, sample for per-call sampling.Read the stats.
You've got events flowing and metrics defined. The last piece is what the daily aggregation does to those numbers — and how to read the result rows without lying to yourself.