For the complete documentation index, see llms.txt. This page is also available as Markdown.

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"
  }
}
Field
Required
What it does

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 / campaign come from the classifier, which reads url + referrer.

  • country / device_type / browser / os come from geo-IP and the User-Agent header.

  • ip_hash is HMAC(ip, IP_SALT || UTC_date) — un-reversible and rotates daily. Raw IPs are never persisted.

  • session_id is filled in if you didn't send one.

Event types you'll meet

Convention
Examples
Who emits it

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

  1. Auth — Bearer token resolved to a project + scope. pk_* keys also have their Origin checked.

  2. Validation — Zod parse against trackEventInput. Bad payloads return 400 invalid_payload.

  3. Rate limit — token bucket per project per route (50/sec for track, 5/sec for batch).

  4. Enrichment — classifier runs, IP is hashed, UA is parsed, session_id is derived if absent.

  5. Insert — single-row JSONEachRow into ClickHouse via the HTTP interface.

  6. Materialised viewsdaily_rollup and sessions update within seconds.

The whole hot path is ~4–8 ms p50 on a Cloudflare Worker.

What happens when an event doesn't land

Symptom
Cause
Fix

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 eventsevents table in ClickHouse. Queried by /v1/query.

  • Daily aggregatesdaily_rollup materialised view. Queried by /v1/stats.

  • Sessionssessions materialised view. Used for entry-source attribution and the FB social-vs-paid split in /v1/sources.

See also

Last updated

Was this helpful?