Events
What an event is, how to send one, and what the server adds.
An event is a single thing that happened, attributed to one visitor at one point in time. Everything in Millimetric is built on events — page views, signups, purchases, AI agent steps, server-side mutations.
If you can describe it as a verb in past tense — signed up, clicked, checked out, retried — it's an event.
The minimum viable event
{ "event": "signup" }That's it. Send that to POST /v1/track with a Bearer key and you have a working analytics setup. Everything else is optional context that makes the data more useful.
The full shape
{
"event": "purchase",
"event_id": "evt_8f3c1...",
"timestamp": "2026-05-16T19:00:00.000Z",
"anonymous_id": "u_abc",
"user_id": "user_42",
"session_id": "sess_xyz",
"url": "https://yoursite.com/checkout/?utm_source=facebook&utm_medium=cpc",
"path": "/checkout/",
"referrer": "https://l.facebook.com/",
"properties": {
"amount_cents": 4900,
"currency": "usd",
"items": 2,
"plan": "pro"
}
}event
yes
Event name. 1–128 chars. Use snake_case.
event_id
no
Idempotency / join key. 1–128 chars.
timestamp
no
ISO 8601. Defaults to server clock at ingest time.
anonymous_id
no
Caller-supplied UUID. Server fabricates one if you don't send it.
user_id
no
Your stable user id, once known.
session_id
no
Custom session boundary. Defaults to ${anonymous_id}-${30min_bucket}.
url
no
Landing URL with query string — the classifier reads utm_*, fbclid, etc. from here.
path
no
Path portion. Browser SDK fills this.
referrer
no
document.referrer or server-side Referer header. The classifier reads this too.
properties
no
Free-form JSON. ≤ 8 KB after stringify.
The full Zod schema lives in packages/schema/src/index.ts.
What the server adds
Every event is enriched server-side before it lands in ClickHouse. You don't need to send any of this — if you do, it gets ignored.
source / medium / campaigncome from the classifier, which readsurl+referrer.country / device_type / browser / oscome from geo-IP and the User-Agent header.ip_hashisHMAC(ip, IP_SALT || UTC_date)— un-reversible and rotates daily. Raw IPs are never persisted.session_idis filled in if you didn't send one.
Event types you'll meet
System events (start with $)
$pageview, $identify
Browser SDK + server, automatically
Product events (your verbs)
signup, purchase, clicked_pricing, feature_used
Your code
Agent events (AI workflows)
agent_task_started, agent_task_completed, tool_called
AI agents via MCP
There's no schema enforcement — pick a naming convention (see Event & property naming) and stick to it.
Sending an event
Browser
Server (Node, Bun, Deno, edge)
Anywhere — plain HTTP
From an AI agent (MCP)
All four shapes hit the same endpoint and produce the same row.
Sending many events at once
For backfills and buffered SDK flushes, use POST /v1/batch. Up to 1000 events per call, all-or-nothing at the request level.
Idempotency
Pass event_id if you want a stable identifier — for joining with rows in your own database, or to safely replay a backfill. Right now the server does not dedupe on event_id (that's on the roadmap), so re-sending the same id will insert two rows. Use it as a join key, not a deduper.
What happens when an event lands
Auth — Bearer token resolved to a project + scope.
pk_*keys also have theirOriginchecked.Validation — Zod parse against
trackEventInput. Bad payloads return400 invalid_payload.Rate limit — token bucket per project per route (50/sec for
track, 5/sec forbatch).Enrichment — classifier runs, IP is hashed, UA is parsed,
session_idis derived if absent.Insert — single-row
JSONEachRowinto ClickHouse via the HTTP interface.Materialised views —
daily_rollupandsessionsupdate within seconds.
The whole hot path is ~4–8 ms p50 on a Cloudflare Worker.
What happens when an event doesn't land
400 invalid_payload
event missing, or properties > 8 KB.
Trim properties. Check details in the response.
403 origin_not_allowed
pk_* key, but Origin header isn't in the allowlist.
Add the origin in the dashboard, or use sk_* server-side.
403 forget_requires_secret_key
You hit /v1/forget with a pk_* key.
Use sk_*.
429 rate_limited
Burst over 200 tracks/project/sec.
Back off — Retry-After is in the response header.
Browser SDK silently no-ops
DNT or GPC is on.
Override with init({ ignoreOptOut: true }) only with a separate legal basis.
Where events go after they land
Raw events —
eventstable in ClickHouse. Queried by/v1/query.Daily aggregates —
daily_rollupmaterialised view. Queried by/v1/stats.Sessions —
sessionsmaterialised view. Used for entry-source attribution and the FB social-vs-paid split in/v1/sources.
See also
Properties — what to put in
propertiesand what the SDK adds for you.Identities —
anonymous_idvsuser_id, when to call/v1/identify.Sessions — how
session_idis derived and when to override it.Attribution — the classifier in detail.
Event & property naming — conventions that scale.
Last updated
Was this helpful?