Two fields, nothing inferred, and why a unique is only ever counted inside one source.
Identity here is two fields and no inference. Every number that counts people rather than events rests on them.
| Field | Comes from | What it is |
|---|---|---|
| distinct_id | The client, on every event | A column. Anonymous and scoped to one source: a visitor id in a browser, an install id on a machine, whatever your server passes per call. |
| user.id | Only ever the string you passed to identify() | An attribute. Your id and your meaning. It is never invented, derived, looked up, or filled in from anything else. |
One definition, everywhere: count(distinct coalesce(user.id, distinct_id)), scoped to a single source. An identified client folds into its user; an anonymous one stands on its own.
Two sources in one project are two separate anonymous id spaces reported next to each other. A project is a namespace for events, sources and boards; it is not an identity namespace. There is no inference, no probabilistic matching, no IP or fingerprint heuristic, and no merging, ever.
If you want one person joined across two sources, call identify() with the same id on both. That is the only link there is, and it is your data and your decision rather than something reconstructed from behaviour. It applies to the events that carry the id: nothing is merged retroactively.
A desktop client keeps distinct_id in per-user local application data, not the roaming profile, because it names one installation rather than one person. Carrying it to a second machine would report two installs as one, which is what identify() is for and what an anonymous id must not do quietly.
time is stamped by the client at the moment the thing happened, and it is authoritative. A laptop that was offline until Monday still reports Friday’s launch as Friday. Every series and every bucket is built on that, never on when the event arrived.