docs/internals/product-analytics.md
Product analytics
The server owns analytics delivery, opt-out, and identity for every connected client. Clients do not load the PostHog browser SDK. Client-use events require an authenticated connection, so visiting the hosted app without connecting does not count as product use.
Installs and owners
distinct_id keeps the rule every build has used: the hashed Codex workspace,
else the Claude machine id, else the install's anonymous id. It can change when
someone logs in, and a Codex Team shares one, but keeping it means PostHog charts
and history never break. Do not change it; add a field instead.
Every event also carries installId when the server can save its state: the hash
of a random id in the state directory, which never changes for the install. If
that file cannot be written, events still go out under distinct_id, as before. Count installs
with it. Who an install belongs to is a separate fact, sent as
install.owner_observed. Owners are resolved at query time in our own store, so
a rule can change and apply to all history.
Owner sources, strongest first:
T3 Connect, then the OpenAI and Claude person ids, then the client device. The
server reports what it can read itself: the linked Connect account and provider
logins. A connecting client reports its signed-in Connect account and its device,
which links every environment opened from one phone, browser, or desktop. Use the
OpenAI user_id claim, not the Codex account_id (the ChatGPT workspace, which a
whole Team shares) or the token sub (one per login method). Two Connect accounts are never the same person, whatever
weaker ids they share.
Every id is SHA-256 hashed with one helper on both sides, so a Connect account hashes the same on the client and the server. The server accepts only hashes from clients.
Attribution boundaries
Client dimensions belong to the event's WebSocket connection. A server-global "current client" would misattribute simultaneous web, desktop, and mobile use. Provider execution has its own events because a turn can outlive the requesting connection.
Keep client and server dimensions separate. A desktop host can serve a phone or a
remote browser, and a direct connection can cross a network. Older clients omit
metadata. Missing client values must stay unknown rather than being backfilled
from server properties. The legacy clientType property describes how the server
runs; use surface for the connected client.
Interpreting events
Use client.turn.requested for active-use reports. There is no connection event,
because T3 Connect sessions expire hourly and each reconnect would count again.
A command whose id was already accepted is a retry, so it records nothing again.
One identity can appear in several client groups during a period; adding those
groups double-counts users.
Provider send and completion counts need not match. Most completions start
without a person: provider continuations, agent sends, and delegated subagents.
provider.turn.completed carries the starting message's createdBy and
creationSource, plus scheduled and subagent; filter on them to count user
turns. Collection is best effort, with no scan or backfill of provider history.
Token totals cover the main agent. Child agents and model rerouting prevent a turn from representing one provider/model combination's full cost. For provider comparisons, require complete usage, no observed subagents, and no mixed models; compare matching model, effort, interaction mode, and terminal status. Aggregate output/input ratios should divide the summed totals. Averaging per-turn ratios lets small-input turns dominate.
Unknown counts stay absent. Partial usage contains valid observed counts but cannot establish a whole-turn total. Keep these distinctions when changing token normalization or building reports.
feature.used counts WebSocket requests that start a feature. RPCs are observed in
the RPC authorization middleware after the request succeeds; dispatched commands
are recorded by the dispatch handler, which can tell a retry apart.
Feature mapping allowlists
the methods and commands, and takes variants only from closed contract enums,
so paths, ids, and text never reach analytics. Guests of a shared thread have no
WebSocket connection, so the guest share service records their joins and messages
itself, with no surface.
Agent tool events report driver kinds, never instance ids: users name
instances, so an instance id can identify an account or employer.
Provider dimensions
report a model only when it is a non-custom entry in a vendor catalog. OpenCode,
Pi, and ACP agents list models from user configuration, such as local Ollama
tags, so their models stay out. Handoff settings come from closed enums and
booleans in tool arguments; prompts, titles, and ids are never read. Thread ids
are used only to look up the threads on each side and never leave the server,
which is also why tool events carry no per-thread sequence. A handoff to several
threads, such as create_threads, records one mcp.tool.invoked per target, each
repeating the call's outcome and duration, so event counts are not call counts.
Delivery
The server sends batches to the ingest endpoint at ingest.t3.codes, which archives
every event and forwards some of them to PostHog. A send can fail after the endpoint
has stored the batch, so every retry is a copy.
Delivery gives each event a
uuid when it is recorded (the endpoint rejects events without one), backs off after
a failed send, and drops a batch after a few tries. Without these limits, one stuck
batch was sent every second for days.
Collection boundary
Keep analytics payloads to product metadata and normalized measurements. Do not send prompts, authentication material, raw provider payloads, user-assigned device names, or conversation identifiers. Client metadata is best effort; invalid values must not reject a connection. PostHog person profiles remain disabled.
Dev-runner servers and the CI archive smoke test turn telemetry off, which also
stops relay tracing, because each would count as a new install. Set
T3CODE_TELEMETRY_ENABLED=true to test analytics or relay tracing from a dev server.