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

25 KiB

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:

{
  "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:

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:

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

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:

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:

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.