Yavio

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 identify event 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 warning

This 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:

TraitDescription
planSubscription tier (free, pro, enterprise)
companyOrganization name
roleUser role within their org
signupDateWhen 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 own user_id is never overwritten. Counts over a date range (active users, Locations) only look at the identify events 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

  1. First call of a conversation → session created (ses_...), from the conversation's key or the connection
  2. Tool calls → events tracked against the anonymous session
  3. .identify() called → user ID and traits attached to the session
  4. Subsequent events → include user_id automatically (the traits stay on the identify event), in the same process (the state is per server process; a session evicted from a busy process's session store starts over)
  5. Client disconnects → no event; remaining events are flushed on shutdown

On this page