User Identification
How Yavio ties events to known users with .identify(), anonymous sessions, and identity rules
Yavio tracks user journeys through a combination of anonymous sessions and explicit identification. This page explains how identity works and when to call .identify().
Anonymous sessions
Every conversation gets a session with a session_id (format: ses_ + 21 characters). It is derived from the conversation key the call carries — Mcp-Session-Id, ChatGPT's openai/session, VS Code's conversation id, the Codex CLI's thread id, or your own sessionKey — so the same conversation has the same session_id on every server instance. Without any key it is a session of its own for that one call over HTTP, on every host (session_source: per_request, not counted as a session), and the client process's on stdio. Events are tracked against this session immediately, even before you know who the user is.
"Anonymous" here means not identified yet. A session_id is still a pseudonymous identifier, so it is personal data under the GDPR and kept only with the raw events: see Data retention.
Anonymous sessions are useful for:
- Measuring overall tool usage and latency
- Tracking conversion funnels before login
- Counting unique sessions per platform
Calling .identify()
When you learn who the user is (e.g., from an auth token or user input), call .identify() to attach a user ID to the session:
yavio.identify("user_42", {
plan: "enterprise",
company: "Acme Corp",
});From this point on, every event the session sends from this server process includes user_id: "user_42". The traits travel on the identify event.
When to call it
Call .identify() as early as possible — ideally in the first tool handler that has access to user identity. Common patterns:
- Auth tool: if your MCP server has a login or auth tool, call
.identify()there - First tool with user context: if the AI platform passes a user ID in the tool arguments
- Every tool: a repeat with the same user ID and traits sends nothing (since SDK 0.5.4), so it's safe to call in every handler — one
identifyevent per session in each server process, and one more whenever a trait changes
Identity rules
Immutable user ID per session
The userId is set once per session. If you call .identify() a second time with a different user ID, the call is ignored and a YAVIO-1302 warning is logged. The check runs in each server process and is best effort: on a fleet of stateless instances, an instance that never saw the first ID accepts another one (the dashboard counts the session for its first identified user).
yavio.identify("user_42"); // Sets user ID, sends an identify event
yavio.identify("user_42"); // Sends nothing (same ID, same traits)
yavio.identify("user_99"); // Ignored — YAVIO-1302 warningThis prevents accidental identity conflicts in multi-tool sessions. It is also why a sessionKey must return a conversation id: a key per user would make all of that user's conversations one session.
Over HTTP, .identify() carries over to the next call only when the calls share a session key: in a per_request session it applies to that one call, on any server since SDK 0.5.5 (on 0.5.4, a server that reuses one stateless transport carried it over to every key-less caller).
Additive traits
User traits are merged across calls. Each .identify() call adds to (but doesn't replace) existing traits:
yavio.identify("user_42", { plan: "pro" });
yavio.identify("user_42", { company: "Acme" });
// Session now has: { plan: "pro", company: "Acme" }If the same trait key is set twice, the later value wins:
yavio.identify("user_42", { plan: "free" });
yavio.identify("user_42", { plan: "pro" });
// Session now has: { plan: "pro" }A call that changes a trait sends a new identify event with all the merged traits; a call that changes nothing sends none.
User traits
Traits are arbitrary key-value pairs attached to the user for segmentation and filtering. Common examples:
| Trait | Description |
|---|---|
plan | Subscription tier (free, pro, enterprise) |
company | Organization name |
role | User role within their org |
signupDate | When the user first registered |
Traits are stored on identify events only: the session's other events carry the user_id, not the traits.
Avoid storing PII in traits. While the SDK strips known PII patterns (emails, phone numbers, etc.), it's best to use opaque identifiers.
Retroactive stitching
Once a session is identified, all its events count for that user. The stored events are not rewritten: events emitted before .identify() keep no user_id, and on a fleet of stateless server instances only the instance that called .identify() stamps it. The dashboard stitches when it reads them.
- Within the session: every event of the session counts for the user of its first
.identify(), the events before the call and the events of every other instance that served the conversation included. A later.identify()with another ID never re-attributes earlier events in the users list and a user's detail, and an event's ownuser_idis never overwritten. Counts over a date range (active users, Locations) only look at theidentifyevents inside what they read (for active users, the range plus the 30 days before it): a conversation identified before that and again with another ID inside it counts for the later ID there. - Not a per-request session: a session with
session_source: per_request(an HTTP call that carries no session key, on any host) is never stitched. Since SDK 0.5.5 it is one call; on SDK 0.5.4 a long-lived server that reused one stateless transport put every key-less caller into one such session, so one caller's.identify()must not count everybody else's calls for that user. - Across sessions: when the platform sends a stable
subject_id(ChatGPT does), a subject that was identified once is counted as that user in its other conversations too. - Where: the users list and detail, active users, retention, new vs returning, the Locations card, the invocation feed and a tool's recent invocations, and the anonymous statistics. The live feed shows each event's own
user_id.
Session lifecycle
- First call of a conversation → session created (
ses_...), from the conversation's key or the connection - Tool calls → events tracked against the anonymous session
.identify()called → user ID and traits attached to the session- Subsequent events → include
user_idautomatically (the traits stay on theidentifyevent), in the same process (the state is per server process; a session evicted from a busy process's session store starts over) - Client disconnects → no event; remaining events are flushed on shutdown