Anonymous, default-on daily usage telemetry owned by src/telemetry/, src/types/telemetry.ts,
src/server/management/telemetry-routes.ts, src/cli/telemetry.ts, and
services/telemetry-collector/, with public user documentation in docs-site/src/content/docs/guides/telemetry.md.
src/telemetry/contract.ts is an import-free module defining TELEMETRY_SCHEMA_VERSION = 1,
the closed rosters (PROVIDER_KINDS, MODEL_FAMILIES, INSTALL_CHANNELS, RELEASE_CHANNELS,
OS_VALUES, ARCH_VALUES, REQUEST_OUTCOME_CLASSES, REQUEST_FAILURE_CAUSES), and
validateTelemetryPayloadV1. Every level of the payload rejects unknown keys, non-roster strings,
out-of-window or non-calendar day values, versions not matching
^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ (including any semver build metadata), and more than
64 model rows. Because both the proxy and the collector validate against src/telemetry/contract.ts,
any closed-roster or wire-contract change requires a collector-first rollout (deploying the
collector before a client release emits the new value).
src/types/telemetry.ts defines OcxTelemetryConfig (decision, decidedAt, noticeShownAt),
validated in src/config/schema/leaf-validators.ts, src/config/schema/config-schema.ts,
src/config/diagnostics.ts, src/config/load-degrade.ts, and adopted into live server state
via adoptPersistedTelemetry in src/config/live-reconcile.ts.
src/telemetry/consent.ts (resolveTelemetryStatus) evaluates precedence in order:
OCX_TELEMETRY (0, false, off, no), DO_NOT_TRACK (1, true, yes),
decision === "declined", decision === "accepted", undecided with noticeShownAt
(default-on), and undecided without notice (awaiting-notice). It reads only the config's
telemetry block: a whole config is never treated as a block, so a top-level noticeShownAt or
decision (which ocx config set can write) is not consent.
Casual-path consent guards require human interaction before transitioning an undecided install to active sending:
PUT /api/telemetryinsrc/telemetry/routes.ts(delegated fromsrc/server/management/telemetry-routes.tsand lazily mounted insrc/server/management-api.ts) requireshasHumanDashboardSessionfromsrc/server/management/consent-session.tsand recordsdecision,decidedAt, andnoticeShownAt. There is no separate notice-only endpoint; dismissing the GUI consent dialog via Escape, the backdrop, or close records nothing and leaves the install undecided for the next app start.GET /api/telemetry(buildTelemetryStatusSnapshotinsrc/telemetry/routes.ts) buildspreviewfor the same completed UTC day the scheduler would send next (oldest eligible unsent day, withpreviewEligible: true), or previews the latest completed UTC day withpreviewEligible: falsewhen no day is eligible to be sent. It reads consent fromconfig.jsonon disk, like the scheduler, and reuses a day's aggregate forTELEMETRY_PREVIEW_CACHE_MSbecause the dashboard polls it.ocx telemetry enableinsrc/cli/telemetry.tsrequires an interactive human terminal (isattyon both stdin and stdout, notOCX_SERVICE, and notisAgentDriven()), prints the full disclosure block, asks a[Y/n]confirmation prompt (default yes), and recordsaccepted; otherwise it exits non-zero withagent_consent_requiredortty_requiredpointing to the dashboard.ocx telemetry disableis always permitted. IfmutatePersistedConfigreturnsunavailableor throws duringenableordisable, the CLI exits non-zero with an actionable error, prints no success line, and does not deletetelemetry-id.jsonunless the decline persisted.ocx config set telemetryandocx config unset telemetryinsrc/cli/config-command.tsrefuse all callers, andocx config importignores incomingtelemetrykeys for every caller and preserves the persistedtelemetryblock unchanged.- Interactive prompts run from
src/cli/star-prompt.ts,src/service/cli.ts, andsrc/cli/init.ts, printing the disclosure block (what is sent, what is never sent, public aggregates atopencodex.me/dashboard,ocx telemetry status --json, and opt-out controls) before the[Y/n]prompt; agent-driven, non-TTY, or service starts print a once-per-version notice and record neithernoticeShownAtnordecision. src/telemetry/disclosure.tsis the one source for the disclosure text: the CLI prompt prints it and the Windows installer dialog is generated from it (structure/desktop-shell.md, "Windows installer telemetry choice").- A decision made before
config.jsonexists (ocx telemetry disable, the dashboard popup on a fresh install, the Windows installer opt-out) is held aspendingDecisionintelemetry-state.jsoninstead of creating the config, soocx initstill runs setup.withPendingTelemetryDecisionapplies it wherever consent is resolved (scheduler, status route, CLI prompt) until a decision is written into the config, which clears it. - The dashboard popup (
gui/src/components/telemetry-consent-card.tsx) opens only for an undecided install with no env override, after the star onboarding has closed, and never on a Remote Link Child. Only Continue writes (PUT /api/telemetry); Escape and the backdrop record nothing. Focus is moved to the heading aftershowModal()(which would otherwise focus the first focusable element), so a stray Enter cannot accept. A saved decision whose reply cannot be parsed is re-read withGET, and if that also fails the save still counts (gui/src/telemetry-api.ts). A session restored from a remembered device is refused (agent_consent_required), and the popup says how to choose instead. On a Remote Link Child the Proxy details row only says telemetry is managed on the Home:/api/telemetryis not a link-readable path. - Telemetry consent in
config.jsonis owned by the file:carryPersistedTelemetry(src/config/telemetry-carry.ts) makes both whole-config saves (saveConfigandsaveConfigPreservingClaudeCode) write the validated on-disk block instead of the caller's snapshot, so a decision written by another process is never dropped or rolled back, and a malformed hand edit is dropped as a load would drop it. Without a readableconfig.jsona save never introduces a decision: before the file exists the decision is held intelemetry-state.json, where another process may have replaced it, and a file that cannot be read may hold a decline the snapshot never saw. Only a snapshot decline survives; any other block is dropped, and the held decision or the notice applies. The same rule coversinitializePersistedConfigIfMissing, which createsconfig.jsonfrom the running config, and the consent route never copies a decision it only held as pending into the running config.
src/telemetry/install-id.ts manages <OPENCODEX_HOME>/telemetry-id.json (0o600, exclusive
no-follow creation) on the first send and deletes it when telemetry is declined or when an
OCX_TELEMETRY / DO_NOT_TRACK opt-out is observed. Only sha256(installId + ":" + day) leaves
the machine.
src/telemetry/aggregate.ts scans usage.jsonl through src/usage/ledger-scanner.ts for one
completed UTC day, accumulating counters inside the streaming onEntry callback for the target day
only without retaining ledger rows in memory, mapping providers to PROVIDER_KINDS, models to
MODEL_FAMILIES, outcomes
via src/usage/request-outcome.ts, and decomposing usage into five disjoint token buckets
(inputTokens, cacheReadTokens, cacheWriteTokens, outputTokens, reasoningTokens) whose
sum matches usageDisplayTotalTokens in src/usage/totals.ts.
src/telemetry/payload.ts resolves the install channel and validates the built payload with
validateTelemetryPayloadV1. src/telemetry/sender.ts posts to the constant collector URL
with a 10-second signalWithTimeout and parses Retry-After on HTTP 429/503 responses.
src/telemetry/scheduler.ts runs an unref'd background timer with startup and interval jitter
started from src/server/background-lifecycle.ts. Whenever an OCX_TELEMETRY or DO_NOT_TRACK
opt-out is observed (at startup in src/server/background-lifecycle.ts or on a tick),
recordTelemetryEnvOptOut persists ineligibleThroughDay (today's UTC day) and the
envOptOutActiveSince marker in telemetry-state.json and deletes telemetry-id.json; a proxy
started under the opt-out records its pid in envOptOutOwners instead. Such a proxy reads the env
once and runs no ticks, so the marker or owner stands for the whole period: while either is set no
day is eligible. endTelemetryEnvOptOut closes the period (ineligible through the closing day) on
that proxy's clean stop, which always covers its stop day, or on the first tick without the opt-out,
which ends it only once no recorded owner pid is still alive and otherwise extends it to today. A
telemetry-state.json that exists but cannot be read (symlink, oversized, malformed, or with an
opt-out field or held decision that is present but invalid) stops sending
(state-unreadable) rather than being read as empty: the tick checks it before choosing a day and
again just before sending, the bookkeeping writes after a send or a Retry-After leave such a file
alone, other writers (including every recorded decision, so sending does not stay paused) rebuild
it as ineligible through the UTC day six hours from now, and the status snapshot reports
stateUnreadable. A rebuild forgets envOptOutOwners, so an opted-out proxy records itself again
every 6 hours (TELEMETRY_ENV_OPT_OUT_REFRESH_MS), within that window. A held decline whose
timestamp is damaged still applies. The state code lives in
src/telemetry/state.ts, which background-lifecycle.ts imports statically; the scheduler, its
aggregation and sender stay behind the dynamic import. Each tick carries an AbortSignal
cancelled by shutdown or consent changes (nudgeTelemetryScheduler), sends at most one completed
UTC day strictly after the consent/disclosure UTC day (day > formatUtcDay(decidedAt ?? noticeShownAt))
and strictly after ineligibleThroughDay (day > ineligibleThroughDay), re-reads persisted consent
and environment variables immediately before transmission, and honors HTTP 429 / 503
Retry-After responses by persisting retryAfterUntil in telemetry-state.json and skipping ticks
until then.
services/telemetry-collector/ is the Cloudflare Worker + D1 service (services/telemetry-collector/src/index.ts,
services/telemetry-collector/src/ingest.ts, services/telemetry-collector/src/stats.ts,
services/telemetry-collector/src/prune.ts, and services/telemetry-collector/schema.sql) that imports
src/telemetry/contract.ts directly, deduplicates reports on (day, install_day_hash) in a single
atomic conditional D1 batch, rate-limits POST /v1/events to 120 reports/hour per salted hourly hash
of the client key (HTTP 429 with Retry-After; a bucket is deleted by the hourly prune at most 2 hours
15 minutes after its hour begins) without storing raw IP addresses, and serves cached aggregate rollups
from GET /v1/stats. The client key comes only from cf-connecting-ip: IPv4 per address,
IPv4-mapped IPv6 as that IPv4 address, and any other IPv6 address collapsed to its /64 prefix;
x-forwarded-for is ignored and a request without cf-connecting-ip shares one fixed bucket. In
TelemetryStatsV1, totals.activeInstalls and breakdown installs counters represent install-days
(sum of daily reporting installs), and totals.avgDailyActiveInstalls reports
install-days / days in range (rounded to 1 decimal). Every breakdown (providers, models, versions,
platforms, channels, errors, dailyByProvider) publishes a value only for (day, key) pairs reported by
at least 10 distinct installs on that day, applied before days are summed into the range, so one install
cannot pass it alone by reporting on many days or through many provider/model-family rows. The distinct
counts come from daily_installs, daily_errors.installs, daily_provider_installs, and
daily_family_installs. Single-valued dimensions (version, platform, install channel) fold a day's
sub-threshold rows into that day's "other", published only if it reaches 10; multi-valued dimensions
(providers, model families, error causes) drop sub-threshold rows. totals, daily, and heatmap stay
unfiltered, so suppressed values can still be approximated by subtraction.
Reports are unauthenticated and self-reported by opencodex installs.
docs-site/src/content/docs/guides/telemetry.md documents the payload schema, consent and agent boundaries, opt-out controls, payload preview, and collector retention.
Owned by src/telemetry/profile/, src/cli/telemetry-profile.ts, the profile modules of
services/telemetry-collector/ (profiles.ts, leaderboard.ts, profile-http.ts), gui/src/components/telemetry-profile-card.tsx
with gui/src/telemetry-profile-api.ts, and docs-site/src/components/TelemetryLeaderboard.astro with
docs-site/src/components/telemetry-dashboard/leaderboard.ts. Design: devlog/_plan/261109_telemetry_personal_rank/.
- Contract.
src/telemetry/profile/contract.tsimports only the anonymous contract's rosters. A profile day is{schemaVersion, day, requests, tokens{5 buckets}, families[{family, requests, tokens}]}with exact keys, consistent sums, closedMODEL_FAMILIES, a completed UTC day at most 7 days old and daily caps. The collector validates with the same functions, so changes need a collector-first rollout. - Separate consent. Connecting is its own decision, never implied by the telemetry decision or notice:
POST /api/telemetry/profilerequireshasHumanDashboardSessionandacknowledgePublic: true;ocx telemetry profile connectrefusesisAgentDriven()(agent_consent_required) and non-TTY orOCX_SERVICE(tty_required) and asks[y/N]. Connecting requires anonymous telemetry to be effective; it never writesconfig.json,telemetry-state.jsonortelemetry-id.json. Disconnecting (DELETE /api/telemetry/profile,ocx telemetry profile disconnect) is always permitted and never touches the telemetry decision. - No linkage. No file under
src/telemetry/profile/importsinstall-id,payloadorsenderor names the install id, install-day hash or batch id (tests/usage/usage-telemetry-profile-sync.test.tsscans for it). Profile calls go to/v1/profiles*with a bearer secret on their own timer. - State.
<OPENCODEX_HOME>/telemetry-profile.json(atomic no-follow write,0600, recorded for uninstall) holds the handle, display name, avatar, secret, connect day, sent days andpendingDelete/revoked. An unreadable file sends nothing. No API response, CLI output or GUI view carries the secret (toPublicProfileState). - Sync.
src/telemetry/profile/sync.tssends at most one day per tick, chosen byresolveNextEligibleTelemetryDaywith the profile's own sent days and the connect day folded intoineligibleThroughDay, so every anonymous eligibility rule applies plus no day on or before the connect day. The floor is the later of the client's connect day and the collector'screatedDay; a day the collector refuses with a 4xx other than 401/429 is set aside (refusedinsentDays) so the oldest-first order never stalls. Every write after a network call goes throughupdateTelemetryProfileState, which re-reads the file and skips the write if the secret changed, the file is gone or a disconnect is pending. A disconnect of an unreadable file removes it locally (local_removed). Sending requires anonymous telemetry to be effective.src/server/background-lifecycle.tsstarts the loop only when the profile file exists, independently of the env opt-out gate, so a pending deletion is retried while telemetry is off;GET /api/telemetry/profilestarts it after a CLI connect. A401marks the profile revoked. - Collector. Profile endpoints, limits, ranking (
RANK()among non-hidden profiles with a positive total;rank: nulloutside that set;ofis the set size),x-robots-tag: noindex, no preflight for bearer routes, and pruning are described inservices/telemetry-collector/README.md. - Surfaces. The Home card shows while telemetry is effective or a profile exists, hides when the route is refused
(Remote Link Child), and renders user strings as text. The docs-site leaderboard validates a handle before
building any request and writes names with
textContentonly (tests/ci-workflows/docs-telemetry-leaderboard.test.ts).