Identities
anonymous_id, user_id, and how /v1/identify stitches them together.
Millimetric uses two identifiers for "who did this", and they're independent on purpose:
anonymous_id
Per device / browser, persistent
localStorage (browser) or your own cookie/header (server)
Caller — the SDK generates it client-side
user_id
Per person, stable across devices
Your auth system
You — once a visitor authenticates
Every event must be attributable to at least one of them. Both is great. Neither isn't allowed (the server will fabricate an anonymous_id if you really don't send one — but you've thrown away your ability to re-link the visit).
anonymous_id
A pseudonymous, per-device UUID. The browser SDK creates one on first load and stores it under localStorage["mm_aid"]. It survives refreshes, tab closes, and SPA navigations. It does not survive:
Clearing browser storage.
Switching browsers.
Switching devices.
Private / incognito sessions (the SDK falls back to an in-memory UUID for the tab's lifetime).
That's a feature, not a bug — anonymous_id is meant to be low-stakes. If a visitor wipes their data, the link is severed.
import { getAnonymousId, setAnonymousId } from "@millimetric/track";
getAnonymousId(); // "u_8f3c1a..."
setAnonymousId("u_custom_42"); // override (e.g. from server-rendered HTML)Server-side, you supply your own anonymous id — usually from a cookie you set yourself or a request header you forward from the browser:
track({
event: "form_submit",
anonymous_id: req.cookies.aid, // ← your job to pass this through
user_id: user.id,
properties: { form: "contact" }
});user_id
Your stable, internal user id. Same one you use in your own database. Once known, you set it on every subsequent track.
The browser SDK stores user_id after identify() and merges it into every event:
The Node SDK doesn't have local state — pass user_id explicitly on each call.
/v1/identify — stitching them together
Calling POST /v1/identify emits a special $identify event that links a known user_id to the visitor's anonymous_id:
What this changes:
A
$identifyevent lands in ClickHouse withevent_name='$identify',anonymous_id,user_id, andtraitsinproperties.Subsequent events for that
anonymous_idshould also senduser_id(the SDK does this automatically).Historical events for the same
anonymous_idare not retroactively rewritten. They still haveuser_id = NULL. To stitch them in analysis, JOIN through the$identifyevent or use thesessionsview.
When to call identify
User signs up
yes — you finally know who they are
User logs in (returning)
yes — confirm the link on this device
User logs out
no — keep tracking events anonymously, do not flip user_id to null
User switches account
yes — call identify with the new user_id
Visitor browses anonymously
no — no need until they identify themselves
Don't be afraid to call identify more than once. It's idempotent at the event level — each call just emits another $identify. Some teams call it on every authenticated request handler so every device the user touches gets a fresh stitch.
The pre-/post-login stitch
The killer use case. A visitor lands from a Facebook ad, browses for a week, finally signs up.
For revenue attribution, you want to credit the Day 1 paid Facebook click for the Day 7 signup. Two approaches:
1. Per-session attribution (recommended). The sessions materialised view captures entry_source / entry_medium per (anonymous_id, 30-min window). user_id shows up on the $identify event itself. JOIN sessions to identifies to credit the first session that brought this user in.
2. Per-user attribution. Find the earliest event for anonymous_id = u_abc regardless of user_id:
(See the Anonymous → known recipe for a fleshed-out version.)
traits
traits on /v1/identify are stored in the $identify event's properties. They're the user's attributes at the moment they identified — not a profile that lives elsewhere. There's no "user store"; if you need the latest plan, query your own DB.
Best practice: include just enough on traits to enable cohort filtering in event queries (plan, tier, team_size). For the source of truth, query your own DB.
A device with multiple users
The SDK stores one user_id at a time. If user A logs out and user B logs in on the same browser:
Anonymous events between the logout and the next identify will be tagged with whichever user_id was last set. If that bothers you, call setAnonymousId(crypto.randomUUID()) between users to force a fresh anonymous identity.
A user across multiple devices
Each device has its own anonymous_id. As soon as the user identifies on each device, you'll have:
user_42↔u_abc(laptop)user_42↔u_xyz(phone)
The events on both devices share user_id = user_42. Per-user analytics (group by user_id) works seamlessly. Per-device analytics (group by anonymous_id) splits them, which is correct.
Forgetting
POST /v1/forget deletes events by (project_id, user_id). It does not touch events that were emitted before identify (user_id = NULL). Those are indistinguishable from any other anonymous traffic — by design.
If you need to forget by anonymous_id, run the SQL directly:
See also
POST /v1/identify — the endpoint.
Sessions — how anonymous activity gets bucketed.
Last updated
Was this helpful?