Files
tutti/docs/architecture/analytics-tracking.md
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

536 lines
25 KiB
Markdown

# Analytics Tracking
This document describes the analytics event tracking architecture for tutti.
## Purpose
tutti uses 火山引擎 DataFinder (Tea SDK) as the analytics platform. All tracking
events — whether originating from user interactions in the renderer or from
daemon-side lifecycle operations — are reported through a single pipeline owned
by `tuttid`.
## Architecture Decision: Unified tuttid Pipeline
All events route through tuttid before reaching the Tea backend.
```
renderer (JS) tuttid (Go) Tea / DataFinder
─────────────────────────────────────────────────────────────────────────
user interaction ──POST──▶ merge common params ──▶ 火山引擎 Server SDK
daemon lifecycle ──direct▶ merge common params ──▶ best-effort HTTP send
```
Renderer does not load or initialize any Tea SDK. It only sends raw event
payloads to tuttid via a local HTTP call. tuttid is the sole Tea client.
Shared renderer modules depend only on `@tutti-os/analytics` and receive an
`IReporterService` from the host composition root. `TuttidClient`,
`DesktopdAnalyticsClient`, event catalogs, and product-specific common
parameters stay in their respective host adapters.
### Multi-window pageview ownership
The desktop main process grants predefine pageview ownership to only the first
workspace renderer window created during the current app process. It encodes
that decision in the window bootstrap query as
`reportPredefinePageview=1|0`. The owning window reports the initial
`app.pageview` and later focus pageviews; secondary OS or standalone Agent
windows do not start the predefine pageview listener.
Ownership is process-scoped and is not transferred when the first window
closes. A new desktop process creates a new owner. Browser-only and legacy
renderer routes without the bootstrap parameter keep pageview reporting
enabled for compatibility. This gate applies only to the predefine
`app.pageview` stream used for DAU/PV measurement; workspace and feature
business events continue to report from the window where the action occurs.
**Why tuttid owns reporting:**
- tuttid always starts before the renderer, so there is no Tea SDK startup
ordering problem in the renderer
- Common params such as `device_id`, `session_id`, `os`, `app_version`, and
account identity are owned by tuttid and do not need to be replicated or
synchronized to the renderer
- Batch scheduling and retry behavior live in one place (the Go Tea SDK)
- Renderer has no dependency on external scripts or CSP relaxations for Tea
## Common Params
Common params are split by ownership. tuttid injects its params on every event
before forwarding to Tea. The renderer supplies only the params it uniquely
knows.
Runtime and application metadata that DataFinder defines as preset properties
is sent in the SDK header as well as retained in the legacy custom common
params where one already exists. This keeps existing dashboards compatible
while allowing DataFinder's built-in dimensions to receive `os_name`,
`os_version`, `app_version`, `app_version_minor`, and `cpu_abi`. The reporter
sends the exact configured application version to both version fields. This
preserves prerelease suffixes such as `-rc.0` in `app_version_minor` when
DataFinder normalizes `app_version`. Event UUIDs follow the same transition:
`event_id` remains in event params and is also assigned to the SDK event's
preset ID field.
| Param | Owner | Notes |
| ------------------- | -------- | --------------------------------------------------- |
| `device_id` | tuttid | Persisted UUID in state dir; stable across restarts |
| `session_id` | tuttid | UUID generated once at daemon startup |
| `app_version` | tuttid | Resolved from generated defaults or env override |
| `app_version_minor` | tuttid | Exact version, including prerelease suffixes |
| `os` | tuttid | Resolved at startup |
| `os_name` | tuttid | Preset SDK header; currently the Go runtime OS key |
| `os_version` | tuttid | Preset SDK header; best-effort product OS version |
| `cpu_abi` | tuttid | Preset SDK header; Go runtime architecture key |
| `event_id` | tuttid | Generated UUID when the event does not supply one |
| `authority` | tuttid | `"client"` for Tutti Desktop events |
| `business_app_id` | tuttid | Tutti account/commerce application ID |
| `client` | tuttid | `"desktop"` |
| `environment` | tuttid | Runtime environment |
| `schema_version` | tuttid | Current analytics contract version |
| `uid` | tuttid | Authenticated account ID; absent when anonymous |
| `login_state` | tuttid | `"authenticated"` or `"anonymous"` |
| `identity_status` | tuttid | Identity readiness for the current event |
| `membership_status` | tuttid | Current membership state or `"unknown"` |
| `membership_tier` | tuttid | Current tier key, `"free"`, or `"unknown"` |
| `client_ts` | renderer | Millisecond timestamp at the moment the event fired |
| `dark_mode` | renderer | `"1"` or `"0"` |
| `mode` | renderer | Current workspace shell: `"os"` or `"agent"` |
| UI-specific params | renderer | Passed through `params` object |
tuttid never tries to infer UI-state params. Renderer never tries to supply
identity or platform params.
The account service supplies dynamic identity parameters and the matching
DataFinder `user_unique_id` as one atomic snapshot. A login or logout cannot
produce an event whose SDK identity disagrees with its own `uid` and
`login_state`. Anonymous events use the stable `device_id` as the SDK identity.
When tuttid starts with a persisted account session, it restores the UID before
the reporter begins handling product events; membership fields remain
`"unknown"` until the product summary is refreshed.
The renderer derives `mode` from the native window route. `view=agent` reports
`"agent"`; `view=workspace`, legacy routes, and unknown routes report `"os"`,
matching the renderer's actual fallback behavior. This remains renderer-owned
because multiple OS and Agent windows can coexist while sharing one tuttid
process.
### Agent send funnel ownership
AgentGUI submits through the shared `AgentSessionEngine` command port. The
successful `session/activate` and `queue/sendPrompt` command boundaries own
`agent.session_started`, `agent.message_sent`, and their `agent.node_result`
events. Keep this telemetry on the corresponding
`WorkspaceAgentActivityService` Engine effects; `AgentGUIRuntime` contains no
lifecycle writes. Non-AgentGUI prompt-session integrations keep their explicit
tracker because they call the activity service without entering the shared
engine.
## Event Naming Convention
Event names follow the product analytics spec's dot-separated domain action
pattern.
| Pattern | Meaning | Examples |
| ------------------- | -------------------------------------------- | ----------------------------- |
| `<domain>.<action>` | Product domain plus confirmed business event | `workspace.opened` |
| Nested domains | Larger feature area plus action | `agent.session_started` |
| Error domains | Feature-specific error event | `error.workspace_unavailable` |
### Account login
Tutti Desktop reports the unified `account.login` event:
| Stage | Action | Result | Meaning |
| ------- | ---------- | ------------------------------ | ----------------------------------- |
| `login` | `start` | `started` | Desktop login attempt accepted |
| `login` | `complete` | `success` | Login completed with a resolved UID |
| `login` | `complete` | `failed / cancelled / expired` | Terminal unsuccessful result |
Every attempt carries a stable `flow_id`. Login success rate is distinct
successful terminal `flow_id` divided by distinct started `flow_id`. Daily
logged-in users are distinct `uid` values on successful terminal events, with
dashboard day boundaries evaluated in `Asia/Shanghai`.
### Workspace UI mode changes
The desktop reports `settings.workspace_ui_mode_changed` after the selected
workspace UI mode has been persisted. The event carries `previous_mode`,
`next_mode`, and an `action` that describes the standalone Agent mode:
`"enabled"` when changing from OS to Agent and `"disabled"` when changing from
Agent to OS. Selecting the already persisted mode or failing to persist the
preference does not emit an event.
The renderer records the durable preference change and passes the transition
metadata in the same IPC request that replaces the current workspace window.
The durable main process starts the analytics transport after the replacement
window is ready and before destroying the previous renderer. This lets the new
window's debug subscriber observe the event while ensuring old-window teardown
cannot discard the handoff. The transport remains best-effort and is not
awaited by the mode-switch product flow: a delayed or rejected analytics
request must never delay replacement or turn a successful preference write
into a save failure. If replacement fails before a new window is ready, main
still reports the already-persisted change before returning the failure because
the saved preference will apply when a workspace window is next opened. This
event measures explicit mode changes through `previous_mode` and `next_mode`.
It does not set the renderer-owned common `mode` field: after an earlier
replacement failure, the durable preference and the actual native owner-window
route can temporarily differ, and the main process must not guess that route.
tuttid reports `settings.workspace_ui_mode_initialized` exactly once per fresh
profile, from the `initializeIfAbsent` write branch that owns every
fresh-preference-row creation (the dedicated field patch writers refuse to
materialize a missing row). The `workspace_ui_mode` param carries the assigned
initial mode derived from the authoritative stored row, so cohort analysis can
separate "assigned by default" from "explicitly chosen" and from later escapes
measured by `settings.workspace_ui_mode_changed`. The param is deliberately not
named `mode` to avoid colliding with the renderer-owned window-mode common
param. As a daemon-side event it usually fires before login, so it attributes
to the device identity like early `account.login` stages.
## API Contract
### Renderer → tuttid
```
POST /v1/track
Authorization: Bearer <per-run token>
Content-Type: application/json
{
"events": [
{
"name": "workspace.opened",
"client_ts": 1749124800000,
"params": {
"source": "dashboard",
"dark_mode": "1"
}
}
]
}
```
Response: `202 Accepted`, empty body.
The endpoint is fire-and-forget. The renderer does not wait for Tea confirmation.
Delivery is handled asynchronously by tuttid and the Go SDK.
`POST /v1/track` is part of the canonical tuttid OpenAPI contract in
`services/tuttid/api/openapi/tuttid.v1.yaml`. Go and TypeScript transport
types are generated from that source like other daemon routes.
The request contract is enforced by tuttid:
- `events` must contain 1 to 100 items
- `name` must match `^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$` and be at most 128
characters
- `client_ts` must be a positive millisecond timestamp
## Configuration
Tea SDK config follows the same pattern as other tutti defaults: a single
source of truth in `config/tutti.defaults.json`, code-generated into Go and
TypeScript, with env var overrides for CI and local development.
### `config/tutti.defaults.json`
The `analytics` section defines the default DataFinder configuration:
```json
{
"analytics": {
"appId": 20004092,
"appKey": "3a7e11907d4f4dba62193392de606331ebaf90e8fd197babf71c9e06a9a74282",
"channel": "sg",
"channelDomain": "https://gator.uba.ap-southeast-1.volces.com",
"appVersion": "0.0.0"
}
}
```
`appId` and `appKey` are the 火山引擎 DataFinder credentials for the tutti
app. These values are embedded in the distributed binary and are not secrets
in the traditional sense — they identify the product, not a user.
### Code Generation
`tools/scripts/generate-defaults.mjs` is extended to render the `analytics`
block into `services/tuttid/types/defaults_generated.go` alongside the
existing state, transport, and logging blocks.
The generated Go struct:
```go
Analytics: generatedAnalyticsDefaults{
AppID: 20004092,
AppKey: "...",
Channel: "sg",
ChannelDomain: "https://gator.uba.ap-southeast-1.volces.com",
AppVersion: "0.0.0",
},
```
### Runtime Resolution
`types/defaults.go` exposes an `AnalyticsConfig` resolved from generated
defaults plus env var overrides:
```go
type AnalyticsConfig struct {
Disabled bool
AppID int
AppKey string
Channel string
ChannelDomain string
AppVersion string
}
```
Supported env var overrides:
| Variable | Effect |
| -------------------------------- | --------------------------------------------------- |
| `TUTTI_ENV=development` | Use debug-only reporting; no remote events sent |
| `TUTTI_APP_VERSION` | Shared desktop app version propagated to tuttid |
| `TUTTI_ANALYTICS_DISABLED=true` | Switch to `NoopReporter`; no events sent |
| `TUTTI_ANALYTICS_APP_ID` | Override app ID (dev/test Tea app) |
| `TUTTI_ANALYTICS_APP_KEY` | Override app key |
| `TUTTI_ANALYTICS_CHANNEL_DOMAIN` | Override endpoint URL |
| `TUTTI_ANALYTICS_APP_VERSION` | Compatibility override for app version common param |
`TUTTI_ENV=development` uses debug-only reporting so local development can
inspect emitted events in the analytics debug panel without making Tea SDK
network requests. `TUTTI_ANALYTICS_DISABLED` is the explicit kill switch when a
run should not publish any local or remote events.
Recognized disabled values are `1`, `true`, and `yes`; recognized false values
are `0`, `false`, and `no`. Unknown non-empty values fail closed and disable
reporting. Invalid `TUTTI_ANALYTICS_APP_ID` values resolve to `0`, which also
selects `NoopReporter`.
Managed desktop launches set `TUTTI_APP_VERSION` from Electron
`app.getVersion()` before starting tuttid, so DataFinder `app_version` follows
the packaged desktop app version. `TUTTI_ANALYTICS_APP_VERSION` remains as a
narrow compatibility override and takes precedence when set.
### Reporter Construction
`newTuttiWiring()` calls `types.ResolveAnalyticsConfig()`, then constructs a
`DebugReporter` in development, a `TeaReporter` in production when config is
present and not disabled, or a `NoopReporter` when reporting is disabled or
production config is incomplete. No other part of tuttid is aware of which
implementation is active.
## Shared Go Implementation
```
packages/analytics/reporter-go/
reporter.go # Public Reporter, Event, and product-neutral config
tea_reporter.go # datarangers-sdk-go implementation
debug_reporter.go # Local debug events without remote reporting
noop_reporter.go # No-op for tests, disabled, or incomplete config
tea_sdk_adapter.go # Vendor SDK boundary and bounded SDK settings
services/tuttid/service/reporter/
reporter.go # Tutti config adapter and compatibility aliases
events/ # Tutti-owned typed daemon business events
```
`github.com/tutti-os/tutti/packages/analytics/reporter-go` is a public Go
module. It is the reusable lower SDK for Tutti products such as TSH. Product
repositories own their event catalog, HTTP contract, configuration, and
business emission points; they must not copy the DataFinder adapter.
## Shared Debug Panel
`@tutti-os/analytics-debug` owns the bounded in-memory event store, redaction
hook, and reusable React floating panel. It does not own daemon connections,
availability flags, persisted preferences, or product translations.
Tutti adapts the `analytics.debug.reported` event stream into the shared store
after daemon common parameters have been applied. Other hosts provide their own
event-source adapter and localized labels. Debug payloads are never persisted;
hosts should supply a redactor when their event parameters may contain
sensitive values.
Tutti connects this stream only when the debug feature is available in a
development build. It intentionally retains a bounded history from application
startup so developers can inspect events emitted before opening the panel. The
history is process-memory only, is discarded on exit, and contains the same
final payload already sent to the configured analytics transport.
### Reporter interface
```go
type Event struct {
Name string
ClientTS int64 // 0 means use current time
Params map[string]any
}
type Reporter interface {
Track(ctx context.Context, events ...Event)
Close() error
}
```
`TeaReporter` wraps `github.com/volcengine/datarangers-sdk-go`. It injects
common params on every `Track` call before handing events to the SDK. Hosts may
supply an existing durable `DeviceID`, static common parameters, and one
`DynamicContextProvider`. That provider returns dynamic common parameters and
the matching DataFinder user identity in one snapshot. The shared reporter
always owns and protects `device_id`, `session_id`, `app_version`, and `os`.
The SDK uses HTTP mode with SDK batch mode disabled, a bounded async queue wait,
and controlled SDK log paths under the product state directory.
`NoopReporter` is used in unit tests and when Tea credentials are absent (e.g.
local development without credentials configured).
### Device ID
`device_id` is a UUID generated once and written to `<state-dir>/device_id`. On
subsequent startups the file is read and the same ID is reused. This gives a
stable anonymous device identity across daemon restarts without requiring user
authentication.
### Wiring
`Reporter` is constructed in `newTuttiWiring()` and injected into `DaemonAPI`.
`wiring.Close()` calls `reporter.Close()` during graceful shutdown. The current
DataFinder Go SDK exposes no public HTTP-mode hard-flush API, so `TeaReporter`
keeps the lifecycle hook but treats close as best-effort for HTTP reporting.
## TypeScript Implementation
### `@tutti-os/analytics`
`packages/analytics/core` publishes the business-facing `IReporterService`,
`ReporterService`, and renderer-to-daemon `AnalyticsTransport` contract:
```ts
interface IReporterService {
track(name: string, params?: Record<string, unknown>): Promise<void>;
trackEvents(events: ReporterEventInput[]): Promise<void>;
}
```
The desktop renderer registers the shared service in the workspace window DI
container. Its local adapter implements `AnalyticsTransport` with
`TuttidClient.trackEvents()`. Renderer business code depends on
`IReporterService`, not on the low-level tuttid client method.
Reusable business packages own the events whose trigger semantics are inside
the package. They receive `Pick<IReporterService, "trackEvents">` from the host
composition root and report their exact event contracts directly. For example,
`@tutti-os/workspace-issue-manager` owns issue/task actions and converts its
camel-case domain params to the analytics wire shape before reporting. A host
must not redispatch those events through a product-local reporter switch.
Host-only events such as pageviews and shell lifecycle events remain in the
host.
`ReporterService` owns renderer-side reporting behavior:
- `track()` wraps one business event
- `trackEvents()` accepts a batch of renderer event inputs
- `clientTS` defaults to `Date.now()`
- a product adapter converts the shared transport event to its daemon OpenAPI
representation (`client_ts` for tuttid)
- event `params` are copied before transport handoff
- transport failures are swallowed because renderer analytics is best-effort
and must not affect product flows
Agent error codes and error normalization are Agent-domain policy rather than
analytics-core policy. Renderer mappings live with `workspace-agent`; daemon
codes live in `services/tuttid/biz/agentanalytics`. Typed analytics events
consume those domain values without owning or redefining the mapping.
### `packages/clients/tuttid-ts`
`packages/clients/tuttid-ts` exposes a hand-written `trackEvents` convenience
method on `TuttidClient`:
```ts
trackEvents(events: TrackEvent[]): Promise<void>
```
The method calls the generated OpenAPI SDK and reuses generated request types.
## Rules
- Renderer must not initialize or reference any Tea SDK directly
- Renderer business code must reuse `@tutti-os/analytics` and report through
`IReporterService` rather than calling daemon clients directly
- Shared modules own their internal event names, exact params, and trigger
timing; hosts only inject `IReporterService`
- Agent error classification must stay in the Agent domain
- `POST /v1/track` acknowledges local acceptance only; callers may await the
local `202`, but must not wait for Tea/DataFinder delivery confirmation
- `client_ts` must be set by the caller to the moment the event occurred, not
the moment the HTTP call is made
- `daemon_` prefixed events are reported directly via `Reporter.Track()`; they
do not go through the HTTP endpoint
- Daemon-owned common params (`device_id`, `session_id`, `os`, `app_version`,
identity fields, authority, app, client, environment, and schema version)
must not be sent by the renderer; tuttid always overwrites them
- `TeaReporter.Close()` must be called during graceful shutdown; with the
current DataFinder Go SDK HTTP mode this is a best-effort lifecycle hook, not
a hard flush guarantee
- Use `NoopReporter` in tests; never make real Tea calls from test code
- Set `TUTTI_ANALYTICS_DISABLED=true` in local development and CI to avoid
polluting production analytics data
- Do not read Tea credentials from anywhere other than `ResolveAnalyticsConfig()`
- After modifying `config/tutti.defaults.json`, always re-run
`generate-defaults.mjs` and commit the generated files together
## Agent Host terminal failure telemetry
Agent Host may emit aggregated terminal failures through
`TerminalFailureObserver`. Sources:
- failed `session_create` / `message_send` lifecycle commands
- guidance target binding failures (`flow=guidance`,
`failure_stage=guidance_target`) before claim creation or when the runtime
rejects an exact turn target before provider admission
- goal-control prepare / refresh command failures, and durable
`GoalOperationFailed` / terminal incidents
- durable runtime-operation failures for `interactive_response`,
`plan_decision`, `turn_cancel`, and `edit_retry`
- settled failed / interrupted root turns (including child/subagent sessions
via `IsChildSession`)
- failed / errored `tool_call` messages in session-message commits
Out of scope for dedicated Host terminal-failure flows:
- queue as a separate event family (queued submits still fail as
`message_send` / turn settlements; `isQueued` remains a diagnostic trait)
- session fork (TSH does not expose fork)
- file-change card open failures (desktop UI / TSH Analytics only)
- shared-agent product events (owned by the shared-agent analytics track)
Adapters should map those observations into product analytics events. Do not
promote every `LifecycleStep` into a product analytics event; lifecycle steps
remain diagnostic. Terminal failures carry the original error message and
failure stage for investigation without user-supplied logs.
### Who extracts failures from a committed delta
`Host.notifyCommitted` owns delta terminal failures. It calls
`ObserveTerminalFailuresFromDelta` before `NotifyCommitted`, so every commit
Host publishes is already accounted for by the time observers run. A
`CommitObserver` — `ActivityProjection.ObserveCommitted` included — must never
re-observe the delta it receives; doing so double-counts every durable runtime,
goal, turn, and tool-call failure whenever the same observer is wired to both.
A report path that commits and fans out without going through Host calls
`ObserveTerminalFailuresFromDelta` exactly once next to its own
`NotifyCommitted`. In tuttid that path is
`ActivityProjection.observeCommittedOutsideHost`, the single entrypoint for the
direct activity-state, session-message, and stale-turn reports;
`ActivityProjection.SetTerminalFailureObserver` exists to feed it. Canonical
bookkeeping commits (`CanonicalDelta`) carry no failure-bearing sections, so
they need no extraction.
Because the observed `RuntimeOperations`, `EffectiveHistory`, and `GoalStore`
wrappers are the only source of durable runtime and goal commit deltas, `New`
installs them when either `CommitObserver` or `TerminalFailureObserver` is
configured. An adapter that wants failure analytics alone still gets them.