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.