The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.
1. Event catalog
An event catalog is an app-defined map from event name to an event definition. The tracking library MUST NOT ship any events of its own — every consuming app builds and passes in its own catalog.
- An event definition carries, at minimum, a schema for that event's properties.
- Event names are the catalog's keys; the tracker accepts only names present in the catalog.
The schema mechanism is implementation-defined (the reference implementation uses Standard Schema), but it MUST provide:
- Synchronous validation. Tracking sits on synchronous call paths; a schema that can only validate asynchronously MUST be rejected with an error at track time — not awaited, not silently skipped.
- A parsed output. Validation returns the value destinations receive. Unknown keys MUST NOT survive validation (either the schema strips them or the implementation forwards only the parsed output), so arbitrary caller data cannot ride along to third-party sinks.
- Failure detail. A validation failure MUST raise an error identifying the event name and the schema's issue messages.
2. Property validation
Given (schema, eventName, rawProperties):
- Run the schema's synchronous validation on
rawProperties. - If the schema reports it validates asynchronously → error: async schemas are unsupported by design.
- If validation reports issues → error carrying
eventNameand the joined issue messages. - Otherwise return the parsed output (transforms applied, unknown keys stripped). Destinations MUST receive this output, never the raw input.
3. The TrackEvent envelope
Every event handed to a destination is one JSON-representable envelope:
{
"type": "track",
"name": "newsletter_signup",
"properties": { "plan": "pro" },
"anonymousId": "6f1c2a4e-9b0d-4c3e-8f5a-1d2e3c4b5a69",
"eventId": "0d9b8a7c-6e5f-4d3c-2b1a-9f8e7d6c5b4a",
"timestamp": 1752796800000
}| Field | Type | Rules |
|---|---|---|
type |
string | Always "track". A discriminant: a future event kind is an additive union member, never a change to this shape. |
name |
string | The catalog key the caller tracked. |
properties |
object | The parsed output of schema validation (section 2). |
anonymousId |
string | The visitor's anonymous id — or the empty string "" when the consent gate is closed (see spec-tracker §3). |
eventId |
string | A freshly generated random UUID (RFC 4122 v4), stamped at track time. Destinations that dedupe MAY key on it. |
timestamp |
number | Milliseconds since the Unix epoch, stamped at track time. |
eventIdMUST be unique pertrack()call — never reused across retries or destinations.- The envelope is immutable once built: every destination in a fan-out receives the same envelope value.
4. Reserved: the context envelope
Status: reserved key, not yet populated. Every comparable system (Segment, RudderStack, PostHog, Amplitude, Mixpanel, Snowplow) auto-attaches a context envelope; this system currently omits one. Reserving the key now — without populating it — avoids a breaking envelope change when the first consumer needs attribution, while honoring the standing "real call site before it ships" rule.
An optional context? key MAY carry ambient attribution data. Its shape is a Segment-subset:
context field |
Contents | Why reserved |
|---|---|---|
page |
url, path, referrer, title, search |
referrer/URL can't be reconstructed by the tag after client-side navigation |
campaign |
source, medium, name, term, content (UTM) |
first-touch attribution is lost after the first navigation if not captured at track time |
library |
name, version |
near-free; invaluable for debugging a misbehaving destination |
locale |
BCP-47 string | cheap, standard |
ip/geo MUST NOT appear in the client envelope — it is consent-sensitive and belongs to the destination's own
server-side derivation.
Consent-first constraint (normative when populated). page.url, page.referrer, and campaign can carry PII and
tracking signal, so any context MUST be subject to the same consent gate as the rest of the envelope and stripped in
the exempt lane — an exempt (identifier-free) envelope carries anonymousId == "" and no context. This is why
the field is specified here rather than copied wholesale from an SDK that ships context unconditionally at init: the
consent-first thesis (spec-tracker §3) applies to ambient context exactly as it applies to the identifier.
Conformance vectors
V1 — parsed output forwarded, unknown keys stripped. Schema: { plan: string }. Input properties
{ "plan": "pro", "injected": "x" } → destinations receive { "plan": "pro" }.
V2 — validation failure. Schema requires plan: string. Input { "plan": 5 } → error whose message contains the
event name and the schema's issue message; no destination receives anything.
V3 — async schema rejected. A schema whose validation returns a deferred/promise value → error stating the event uses an async schema; no destination receives anything.
V4 — unknown event name. track("not_in_catalog", …) → error Unknown event: not_in_catalog; no destination
receives anything.
V5 — envelope shape. A permitted track("signup", {"plan":"pro"}) with anonymous id A produces an envelope where
type == "track", name == "signup", properties == {"plan":"pro"}, anonymousId == A, eventId matches
^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ (case-insensitive), and timestamp is the current
epoch-milliseconds.
V6 — context stripped in the exempt lane (when §4 is populated). A gated event delivered to an exempt destination
carries anonymousId == "" and no context key, even when the same event delivered under an open gate would carry
context.
License
Released under the MIT License.