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

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:

Identifier
Lifecycle
Lives in
Set by

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 $identify event lands in ClickHouse with event_name='$identify', anonymous_id, user_id, and traits in properties.

  • Subsequent events for that anonymous_id should also send user_id (the SDK does this automatically).

  • Historical events for the same anonymous_id are not retroactively rewritten. They still have user_id = NULL. To stitch them in analysis, JOIN through the $identify event or use the sessions view.

When to call identify

Moment
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_42u_abc (laptop)

  • user_42u_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

Last updated

Was this helpful?