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:
eventsmust contain 1 to 100 itemsnamemust match^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$and be at most 128 charactersclient_tsmust 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 eventtrackEvents()accepts a batch of renderer event inputsclientTSdefaults toDate.now()- a product adapter converts the shared transport event to its daemon OpenAPI
representation (
client_tsfor tuttid) - event
paramsare 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/analyticsand report throughIReporterServicerather 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/trackacknowledges local acceptance only; callers may await the local202, but must not wait for Tea/DataFinder delivery confirmationclient_tsmust be set by the caller to the moment the event occurred, not the moment the HTTP call is madedaemon_prefixed events are reported directly viaReporter.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
NoopReporterin tests; never make real Tea calls from test code - Set
TUTTI_ANALYTICS_DISABLED=truein 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-rungenerate-defaults.mjsand commit the generated files together
Agent Host terminal failure telemetry
Agent Host may emit aggregated terminal failures through
TerminalFailureObserver. Sources:
- failed
session_create/message_sendlifecycle 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, andedit_retry - settled failed / interrupted root turns (including child/subagent sessions
via
IsChildSession) - failed / errored
tool_callmessages 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;isQueuedremains 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.