From b9ca17478ce3032bdbcfa7387b0c365d63b9e2cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 22:52:29 +0900 Subject: [PATCH 01/29] docs(devlog): plan opt-in telemetry and the opencodex.me usage dashboard Roadmap unit for a six-layer stack: client telemetry core, GUI consent popup, Cloudflare Worker collector, telemetry guide, and the public /dashboard page. Docs only. --- .../261107_telemetry_dashboard/000_plan.md | 93 +++++++++ .../010_client_core.md | 179 ++++++++++++++++++ .../020_gui_consent.md | 55 ++++++ .../030_collector.md | 80 ++++++++ .../040_docs_privacy.md | 31 +++ .../050_dashboard.md | 43 +++++ 6 files changed, 481 insertions(+) create mode 100644 devlog/_plan/261107_telemetry_dashboard/000_plan.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/010_client_core.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/030_collector.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/050_dashboard.md diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md new file mode 100644 index 00000000000..e694f22d51a --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -0,0 +1,93 @@ +# Opt-in telemetry and the opencodex.me usage dashboard + +## Objective and scope + +Add anonymous, opt-in product telemetry to the opencodex proxy and a public, project-wide +analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform +pings, active time, request and token totals, and error counts; a collector stores daily +aggregates; the dashboard renders installs, active time, usage, errors and downloads. + +Delivery is a manual chain of stacked pull requests, one per work phase below, each based on the +previous layer and the bottom on `dev`. No deploy of the collector, no DNS change, no merge and no +release are part of this unit; those are maintainer actions after review. + +The visual and structural reference is [junhoyeo/tokscale](https://github.com/junhoyeo/tokscale) +(MIT): its contribution heatmap, KPI header, client/model breakdown tables, and the +validated aggregate submit API. Patterns are adapted, not vendored; credit lives in the dashboard +footer and in the PR descriptions. + +## Owner decisions (2026-11-07) + +| Id | Decision | +| --- | --- | +| hosting | Collector is a Cloudflare Worker with D1 at `telemetry.opencodex.me`. The repository ships its code, schema and tests; deployment is a maintainer step. | +| consent | Superseded by default-on. | +| update-popup | An install that updates and has no recorded decision sees the same consent popup once after the update. | +| default-on | Telemetry defaults to **on** (opt-out). The GUI first-run/post-update popup (checkbox pre-checked) and the first interactive `ocx start` (Y/n, default yes) let the user choose. `ocx init` and `ocx service install` ask the same question. An agent-driven start prints a notice and never answers for the user or records the notice. No payload is sent until a person has seen the disclosure. Advanced settings and `ocx telemetry disable` turn it off later. | +| scope | The dashboard is public and project-wide (aggregates only). No per-user profiles or leaderboards. | + +## Privacy contract (applies to every layer) + +- Effective state: env opt-out (`OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) → off; decision `declined` → off; `accepted` → on; undecided → on only after `noticeShownAt` is recorded, which only a person can cause (GUI popup seen, interactive TTY prompt answered). An agent-driven start prints a notice but records nothing, so an agent can never switch sending on. +- Identity is a random install id (UUID v4) generated on first send, never derived from + hardware, accounts, hostnames or paths. Disabling telemetry deletes it; re-enabling mints a new one. +- Payloads carry only closed-vocabulary values and counters: version, OS, arch, install channel, + provider *kind* (closed vendor roster), coarse model family, closed + error codes, and integer counts. Never request or response bodies, prompts, model output, API + keys, OAuth tokens, account ids or e-mails, custom base URLs, hostnames, file paths, or free-form + error text. +- The exact JSON the next send would contain is visible before consent ("What is sent") and via + the management API and `ocx telemetry status`. +- The collector stores no IP address and no raw install id: reports carry a per-day install hash, + rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per + install and day counts. +- No backfill: days before the disclosure are never sent; at most one day is sent per scheduler tick. +- The collector URL is a constant; it is not configurable from `config.json`. +- Telemetry code stays off the core request path: `src/router.ts`, + `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. + Aggregates are computed from the existing usage log on a timer, never inline in a request. + +## Work phases (stack order) + +| Layer | Doc | Thesis | +| --- | --- | --- | +| 1 | this unit | Docs-only roadmap. | +| 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | +| 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | +| 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | +| 6 | [050_dashboard.md](050_dashboard.md) | Astro page `/dashboard`: heatmap, KPIs, breakdowns, errors, downloads. | + +## Verification floor per layer + +`bun run typecheck`; focused tests for the layer plus `bun run test:changed`; +`bun run structure:check`, `bun run privacy:scan`, file-size ratchet and test-layout guards for +any layer adding files; `bun run lint:gui` and `bun run build:gui` for layer 3; docs-site build +for layers 5 and 6; `bun run skill:surface:check` when a CLI capability is added. Each layer gets an +independent review before its PR is opened. + + +## Audit disposition (round 1) + +Independent audit (argon, read-only): FAIL with 9 blockers and 6 majors; all accepted and folded. +Agent-driven starts no longer record the notice; the notice and decision routes need a human session; +`ocx telemetry enable` and `ocx config set telemetry` refuse agent-driven use; no persisted endpoint; +no pre-consent backfill and one day per tick; an import-free shared contract module with closed rosters +that the collector imports; strict version regex; `OcxTelemetryConfig` in `src/types/telemetry.ts` +and no edits to `src/cli/index.ts` or `scripts/test-layout/layout.json` (ratchet headroom 0–4 lines); +`telemetry` added to the skill-surface roots; session-only route exemptions; the docs guide now lands +before the dashboard; collector typecheck test; a tick-driven scheduler that re-reads config and stops in +`stopProcessLoops`, with `OCX_TELEMETRY=0` in the test preload; explicit install-channel mapping; GUI +dialog sequencing via `onSettled`, notice only on dismiss, `readOnly`; unified `env` and kebab-case +causes; disjoint token buckets; `TelemetryStatsV1` with `dailyByProvider` and a 365-day heatmap; dedup +on `(day, install_day_hash)`; prompts in `ocx init` and `ocx service install`; sidebar, configuration +reference and both header menus. Goalplan note: work phase wp4 delivers 040 (docs) and wp5 delivers 050 +(dashboard). + + +## Audit disposition (round 2) + +Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. Two minors folded: the +collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in +`src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` +rejects `/` tokens and host identifiers there (010). diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md new file mode 100644 index 00000000000..62a3cb13885 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -0,0 +1,179 @@ +# 010 — Client telemetry core + +Stack layer 2. Everything the proxy needs to decide, build, preview and send a daily report, the +management API and CLI that later layers use, the shared wire contract, and the structure doc that +claims the new `src/` area. Anchors were read on `c61e957900`. + +## Shared wire contract (`src/telemetry/contract.ts`) + +An **import-free** module (same rule as `src/usage/telemetry-contract.ts`) holding the closed rosters +and the payload validator: `TELEMETRY_SCHEMA_VERSION = 1`, `PROVIDER_KINDS`, `MODEL_FAMILIES`, +`INSTALL_CHANNELS`, `RELEASE_CHANNELS`, `OS_VALUES`, `ARCH_VALUES`, the outcome classes and the failure +causes (re-declared and pinned equal to `REQUEST_OUTCOME_CLASSES` / `REQUEST_FAILURE_CAUSES` by a test), +and `validateTelemetryPayloadV1(input, now)`. The collector (030) imports this file directly, so the +client cannot emit a key or value the collector rejects and the two cannot drift. + +- `PROVIDER_KINDS` (vendor-level, closed): openai, chatgpt, anthropic, claude-subscription, google, + vertex, xai, deepseek, moonshot, qwen, zai, minimax, mistral, openrouter, groq, cerebras, together, + fireworks, cursor, kiro, copilot, devin, ollama, lmstudio, local-other, other. The client maps a + provider's registry preset to a kind; custom/unknown providers become `other`. User-chosen provider + keys, labels and base URLs are never read into the payload. +- `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, + claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, + llama, other. +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d+)?$` (no build metadata). + +## State model + +Type `OcxTelemetryConfig` lives in a new `src/types/telemetry.ts`; `src/types/config.ts` (1996 lines, +ratchet threshold 2000) gains exactly one import line and one field line, or an existing block moves +out first if the count says otherwise. Schema in `src/config/schema/config-schema.ts` as +`telemetryConfigSchema.optional().catch(undefined)`, strict live-write validation in +`src/config/diagnostics.ts`, load-degrade warning in `src/config/load-degrade.ts`, baseline adoption +`adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +```ts +type OcxTelemetryConfig = { + decision?: "accepted" | "declined"; // absent = undecided + decidedAt?: string; // ISO + noticeShownAt?: string; // a human saw the disclosure (GUI or interactive TTY) +}; +``` + +There is **no persisted endpoint**: the collector URL is the constant +`https://telemetry.opencodex.me/v1/events` in `sender.ts` with a test-only injection seam. + +`resolveTelemetryStatus(config, env)` (`src/telemetry/consent.ts`): + +| Condition (first match) | `effective` | `reason` | +| --- | --- | --- | +| `OCX_TELEMETRY` ∈ {0,false,off,no} | false | `env-ocx-telemetry` | +| `DO_NOT_TRACK` ∈ {1,true,yes} | false | `env-do-not-track` | +| `decision === "declined"` | false | `declined` | +| `decision === "accepted"` | true | `accepted` | +| undecided and `noticeShownAt` set | true | `default-on` | +| undecided, no notice | false | `awaiting-notice` | + +## Who may change it (agent boundary) + +Telemetry is on by default, so the only thing that can switch sending on for an undecided install is +a human seeing the disclosure. Following `AGENTS_INSTALL.md` and `src/cli/star-prompt.ts`: + +- An agent-driven start (`isAgentDriven()`, non-TTY, or `OCX_SERVICE=1`) prints a one-line notice at + most once per version (`.telemetry-deferred` marker in the config dir) and writes **neither** + `noticeShownAt` nor `decision`. The install stays `awaiting-notice` until a human sees the GUI popup + or an interactive prompt. +- `PUT /api/telemetry` and `POST /api/telemetry/notice` both require `hasHumanDashboardSession(ctx)`. +- `ocx telemetry enable` refuses with `agent_consent_required` (non-zero exit) when `isAgentDriven()`; + `ocx telemetry disable` is always allowed (opting out is never a consent risk). +- `src/cli/config-command.ts` `set`/`unset` refuse the `telemetry` key, and `import` drops a + `telemetry` block when agent-driven, pointing to `ocx telemetry`. +- `AGENTS_INSTALL.md` gains a short "Telemetry" entry in its user-consent list. + +## Identity + +`src/telemetry/install-id.ts` keeps `/telemetry-id.json` (`0o600`, exclusive no-follow +create, pattern of `src/quota-pool/identity.ts`), created on the first send and deleted when the +decision becomes `declined`. The raw id never leaves the machine; each report carries +`installDayHash = sha256(installId + ":" + day)` and `newInstall: true` only on the very first report +of a freshly minted id (the collector counts new installs per day, version and channel from it). + +## Aggregation (`src/telemetry/aggregate.ts`) + +Scans `usage.jsonl` with `scanUsageLedgerCooperatively` (`src/usage/ledger-scanner.ts`) for one +completed UTC day: + +- `activeMinutes`: distinct `floor(timestamp / 60000)` values (≤ 1440). +- `requests`: counts per outcome class via `classifyRequestOutcome` (`src/usage/request-outcome.ts`). +- `models`: per (`providerKind`, `modelFamily`) requests and **disjoint** token buckets — + `inputTokens` = uncached input (`normalizeCostTokens`, `src/usage/cost.ts`), `cacheReadTokens`, + `cacheWriteTokens`, `outputTokens` = output minus reasoning, `reasoningTokens`. The five sum to the + display total (`usageDisplayTotalTokens`); a test pins that identity. +- `errors`: counts keyed by failure cause (kebab-case, e.g. `rate-limit`). + +## Payload v1 + +```json +{ "schemaVersion": 1, "batchId": "", "day": "2026-11-06", "installDayHash": "<64 hex>", + "newInstall": false, + "env": { "version": "2.40.1", "releaseChannel": "latest", "os": "darwin", "arch": "arm64", + "installChannel": "npm-global" }, + "activeMinutes": 312, + "requests": { "completed": 840, "failed": 12, "incomplete": 3, "aborted": 5 }, + "models": [ { "providerKind": "anthropic", "modelFamily": "claude-opus", "requests": 400, + "inputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, + "outputTokens": 0, "reasoningTokens": 0 } ], + "errors": { "rate-limit": 7 } } +``` + +`installChannel` mapping in `src/telemetry/payload.ts`: `OCX_DESKTOP_SUPERVISED === "1"` → desktop; +container markers (`/.dockerenv`, `/run/.containerenv`) → docker; `detectInstallOwnership().installer` +npm → npm-global, pnpm → pnpm-global, bun → bun-global, mise → mise, standalone → standalone, +source → source; otherwise unknown. Every field is named explicitly in the builder and the result is +run through `validateTelemetryPayloadV1` before sending or previewing. + +## Scheduling and sending + +- `src/server/background-lifecycle.ts` `startProcessLoops` starts a dormant `.unref()` scheduler via + `void import("../telemetry/scheduler")…` unless an env opt-out is set; `stopProcessLoops` stops it + (handle kept in background-lifecycle), and `registerOptionalShutdownHook("telemetry", stop)` covers + drain. `src/server/index.ts` and `src/cli/index.ts` (1999 lines) are not touched. +- Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates + `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management + routes also call `adoptPersistedTelemetry` and nudge the scheduler. +- It sends only completed days on or after the UTC day of `decidedAt ?? noticeShownAt` (no pre-consent + backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s + `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. +- `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. + +## Management API + +Thin `src/server/management/telemetry-routes.ts` (seen by `management-route-registry.test.ts`) +delegating to `src/telemetry/routes.ts`, lazily namespaced from `src/server/management-api.ts` and +declared in `route-registry.ts`: + +- `GET /api/telemetry` → effective/reason/decision/env override, install channel, last sent day, + `preview` (the exact payload for the latest completed day; hash shown as a placeholder when no id + exists). +- `PUT /api/telemetry` `{ decision }` — human session only; `exempt: { reason: "session-only" }`. +- `POST /api/telemetry/notice` — human session only, idempotent; `exempt: { reason: "session-only" }`. + +## CLI + +`src/cli/telemetry.ts`: `ocx telemetry status [--json]` (route `GET /api/telemetry`, falls back to +local status when the proxy is down), `enable` and `disable` (local config mutation, `routes: []`, +`mutates: true`). Capability objects stay plain string/array/object literals (the lexical guard in `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers). Registered in `capabilities-observe-system.ts`, `registry.ts`, `dispatch.ts`, +`help.ts`; `telemetry` is added to the `observe-system` roots in +`scripts/generate-ocx-skill-surface.ts` and `tests/ci-workflows/skill-ocx-generated.test.ts`, then +`bun run skill:surface`. + +Interactive prompt `Share anonymous usage statistics? [Y/n]` (default yes; Enter = yes; records +`decision` and `noticeShownAt`) shown once when undecided, from: `maybeShowStarPrompt()`'s module on +interactive `ocx start` (no new line in `src/cli/index.ts`), `ocx service install` next to its star +prompt (`src/service/cli.ts`), and the end of `ocx init` (`src/cli/init.ts`). + +## Ownership + +`structure/telemetry.md` registered in `structure/manifest.json` claiming `src/telemetry/`, +`src/types/telemetry.ts`, `src/server/management/telemetry-routes.ts` and `src/cli/telemetry.ts`, +naming only paths that exist in this layer; `bun run structure:index`. The four 600-line structure +docs are not extended. + +## Verification + +- `tests/usage/usage-telemetry-client.test.ts`: status matrix, env precedence, id file mode and + deletion, aggregate bucketing and the disjoint-token identity on a fixture ledger, payload validator + rejects extra keys/unknown roster values/semver metadata, roster parity with the usage contract, no + pre-consent days, one day per tick, sender timeout, nothing sent when not effective. +- `tests/usage/usage-telemetry-core-boundary.test.ts`: `router.ts`, `lifecycle.ts`, + `responses/core.ts`, `management-api.ts` have no load-time path to `src/telemetry/`. +- `tests/server/server-telemetry-routes.test.ts`: GET preview; PUT and notice refused with admin token + and remembered-admin; accepted with a human session; notice idempotent. +- `tests/cli/cli-telemetry.test.ts`: status/enable/disable, enable refused when agent-driven, config + set refusal, agent-driven start writes no notice, prompt default yes. +- New tests registered in a domain shard (`layout-usage.json` etc.; `layout.json` is at 1999 lines) + and `tests/fixtures/test-layout-expected.json`. +- `bun run typecheck`, focused files, `bun run test:changed`, `bun run structure:check`, + `bun run skill:surface:check`, `bun run privacy:scan`, file-size and layout guards, + `tests/cli/cli-capabilities.test.ts`, `tests/server/management-route-registry.test.ts`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md new file mode 100644 index 00000000000..96a0a3bc3b7 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md @@ -0,0 +1,55 @@ +# 020 — GUI consent popup, post-update popup and Advanced toggle + +Stack layer 3. Depends on 010 (`GET /api/telemetry`, `PUT /api/telemetry`, `POST /api/telemetry/notice`). + +## Behaviour + +- `TelemetryConsentCard` is a native `` mounted beside + `` in `gui/src/App.tsx`. It is enabled only when + `targetsSettled && !targets.connected && sharedSessionReady && starOnboardingSettled`. + `DesktopStarOnboarding` gains an `onSettled` callback (fired when it closes or decides it will not + show), so the two dialogs never open together. +- It opens when the status reports an undecided install with no env override and the proxy says + `reason === "awaiting-notice"` or `"default-on"` without a decision. That covers first run and + installs that update into this version (they have no decision yet): the post-update popup. +- The checkbox is **pre-checked**. Continue sends `PUT /api/telemetry { decision: checked ? "accepted" : + "declined" }` (this also records the notice server-side). Escape/backdrop without a decision sends + `POST /api/telemetry/notice`: the user saw the disclosure and the default (on) applies. Opening the + dialog alone records nothing, so an unread or automated page load never enables sending. +- The card lists what is sent and what is never sent and has a "What is sent" disclosure with the + exact preview JSON from the status endpoint. +- `TelemetrySettingsPanel` renders in Proxy details (`SettingsSection id="proxy-telemetry"` in + `dashboard-overview-panels.tsx`, id appended to `PROXY_SECTION_IDS`) with `Switch` from `ui.tsx`, + `readOnly` honoured for Remote Link children, and the preview dialog. With `OCX_TELEMETRY=0` or + `DO_NOT_TRACK=1` the switch is disabled and a `Notice tone="warn"` names the variable. + `AdvancedHub.tsx` gets `telemetry`/`privacy` search words on the proxy entry. +- The panel and card fetch on their own with `createBoundedFetch(15_000)`, outside + `useDashboardData` (dashboard-contracts poll-wave rule). + +## Files + +| File | Change | +| --- | --- | +| `gui/src/telemetry-api.ts` | DTO types and fetch helpers (non-component, oxlint `only-export-components`). | +| `gui/src/components/telemetry-consent-card.tsx` | First-run / post-update dialog. | +| `gui/src/components/TelemetrySettingsPanel.tsx` | Toggle row and preview dialog. | +| `gui/src/components/desktop-star-onboarding.tsx` | `onSettled` prop. | +| `gui/src/App.tsx` | Mount the card, track `starOnboardingSettled`. | +| `gui/src/pages/dashboard-section-ids.ts`, `dashboard-overview-panels.tsx`, `AdvancedHub.tsx` | Section, panel, search words. | +| `gui/src/i18n/v2-pages/proxy-copy.ts` | All `proxyV2.telemetry*` rows in the 11-column table (en, ko, de, fr, zh, zh-TW, ru, ja, tr, vi, pt). | + +No CSS is added to `gui/src/styles.css` (ratchet cap); reuse `.modal-overlay`/`.modal-card` and v2 +utilities. + +## Verification + +- `gui/tests/telemetry-consent-card.test.tsx`: waits for star onboarding; shows only when undecided + and no env override; checkbox pre-checked; nothing recorded on open; Continue sends accepted / + declined; Escape sends notice only; hidden once decided. +- `gui/tests/telemetry-settings-panel.test.tsx`: switch mirrors effective state, toggles via PUT, + disabled with env notice and in read-only mode, preview dialog shows the JSON. +- `gui/tests/desktop-star-onboarding.test.tsx` extended for `onSettled`. +- `cd gui && bun test tests && bun run lint && bun run lint:i18n && bun run build`; root + `bun run typecheck`. +- Screenshot of the card and panel for the PR description (uploaded via `pr-assets`, never committed). + diff --git a/devlog/_plan/261107_telemetry_dashboard/030_collector.md b/devlog/_plan/261107_telemetry_dashboard/030_collector.md new file mode 100644 index 00000000000..a8a02208b02 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/030_collector.md @@ -0,0 +1,80 @@ +# 030 — Telemetry collector (Cloudflare Worker + D1) + +Stack layer 4. Imports the wire contract `src/telemetry/contract.ts` from 010 directly. + +## Layout (dependency-free) + +| File | Role | +| --- | --- | +| `services/telemetry-collector/wrangler.toml` | Worker name, route `telemetry.opencodex.me/*`, D1 binding `DB` with placeholder id `00000000-0000-0000-0000-000000000000`, cron trigger for pruning. Real ids are set by a maintainer at deploy time. | +| `services/telemetry-collector/schema.sql` | D1 tables (below). | +| `services/telemetry-collector/tsconfig.json` | Extends root settings with `rootDir: "../.."`, `noEmit: true`, `declaration: false` (as in `scripts/test-layout/tsconfig.verify.json`) so importing `../../src/telemetry/contract.ts` does not hit TS6059. | +| `services/telemetry-collector/src/types.ts` | Minimal local `D1Database`/`D1PreparedStatement`/`ExecutionContext` interfaces, `CollectorEnv`, `TelemetryStatsV1`. | +| `services/telemetry-collector/src/ingest.ts` | `POST /v1/events`. | +| `services/telemetry-collector/src/stats.ts` | `GET /v1/stats?range=7d|30d|90d|365d`. | +| `services/telemetry-collector/src/prune.ts` | Scheduled pruning. | +| `services/telemetry-collector/src/index.ts` | `fetch`/`scheduled` handlers, CORS, 404/405. | +| `services/telemetry-collector/README.md` | Deploy: `bunx wrangler d1 create`, `d1 execute --file schema.sql`, secrets (`RATE_LIMIT_SALT`), `wrangler deploy`. | + +No root `package.json`/`bun.lock` change. The root typecheck only covers `src/`; this layer adds a +test that typechecks the collector project with the repository's own TypeScript toolchain (command +chosen and proven in B, since `node_modules/.bin/tsc` is absent and `bun check` is the root checker). + +## Storage + +- `daily_installs(day, install_day_hash, version, release_channel, os, arch, install_channel, + new_install, active_minutes, PRIMARY KEY(day, install_day_hash))` — pruned after 400 days. +- `daily_totals(day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, + input, cache_read, cache_write, output, reasoning)`. +- `daily_models(day, provider_kind, model_family, requests, input, cache_read, cache_write, output, + reasoning)`. +- `daily_errors(day, cause, count)`. +- `rate_limits(bucket_hour, ip_hash, hits)` — pruned after 2 h. + +**Dedup:** ingest first `INSERT OR IGNORE` into `daily_installs`; only when that inserted a new +`(day, install_day_hash)` row are the rollups incremented (one D1 `batch`). A re-sent day — new +`batchId` or not — changes nothing, so lost client state cannot inflate totals. Raw install ids and IPs +are never stored; `ip_hash = sha256(RATE_LIMIT_SALT + ip + hour)`. + +## Validation and limits + +`validateTelemetryPayloadV1` from the contract (closed keys at every level, closed rosters for +provider kind, model family, channels, os, arch, failure causes; calendar-checked `day` in +[today − 7, today + 1]; strict version regex; bounded integers; ≤ 64 model rows). Body ≤ 16 KiB +(413), JSON only (400), 30 reports/hour per IP hash (429 with `Retry-After`). + +## Stats API (`TelemetryStatsV1`, consumed by 050) + +```ts +type TelemetryStatsV1 = { + schemaVersion: 1; generatedAt: string; range: "7d" | "30d" | "90d" | "365d"; + totals: { activeInstalls: number; newInstalls: number; activeMinutes: number; requests: number; + tokens: { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning: number } }; + daily: Array<{ day: string; activeInstalls: number; newInstalls: number; activeMinutes: number; + requests: number; failed: number; tokens: number }>; + dailyByProvider: Array<{ day: string; providerKind: string; requests: number; tokens: number }>; + heatmap: Array<{ day: string; activeInstalls: number }>; // always the last 365 days + providers: Array<{ providerKind: string; requests: number; tokens: number }>; + models: Array<{ modelFamily: string; requests: number; tokens: number }>; + versions: Array<{ version: string; installs: number; newInstalls: number }>; + platforms: Array<{ os: string; arch: string; installs: number }>; + channels: Array<{ installChannel: string; installs: number; newInstalls: number }>; + outcomes: { completed: number; failed: number; incomplete: number; aborted: number }; + errors: Array<{ cause: string; count: number }>; +}; +``` + +Served with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and +`http://localhost:4321`. + +## Verification + +- `tests/usage/usage-telemetry-collector-worker.test.ts`: `bun:sqlite` in-memory D1 shim loading + `schema.sql` via `repoPath`; ingest accepts a payload built by the 010 builder, rejects unknown keys + and roster values, dedups re-sent days, rate-limits with 429, sets CORS, returns every stats field + for each range with a full 365-day heatmap, never stores a raw id or IP; pruning. +- `tests/usage/usage-telemetry-collector-typecheck.test.ts`: the collector project typechecks. +- Registered in `scripts/test-layout/layout-usage.json` and `tests/fixtures/test-layout-expected.json`; + add `ci:full` if the import graph misses them. +- `bun run typecheck`, `bun run privacy:scan` (placeholder ids only), file-size and layout guards. + diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md new file mode 100644 index 00000000000..b50b0589b7e --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -0,0 +1,31 @@ +# 040 — Telemetry guide and configuration reference + +Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the +docs build rejects links to missing pages. + +## Public docs + +- `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never + collected, default-on with "nothing is sent until a person has seen the notice", agent-driven starts + never enable it, how to turn it off (GUI popup, Proxy details toggle, `ocx telemetry disable`, + `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1`), how to see the exact payload (`ocx telemetry status --json`, + "What is sent"), storage and retention (daily aggregates, install hash rotates daily, no IPs), and + the collector source location. No link to `/dashboard` yet (050 adds it). +- `docs-site/astro.config.mjs`: add `guides/telemetry` to the Guides sidebar. +- `docs-site/src/content/docs/reference/configuration/server.md`: `telemetry` config block and the + two environment variables. +- `guides/web-dashboard.md`: one line pointing to the Proxy details telemetry toggle. +- Translated locales follow the docs-site locale rule (translated or absent with English fallback); + nothing may contradict the English page. + +## Maintainer docs + +`structure/telemetry.md` (from 010) gains the collector location (`services/telemetry-collector/`, +now present) and the guide path. + +## Verification + +docs-site build (`cd docs-site && bun install --frozen-lockfile && bun run build`), +`tests/ci-workflows/docs-link-targets.test.ts` and the other `docs-*` claim guards, +`bun run structure:check`, `bun run privacy:scan`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md new file mode 100644 index 00000000000..41609671e36 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md @@ -0,0 +1,43 @@ +# 050 — opencodex.me/dashboard + +Stack layer 6. Reads `GET https://telemetry.opencodex.me/v1/stats` (`TelemetryStatsV1`, 030) from the browser and links the telemetry guide from 040. + +## Page + +`docs-site/src/content/docs/dashboard.mdx` with `template: splash`, mounting +``. A content page (not `src/pages/`) gets Starlight's locale fallback routes, +so the header's language links (`/ko/dashboard/` ...) resolve and `internal-links.mjs` and +`tests/ci-workflows/docs-link-targets.test.ts` pass unchanged. + +## Sections (adapted from tokscale, MIT, credited in the footer) + +1. Header: title, range pills (7d / 30d / 90d / 365d), last-updated time, privacy callout linking the + telemetry guide and the opt-out variables. +2. KPI strip (tokscale `ProfileOverview`): active installs, requests, tokens, active hours — + `tabular-nums`, compact K/M/B with exact values in `title`. +3. Token mix bar (tokscale `TokenBreakdown`): input / cache read / cache write / output / reasoning — disjoint buckets from the payload contract, so the segments sum to the total. +4. Activity: a 52-week heatmap of daily active installs from the always-365-day `heatmap` series (tokscale `ProfileContributionGraph`, 5 grades) + and an ECharts stacked daily chart from `dailyByProvider` (tokens or requests by provider kind). ECharts is already a + docs-site dependency (`FrontierBoards.astro`) and re-themes on `data-theme` changes. +5. Provider-kind and model-family share tables (tokscale `ProfileModels` colour dots). +6. Platform, version and install-channel distribution, new installs per version/channel; reliability (outcome ratio and top error codes). +7. Downloads: npm weekly/total downloads (`api.npmjs.org/downloads`) and GitHub release asset download + counts (`api.github.com/repos///releases`), fetched client-side with graceful fallback. + +Loading, empty ("collector not deployed yet") and error states are explicit; no layout jumps. + +## Files + +`docs-site/src/components/TelemetryDashboard.astro` (shell + client script), +`docs-site/src/components/telemetry-dashboard/*.ts` (pure formatting / series helpers testable from +`tests/`), `docs-site/src/data/telemetry-dashboard-i18n.ts` (copy for the 8 site locales), and the localized `/dashboard/` link in both the desktop nav and the splash +mobile menu of `docs-site/src/components/Header.astro`, plus a link from `guides/telemetry.md`. + +## Verification + +- `tests/ci-workflows/docs-telemetry-dashboard.test.ts`: the route exists, helpers format and bucket a + fixture stats response correctly, empty/error states render, no hard-coded secrets. +- `cd docs-site && bun install --frozen-lockfile && bun run build`. +- Browser check of the built page against a fixture stats JSON (screenshot for the PR). + + From 0c542dea857d825ac6407fc8202a769761a29b70 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 02/29] feat(telemetry): add shared wire contract, config schema, and core telemetry engine --- src/config/diagnostics.ts | 15 + src/config/live-reconcile.ts | 13 + src/config/load-degrade.ts | 16 ++ src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 11 + src/server/background-lifecycle.ts | 27 ++ src/telemetry/aggregate.ts | 321 +++++++++++++++++++++ src/telemetry/consent.ts | 127 +++++++++ src/telemetry/contract.ts | 403 +++++++++++++++++++++++++++ src/telemetry/install-id.ts | 197 +++++++++++++ src/telemetry/payload.ts | 129 +++++++++ src/telemetry/scheduler.ts | 246 ++++++++++++++++ src/telemetry/sender.ts | 63 +++++ src/types.ts | 2 + src/types/config.ts | 2 + src/types/telemetry.ts | 15 + tests/preload.ts | 1 + 17 files changed, 1590 insertions(+) create mode 100644 src/telemetry/aggregate.ts create mode 100644 src/telemetry/consent.ts create mode 100644 src/telemetry/contract.ts create mode 100644 src/telemetry/install-id.ts create mode 100644 src/telemetry/payload.ts create mode 100644 src/telemetry/scheduler.ts create mode 100644 src/telemetry/sender.ts create mode 100644 src/types/telemetry.ts diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 5b68cffaab4..617db940f68 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -49,6 +49,7 @@ import { malformedQuotaResetNotifyWarning, malformedCatalogAutoRefreshWarning, malformedCodexPoolWarning, + malformedTelemetryWarning, malformedSpendWarning, rawConfigRecord, malformedNativeSubagentFields, @@ -81,6 +82,7 @@ import { compactionRoutingSchema, reasoningRetentionSchema, skillsConfigSchema, + telemetryConfigSchema, memoryModelsSchema, } from "./schema/leaf-validators"; @@ -175,6 +177,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (catalogRefreshWarning) warnings.push(catalogRefreshWarning); const codexPoolWarning = malformedCodexPoolWarning(rawParsed); if (codexPoolWarning) warnings.push(codexPoolWarning); + const telemetryWarning = malformedTelemetryWarning(rawParsed); + if (telemetryWarning) warnings.push(telemetryWarning); const spendWarning = malformedSpendWarning(rawParsed); if (spendWarning) warnings.push(spendWarning); const retentionWarning = degradedReasoningRetentionWarning(rawParsed, normalized); @@ -709,6 +713,16 @@ function skillsConfigError(value: unknown): string | null { return "schema_invalid: skills" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); } +export function telemetryConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return "schema_invalid: telemetry" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { @@ -773,6 +787,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? managementIngressConfigError(value) ?? metricsExportConfigError(value) ?? serviceHealthRecoveryConfigError(value) + ?? telemetryConfigError(value) ?? skillsConfigError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 3e9d14d4439..e836e31add0 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -118,6 +118,19 @@ export function adoptPersistedAdvisor(config: OcxConfig, persisted: OcxConfig["a clearConfigTopLevelDeletionIntent(config, ["advisor"]); if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["advisor"]); } + +/** Adopt the committed telemetry consent subtree without advancing unrelated pending live intent. */ +export function adoptPersistedTelemetry(config: OcxConfig, persisted: OcxConfig["telemetry"]): void { + if (persisted === undefined) delete config.telemetry; + else config.telemetry = structuredClone(persisted); + const baseline = liveConfigBaseline.get(config); + if (baseline) { + if (persisted === undefined) delete baseline.telemetry; + else baseline.telemetry = structuredClone(persisted); + } + clearConfigTopLevelDeletionIntent(config, ["telemetry"]); + if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["telemetry"]); +} /** * The live config retains the address of the socket Bun actually opened, while * this map retains the operator's desired address for the next process start. diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 355819b2ef8..ce3bae9c269 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -52,6 +52,7 @@ import { transientRetryOn5xxPolicySchema, runtimeRoleSchema, spendSchema, + telemetryConfigSchema, chatgptDesktopConfigIssue, } from "./schema/leaf-validators"; import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo"; @@ -152,6 +153,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); warnDegradedMemoryModels(rawParsed, validated); + warnDegradedTelemetry(rawParsed); // The schema replaces a malformed policy with { allProfiles: false }, which silently turns every Aside profile off (#6757). const asidePolicy = rawParsed && typeof rawParsed === "object" ? (rawParsed as Record).asideProfileSync : undefined; if (asidePolicy !== undefined && !asideProfileSyncSchema.safeParse(asidePolicy).success) { @@ -850,6 +852,20 @@ export function malformedCodexPoolWarning(rawParsed: unknown): string | null { return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; } +export function malformedTelemetryWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `telemetry${field ? `.${field}` : ""} ignored: invalid telemetry consent configuration`; +} + +export function warnDegradedTelemetry(rawParsed: unknown): void { + const warning = malformedTelemetryWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + /** * Warn once per load that the section was dropped. * diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b57c3efa537..189344bebee 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -29,6 +29,7 @@ import { spendSchema, chatgptDesktopSchema, skillsConfigSchema, + telemetryConfigSchema, configuredCodexPoolAccountIds, apiKeyEntrySchema, asideProfileSyncSchema, @@ -120,6 +121,7 @@ export const configSchema = z.object({ skills: skillsConfigSchema.optional().catch(undefined), // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts. metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), + telemetry: telemetryConfigSchema.optional().catch(undefined), // Same contract as metricsExport: a malformed hand edit leaves recovery off; live writes reject it. serviceHealthRecovery: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), // Kept raw on purpose: `.catch(undefined)` would turn a mistyped `enabled` into "inherit", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 5df3213e410..d601679717b 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1189,3 +1189,14 @@ export const spendSchema = z.object({ export const skillsConfigSchema = z.object({ catalog_refresh: z.enum(["per_session", "per_turn"]).optional(), }).strict(); + +/** + * Anonymous telemetry consent state. + * + * No endpoint is accepted here: the collector URL is a constant in src/telemetry/sender.ts. + */ +export const telemetryConfigSchema = z.object({ + decision: z.enum(["accepted", "declined"]).optional(), + decidedAt: clientTimestampSchema.optional(), + noticeShownAt: clientTimestampSchema.optional(), +}).strict(); diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8f51e2c9fcf..2661fff01d3 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -60,6 +60,15 @@ export type ServerBackgroundLifecycleLease = { const owners: LeaseOwner[] = []; let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; +let telemetryLoopGeneration = 0; +let stopTelemetryLoop: (() => void) | null = null; + +function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx === "0" || ocx === "false" || ocx === "off" || ocx === "no") return true; + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + return dnt === "1" || dnt === "true" || dnt === "yes"; +} function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); @@ -124,8 +133,22 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next poll tick retries. }); + const generation = ++telemetryLoopGeneration; + if (!isTelemetryDisabledByEnv()) { + void import("../telemetry/scheduler") + .then(scheduler => { + if (generation !== telemetryLoopGeneration) return; + stopTelemetryLoop = scheduler.startTelemetryScheduler(); + }) + .catch(() => { + // Optional background loop. + }); + } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { + telemetryLoopGeneration += 1; + stopTelemetryLoop?.(); + stopTelemetryLoop = null; memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); @@ -138,6 +161,10 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { } function stopProcessLoops(): void { + telemetryLoopGeneration += 1; + const stopTelemetry = stopTelemetryLoop; + stopTelemetryLoop = null; + stopTelemetry?.(); const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/telemetry/aggregate.ts b/src/telemetry/aggregate.ts new file mode 100644 index 00000000000..fc71f1030cb --- /dev/null +++ b/src/telemetry/aggregate.ts @@ -0,0 +1,321 @@ +import type { OcxUsage } from "../types"; +import { normalizeCostTokens } from "../usage/cost"; +import { scanUsageLedgerCooperatively } from "../usage/ledger-scanner"; +import { isKnownRequestFailureCause, type PersistedUsageEntry } from "../usage/log"; +import { classifyRequestOutcome } from "../usage/request-outcome"; +import { + TELEMETRY_MAX_MODELS, + parseCalendarUtcDayMs, + type TelemetryErrorsV1, + type TelemetryFailureCause, + type TelemetryModelFamily, + type TelemetryModelRowV1, + type TelemetryProviderKind, + type TelemetryRequestsV1, +} from "./contract"; + +export interface DisjointTelemetryTokens { + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export interface DailyTelemetryAggregate { + day: string; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const ACCOUNT_SUFFIX_RE = /-(?:main|[pko][0-9a-f]{6})$/i; + +const PRESET_TO_PROVIDER_KIND: Readonly> = Object.freeze({ + openai: "openai", + "openai-apikey": "openai", + "azure-openai": "openai", + chatgpt: "chatgpt", + "openai-multi": "chatgpt", + anthropic: "anthropic", + "anthropic-apikey": "anthropic", + "claude-subscription": "claude-subscription", + "claude-cli": "claude-subscription", + anthropic2: "claude-subscription", + google: "google", + "google-antigravity": "google", + vertex: "vertex", + "google-vertex": "vertex", + xai: "xai", + grok: "xai", + deepseek: "deepseek", + moonshot: "moonshot", + kimi: "moonshot", + "kimi-responses": "moonshot", + "kimi-code": "moonshot", + qwen: "qwen", + "qwen-cloud": "qwen", + alibaba: "qwen", + "alibaba-token-plan": "qwen", + "alibaba-token-plan-intl": "qwen", + zai: "zai", + "zhipu-bigmodel": "zai", + "zhipu-bigmodel-coding": "zai", + "zhipu-bigmodel-responses": "zai", + minimax: "minimax", + "minimax-cn": "minimax", + mistral: "mistral", + openrouter: "openrouter", + groq: "groq", + cerebras: "cerebras", + together: "together", + fireworks: "fireworks", + firepass: "fireworks", + cursor: "cursor", + kiro: "kiro", + copilot: "copilot", + "github-copilot": "copilot", + devin: "devin", + ollama: "ollama", + "ollama-cloud": "ollama", + lmstudio: "lmstudio", + "lm-studio": "lmstudio", + "local-other": "local-other", + vllm: "local-other", + litellm: "local-other", +}); + +/** + * Map a logged provider identifier to the closed vendor-level `TelemetryProviderKind` roster. + * Custom/unknown provider keys never enter the payload; they map to `"other"`. + */ +export function classifyProviderKind(provider: string): TelemetryProviderKind { + const trimmed = provider.trim().toLowerCase(); + if (!trimmed) return "other"; + const direct = PRESET_TO_PROVIDER_KIND[trimmed]; + if (direct) return direct; + const stripped = trimmed.replace(ACCOUNT_SUFFIX_RE, ""); + return PRESET_TO_PROVIDER_KIND[stripped] ?? "other"; +} + +/** + * Map a model identifier to the closed `TelemetryModelFamily` prefix roster. + */ +export function classifyModelFamily(model: string): TelemetryModelFamily { + const trimmed = model.trim().toLowerCase(); + if (!trimmed) return "other"; + const slash = trimmed.lastIndexOf("/"); + const tail = (slash >= 0 ? trimmed.slice(slash + 1) : trimmed).replace(/--[a-z0-9_-]+$/, ""); + if (!tail) return "other"; + if (tail.startsWith("gpt-6")) return "gpt-6"; + if (tail.startsWith("gpt-5")) return "gpt-5"; + if (tail.startsWith("gpt-4")) return "gpt-4"; + if (tail === "o-series" || /^o[134](?:$|[-._])/.test(tail)) return "o-series"; + if (tail.startsWith("codex")) return "codex"; + if (/^claude-(?:[\d.-]+-)?opus(?:$|[-._])/.test(tail) || tail === "claude-opus") return "claude-opus"; + if (/^claude-(?:[\d.-]+-)?sonnet(?:$|[-._])/.test(tail) || tail === "claude-sonnet") return "claude-sonnet"; + if (/^claude-(?:[\d.-]+-)?haiku(?:$|[-._])/.test(tail) || tail === "claude-haiku") return "claude-haiku"; + if (tail.startsWith("gemini-3")) return "gemini-3"; + if (tail.startsWith("gemini-2")) return "gemini-2"; + if (tail.startsWith("grok")) return "grok"; + if (tail.startsWith("deepseek")) return "deepseek"; + if (tail.startsWith("qwen") || tail.startsWith("qwq")) return "qwen"; + if (tail.startsWith("kimi") || tail.startsWith("moonshot")) return "kimi"; + if (tail.startsWith("glm") || tail.startsWith("chatglm")) return "glm"; + if (tail.startsWith("minimax") || tail.startsWith("abab")) return "minimax"; + if ( + tail.startsWith("mistral") + || tail.startsWith("mixtral") + || tail.startsWith("codestral") + || tail.startsWith("pixtral") + || tail.startsWith("ministral") + ) { + return "mistral"; + } + if (tail.startsWith("llama") || tail.startsWith("meta-llama")) return "llama"; + return "other"; +} + +function nonNegInt(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0; +} + +/** + * Decompose `OcxUsage` into five disjoint token buckets whose sum equals `usageDisplayTotalTokens(usage)`: + * - `inputTokens`: uncached input (`normalizeCostTokens`) + * - `cacheReadTokens` + * - `cacheWriteTokens` + * - `outputTokens`: output minus reasoning + * - `reasoningTokens` + */ +export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTelemetryTokens { + if (!usage) { + return { + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + } + const normalized = normalizeCostTokens(usage); + let inputTokens: number; + let cacheReadTokens: number; + let cacheWriteTokens: number; + let totalOutput: number; + if (normalized) { + inputTokens = nonNegInt(normalized.input); + cacheReadTokens = nonNegInt(normalized.cacheRead); + cacheWriteTokens = nonNegInt(normalized.cacheWrite); + totalOutput = nonNegInt(normalized.output); + } else { + const rawInput = nonNegInt(usage.inputTokens); + cacheWriteTokens = Math.min(rawInput, nonNegInt(usage.cacheCreationInputTokens)); + cacheReadTokens = Math.min( + rawInput - cacheWriteTokens, + nonNegInt(usage.cacheReadInputTokens ?? usage.cachedInputTokens), + ); + inputTokens = Math.max(0, rawInput - cacheReadTokens - cacheWriteTokens); + totalOutput = nonNegInt(usage.outputTokens); + } + const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); + const outputTokens = Math.max(0, totalOutput - reasoningTokens); + return { + inputTokens, + cacheReadTokens, + cacheWriteTokens, + outputTokens, + reasoningTokens, + }; +} + +export function aggregateTelemetryEntriesForDay( + day: string, + entries: Iterable, +): DailyTelemetryAggregate { + const dayStartMs = parseCalendarUtcDayMs(day); + if (dayStartMs === null) { + throw new Error(`Invalid UTC day: ${day}`); + } + const dayEndMs = dayStartMs + 86_400_000; + const minutes = new Set(); + const requests: TelemetryRequestsV1 = { + completed: 0, + failed: 0, + incomplete: 0, + aborted: 0, + }; + const modelMap = new Map(); + const errors: TelemetryErrorsV1 = {}; + + const addModelAttribution = (provider: string, model: string, usage: OcxUsage | undefined) => { + const providerKind = classifyProviderKind(provider); + const modelFamily = classifyModelFamily(model); + const key = `${providerKind}\0${modelFamily}`; + let row = modelMap.get(key); + if (!row) { + row = { + providerKind, + modelFamily, + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + modelMap.set(key, row); + } + row.requests += 1; + const tokens = disjointTelemetryTokens(usage); + row.inputTokens += tokens.inputTokens; + row.cacheReadTokens += tokens.cacheReadTokens; + row.cacheWriteTokens += tokens.cacheWriteTokens; + row.outputTokens += tokens.outputTokens; + row.reasoningTokens += tokens.reasoningTokens; + }; + + for (const entry of entries) { + if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { + continue; + } + minutes.add(Math.floor(entry.timestamp / 60_000)); + const outcome = classifyRequestOutcome(entry); + requests[outcome] += 1; + + const cause = entry.failureCause ?? entry.attempts?.at(-1)?.failureCause; + if (cause && isKnownRequestFailureCause(cause)) { + errors[cause as TelemetryFailureCause] = (errors[cause as TelemetryFailureCause] ?? 0) + 1; + } + + if (entry.provider === "combo" && entry.attempts && entry.attempts.length > 0) { + for (const attempt of entry.attempts) { + addModelAttribution(attempt.provider, attempt.model, attempt.usage); + } + } else { + addModelAttribution(entry.provider, entry.model, entry.usage); + } + } + + let models = [...modelMap.values()].sort((a, b) => { + if (b.requests !== a.requests) return b.requests - a.requests; + const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; + const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; + if (bTokens !== aTokens) return bTokens - aTokens; + if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); + return a.modelFamily.localeCompare(b.modelFamily); + }); + + if (models.length > TELEMETRY_MAX_MODELS) { + const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); + const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); + let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); + if (!otherRow) { + otherRow = { + providerKind: "other", + modelFamily: "other", + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + kept.push(otherRow); + } + for (const row of overflow) { + otherRow.requests += row.requests; + otherRow.inputTokens += row.inputTokens; + otherRow.cacheReadTokens += row.cacheReadTokens; + otherRow.cacheWriteTokens += row.cacheWriteTokens; + otherRow.outputTokens += row.outputTokens; + otherRow.reasoningTokens += row.reasoningTokens; + } + models = kept; + } + + return { + day, + activeMinutes: Math.min(1440, minutes.size), + requests, + models, + errors, + }; +} + +export async function aggregateTelemetryForDay( + day: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const entries: PersistedUsageEntry[] = []; + await scanUsageLedgerCooperatively({ + signal: options.signal, + onEntry: entry => { + entries.push(entry); + }, + }); + return aggregateTelemetryEntriesForDay(day, entries); +} diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts new file mode 100644 index 00000000000..5461028ba51 --- /dev/null +++ b/src/telemetry/consent.ts @@ -0,0 +1,127 @@ +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types/telemetry"; + +export type TelemetryStatusReason = + | "env-ocx-telemetry" + | "env-do-not-track" + | "declined" + | "accepted" + | "default-on" + | "awaiting-notice"; + +export type TelemetryEnvOverride = "OCX_TELEMETRY" | "DO_NOT_TRACK" | null; + +export interface TelemetryConsentStatus { + effective: boolean; + reason: TelemetryStatusReason; + decision: OcxTelemetryDecision | null; + decidedAt: string | null; + noticeShownAt: string | null; + envOverride: TelemetryEnvOverride; +} + +const OCX_TELEMETRY_OFF_VALUES = new Set(["0", "false", "off", "no"]); +const DO_NOT_TRACK_ON_VALUES = new Set(["1", "true", "yes"]); + +function extractTelemetryConfig( + input: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, +): OcxTelemetryConfig | undefined { + if (!input || typeof input !== "object") return undefined; + if ("telemetry" in input) { + const nested = (input as { telemetry?: unknown }).telemetry; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as OcxTelemetryConfig) + : undefined; + } + return input as OcxTelemetryConfig; +} + +function validIsoString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || Number.isNaN(Date.parse(trimmed))) return null; + return trimmed; +} + +export function isTelemetryEnvOptOut(env: Record = process.env): TelemetryEnvOverride { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx !== undefined && OCX_TELEMETRY_OFF_VALUES.has(ocx)) { + return "OCX_TELEMETRY"; + } + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + if (dnt !== undefined && DO_NOT_TRACK_ON_VALUES.has(dnt)) { + return "DO_NOT_TRACK"; + } + return null; +} + +export function resolveTelemetryStatus( + config: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + env: Record = process.env, +): TelemetryConsentStatus { + const telemetry = extractTelemetryConfig(config); + const decision = telemetry?.decision === "accepted" || telemetry?.decision === "declined" + ? telemetry.decision + : null; + const decidedAt = validIsoString(telemetry?.decidedAt); + const noticeShownAt = validIsoString(telemetry?.noticeShownAt); + const envOverride = isTelemetryEnvOptOut(env); + + if (envOverride === "OCX_TELEMETRY") { + return { + effective: false, + reason: "env-ocx-telemetry", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (envOverride === "DO_NOT_TRACK") { + return { + effective: false, + reason: "env-do-not-track", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (decision === "declined") { + return { + effective: false, + reason: "declined", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (decision === "accepted") { + return { + effective: true, + reason: "accepted", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (noticeShownAt !== null) { + return { + effective: true, + reason: "default-on", + decision: null, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + return { + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt, + noticeShownAt: null, + envOverride: null, + }; +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts new file mode 100644 index 00000000000..2e645963f9a --- /dev/null +++ b/src/telemetry/contract.ts @@ -0,0 +1,403 @@ +/** + * Shared wire contract for anonymous daily product telemetry (schemaVersion 1). + * + * This module is deliberately import-free so both the local proxy and the Cloudflare Worker + * collector (services/telemetry-collector) can import it directly without pulling runtime + * dependencies across project boundaries. + */ + +export const TELEMETRY_SCHEMA_VERSION = 1 as const; +export const TELEMETRY_MAX_MODELS = 64; +export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d+)?$/; + +export const PROVIDER_KINDS = Object.freeze([ + "openai", + "chatgpt", + "anthropic", + "claude-subscription", + "google", + "vertex", + "xai", + "deepseek", + "moonshot", + "qwen", + "zai", + "minimax", + "mistral", + "openrouter", + "groq", + "cerebras", + "together", + "fireworks", + "cursor", + "kiro", + "copilot", + "devin", + "ollama", + "lmstudio", + "local-other", + "other", +] as const); + +export type TelemetryProviderKind = typeof PROVIDER_KINDS[number]; +export type ProviderKind = TelemetryProviderKind; + +export const MODEL_FAMILIES = Object.freeze([ + "gpt-6", + "gpt-5", + "gpt-4", + "o-series", + "codex", + "claude-opus", + "claude-sonnet", + "claude-haiku", + "gemini-3", + "gemini-2", + "grok", + "deepseek", + "qwen", + "kimi", + "glm", + "minimax", + "mistral", + "llama", + "other", +] as const); + +export type TelemetryModelFamily = typeof MODEL_FAMILIES[number]; +export type ModelFamily = TelemetryModelFamily; + +export const INSTALL_CHANNELS = Object.freeze([ + "npm-global", + "pnpm-global", + "bun-global", + "mise", + "standalone", + "desktop", + "docker", + "source", + "unknown", +] as const); + +export type TelemetryInstallChannel = typeof INSTALL_CHANNELS[number]; +export type InstallChannel = TelemetryInstallChannel; + +export const RELEASE_CHANNELS = Object.freeze([ + "latest", + "preview", +] as const); + +export type TelemetryReleaseChannel = typeof RELEASE_CHANNELS[number]; +export type ReleaseChannel = TelemetryReleaseChannel; + +export const OS_VALUES = Object.freeze([ + "darwin", + "linux", + "win32", + "other", +] as const); + +export type TelemetryOs = typeof OS_VALUES[number]; + +export const ARCH_VALUES = Object.freeze([ + "x64", + "arm64", + "other", +] as const); + +export type TelemetryArch = typeof ARCH_VALUES[number]; + +export const REQUEST_OUTCOME_CLASSES = Object.freeze([ + "completed", + "failed", + "incomplete", + "aborted", +] as const); +export const TELEMETRY_OUTCOME_CLASSES = REQUEST_OUTCOME_CLASSES; +export type TelemetryOutcomeClass = typeof REQUEST_OUTCOME_CLASSES[number]; + +export const REQUEST_FAILURE_CAUSES = Object.freeze([ + "transport-unsent", + "transport-ambiguous", + "upstream-declined", + "rate-limit", + "quota-exhausted", + "credential-rejected", + "policy-refusal", + "parameter-rejected", + "ciphertext-refusal", + "payload-too-large", + "payload-rejected", + "upstream-fault", + "empty-output", + "client-cancelled", + "local-refusal", +] as const); +export const TELEMETRY_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES; +export type TelemetryFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; + +export interface TelemetryEnvV1 { + version: string; + releaseChannel: TelemetryReleaseChannel; + os: TelemetryOs; + arch: TelemetryArch; + installChannel: TelemetryInstallChannel; +} + +export interface TelemetryRequestsV1 { + completed: number; + failed: number; + incomplete: number; + aborted: number; +} + +export interface TelemetryModelRowV1 { + providerKind: TelemetryProviderKind; + modelFamily: TelemetryModelFamily; + requests: number; + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export type TelemetryErrorsV1 = Partial>; + +export interface TelemetryPayloadV1 { + schemaVersion: typeof TELEMETRY_SCHEMA_VERSION; + batchId: string; + day: string; + installDayHash: string; + newInstall: boolean; + env: TelemetryEnvV1; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const PROVIDER_KIND_SET: ReadonlySet = new Set(PROVIDER_KINDS); +const MODEL_FAMILY_SET: ReadonlySet = new Set(MODEL_FAMILIES); +const INSTALL_CHANNEL_SET: ReadonlySet = new Set(INSTALL_CHANNELS); +const RELEASE_CHANNEL_SET: ReadonlySet = new Set(RELEASE_CHANNELS); +const OS_VALUE_SET: ReadonlySet = new Set(OS_VALUES); +const ARCH_VALUE_SET: ReadonlySet = new Set(ARCH_VALUES); +const FAILURE_CAUSE_SET: ReadonlySet = new Set(REQUEST_FAILURE_CAUSES); + +const TOP_LEVEL_KEYS = [ + "schemaVersion", + "batchId", + "day", + "installDayHash", + "newInstall", + "env", + "activeMinutes", + "requests", + "models", + "errors", +] as const; +const ENV_KEYS = ["version", "releaseChannel", "os", "arch", "installChannel"] as const; +const REQUESTS_KEYS = ["completed", "failed", "incomplete", "aborted"] as const; +const MODEL_ROW_KEYS = [ + "providerKind", + "modelFamily", + "requests", + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", +] as const; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX64_RE = /^[0-9a-f]{64}$/; +const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DAY_MS = 86_400_000; +const MAX_REQUEST_COUNT = 10_000_000; +const MAX_TOKEN_COUNT = 1_000_000_000_000; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function hasExactKeys(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record); + if (keys.length !== expected.length) return false; + for (const key of expected) { + if (!Object.hasOwn(record, key)) return false; + } + return true; +} + +function isBoundedInt(value: unknown, max: number): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max; +} + +export function parseCalendarUtcDayMs(day: string): number | null { + const match = DAY_RE.exec(day); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const date = Number(match[3]); + const ms = Date.UTC(year, month - 1, date); + const check = new Date(ms); + if ( + check.getUTCFullYear() !== year + || check.getUTCMonth() !== month - 1 + || check.getUTCDate() !== date + ) { + return null; + } + return ms; +} + +export function validateTelemetryPayloadV1( + input: unknown, + now: number = Date.now(), +): { ok: true; payload: TelemetryPayloadV1 } | { ok: false; error: string } { + if (!isPlainRecord(input)) return { ok: false, error: "payload must be a plain object" }; + if (!hasExactKeys(input, TOP_LEVEL_KEYS)) { + return { ok: false, error: "payload has missing or unexpected top-level keys" }; + } + if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) { + return { ok: false, error: "schemaVersion must be 1" }; + } + if (typeof input.batchId !== "string" || !UUID_RE.test(input.batchId)) { + return { ok: false, error: "batchId must be a UUID" }; + } + if (typeof input.day !== "string") { + return { ok: false, error: "day must be a YYYY-MM-DD string" }; + } + const dayMs = parseCalendarUtcDayMs(input.day); + if (dayMs === null) { + return { ok: false, error: "day must be a valid calendar date in YYYY-MM-DD format" }; + } + const nowDate = new Date(now); + const todayUtcMs = Date.UTC(nowDate.getUTCFullYear(), nowDate.getUTCMonth(), nowDate.getUTCDate()); + if (dayMs < todayUtcMs - 7 * DAY_MS || dayMs > todayUtcMs + DAY_MS) { + return { ok: false, error: "day must be within [today - 7d, today + 1d]" }; + } + if (typeof input.installDayHash !== "string" || !HEX64_RE.test(input.installDayHash)) { + return { ok: false, error: "installDayHash must be 64 lowercase hex characters" }; + } + if (typeof input.newInstall !== "boolean") { + return { ok: false, error: "newInstall must be a boolean" }; + } + if (!isPlainRecord(input.env) || !hasExactKeys(input.env, ENV_KEYS)) { + return { ok: false, error: "env has missing or unexpected keys" }; + } + const env = input.env; + if (typeof env.version !== "string" || !TELEMETRY_VERSION_RE.test(env.version)) { + return { ok: false, error: "env.version must match strict semver without build metadata" }; + } + if (typeof env.releaseChannel !== "string" || !RELEASE_CHANNEL_SET.has(env.releaseChannel)) { + return { ok: false, error: "env.releaseChannel is not in RELEASE_CHANNELS" }; + } + if (typeof env.os !== "string" || !OS_VALUE_SET.has(env.os)) { + return { ok: false, error: "env.os is not in OS_VALUES" }; + } + if (typeof env.arch !== "string" || !ARCH_VALUE_SET.has(env.arch)) { + return { ok: false, error: "env.arch is not in ARCH_VALUES" }; + } + if (typeof env.installChannel !== "string" || !INSTALL_CHANNEL_SET.has(env.installChannel)) { + return { ok: false, error: "env.installChannel is not in INSTALL_CHANNELS" }; + } + if (!isBoundedInt(input.activeMinutes, 1440)) { + return { ok: false, error: "activeMinutes must be an integer between 0 and 1440" }; + } + if (!isPlainRecord(input.requests) || !hasExactKeys(input.requests, REQUESTS_KEYS)) { + return { ok: false, error: "requests has missing or unexpected keys" }; + } + for (const key of REQUESTS_KEYS) { + if (!isBoundedInt(input.requests[key], MAX_REQUEST_COUNT)) { + return { ok: false, error: `requests.${key} must be a bounded non-negative integer` }; + } + } + if (!Array.isArray(input.models) || input.models.length > TELEMETRY_MAX_MODELS) { + return { ok: false, error: `models must be an array of at most ${TELEMETRY_MAX_MODELS} rows` }; + } + const seenPairs = new Set(); + const models: TelemetryModelRowV1[] = []; + for (const row of input.models) { + if (!isPlainRecord(row) || !hasExactKeys(row, MODEL_ROW_KEYS)) { + return { ok: false, error: "models row has missing or unexpected keys" }; + } + if (typeof row.providerKind !== "string" || !PROVIDER_KIND_SET.has(row.providerKind)) { + return { ok: false, error: "models row providerKind is not in PROVIDER_KINDS" }; + } + if (typeof row.modelFamily !== "string" || !MODEL_FAMILY_SET.has(row.modelFamily)) { + return { ok: false, error: "models row modelFamily is not in MODEL_FAMILIES" }; + } + const pairKey = `${row.providerKind}\0${row.modelFamily}`; + if (seenPairs.has(pairKey)) { + return { ok: false, error: "models rows must have unique (providerKind, modelFamily) pairs" }; + } + seenPairs.add(pairKey); + if (!isBoundedInt(row.requests, MAX_REQUEST_COUNT)) { + return { ok: false, error: "models row requests must be a bounded non-negative integer" }; + } + for (const tokenField of [ + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", + ] as const) { + if (!isBoundedInt(row[tokenField], MAX_TOKEN_COUNT)) { + return { ok: false, error: `models row ${tokenField} must be a bounded non-negative integer` }; + } + } + models.push({ + providerKind: row.providerKind as TelemetryProviderKind, + modelFamily: row.modelFamily as TelemetryModelFamily, + requests: row.requests, + inputTokens: row.inputTokens as number, + cacheReadTokens: row.cacheReadTokens as number, + cacheWriteTokens: row.cacheWriteTokens as number, + outputTokens: row.outputTokens as number, + reasoningTokens: row.reasoningTokens as number, + }); + } + if (!isPlainRecord(input.errors)) { + return { ok: false, error: "errors must be a plain object" }; + } + const errors: TelemetryErrorsV1 = {}; + for (const [cause, count] of Object.entries(input.errors)) { + if (!FAILURE_CAUSE_SET.has(cause)) { + return { ok: false, error: `errors key "${cause}" is not in REQUEST_FAILURE_CAUSES` }; + } + if (!isBoundedInt(count, MAX_REQUEST_COUNT)) { + return { ok: false, error: `errors["${cause}"] must be a bounded non-negative integer` }; + } + errors[cause as TelemetryFailureCause] = count; + } + return { + ok: true, + payload: { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: input.batchId.toLowerCase(), + day: input.day, + installDayHash: input.installDayHash, + newInstall: input.newInstall, + env: { + version: env.version, + releaseChannel: env.releaseChannel as TelemetryReleaseChannel, + os: env.os as TelemetryOs, + arch: env.arch as TelemetryArch, + installChannel: env.installChannel as TelemetryInstallChannel, + }, + activeMinutes: input.activeMinutes, + requests: { + completed: input.requests.completed as number, + failed: input.requests.failed as number, + incomplete: input.requests.incomplete as number, + aborted: input.requests.aborted as number, + }, + models, + errors, + }, + }; +} diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts new file mode 100644 index 00000000000..a69e3882c37 --- /dev/null +++ b/src/telemetry/install-id.ts @@ -0,0 +1,197 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fchmodSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; + +export const TELEMETRY_ID_FILE = "telemetry-id.json"; +export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); + +export interface TelemetryInstallIdentity { + installId: string; + createdAt: string; +} + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; +const PUBLISHED_READ_ATTEMPTS = 50; +const PUBLISHED_READ_INTERVAL_MS = 10; + +let memo: { path: string; identity: TelemetryInstallIdentity } | null = null; + +export function telemetryIdPath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_ID_FILE); +} + +function parseTelemetryId(text: string): TelemetryInstallIdentity | null { + try { + const raw = JSON.parse(text) as Record; + if (raw?.version !== 1 || typeof raw.installId !== "string" || typeof raw.createdAt !== "string") { + return null; + } + if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { + return null; + } + return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + } catch { + return null; + } +} + +/** Read-only: returns the persisted telemetry install identity, or null when absent/invalid. */ +export function readTelemetryInstallId(configDir = getConfigDir()): TelemetryInstallIdentity | null { + const path = telemetryIdPath(configDir); + if (memo?.path === path) return memo.identity; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + } catch { + return null; + } + let identity: TelemetryInstallIdentity | null; + try { + identity = parseTelemetryId(readFileSync(path, "utf8")); + } catch { + return null; + } + if (identity) memo = { path, identity }; + return identity; +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +function createExclusive(path: string, content: string): void { + const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600); + try { + if (process.platform !== "win32") { + try { fchmodSync(fd, 0o600); } catch { /* best-effort */ } + } + writeSync(fd, content); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function syncParentDirectory(path: string): void { + if (process.platform === "win32") return; + try { + const fd = openSync(dirname(path), fsConstants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } + } catch { /* best-effort */ } +} + +function pause(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function adoptPublishedIdentity(configDir: string, path: string): TelemetryInstallIdentity { + for (let attempt = 0; attempt < PUBLISHED_READ_ATTEMPTS; attempt++) { + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + stat = null; + } + if (stat && (stat.isSymbolicLink() || !stat.isFile())) break; + const identity = readTelemetryInstallId(configDir); + if (identity) return identity; + if (attempt + 1 < PUBLISHED_READ_ATTEMPTS) pause(PUBLISHED_READ_INTERVAL_MS); + } + throw new Error("telemetry identity file is not a valid regular file; remove it to rotate the identity"); +} + +/** + * Create `/telemetry-id.json` (`0o600`, exclusive no-follow) on the first send. + * Returns `{ identity, freshlyCreated }` so the caller knows when a brand-new id was minted. + */ +export function ensureTelemetryInstallId( + configDir = getConfigDir(), +): { identity: TelemetryInstallIdentity; freshlyCreated: boolean } { + const existing = readTelemetryInstallId(configDir); + if (existing) return { identity: existing, freshlyCreated: false }; + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryIdPath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + let present = true; + try { + lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + present = false; + } + if (present) { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + const identity: TelemetryInstallIdentity = { + installId: randomUUID().toLowerCase(), + createdAt: new Date().toISOString(), + }; + const content = `${JSON.stringify({ version: 1, ...identity })}\n`; + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let published = false; + try { + createExclusive(temp, content); + try { + linkSync(temp, path); + published = true; + } catch (error) { + const code = errorCode(error); + if (code === "EEXIST") { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error; + } + } finally { + try { unlinkSync(temp); } catch { /* best-effort */ } + } + if (!published) { + try { + createExclusive(path, content); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + } + syncParentDirectory(path); + memo = { path, identity }; + return { identity, freshlyCreated: true }; +} + +/** Delete `/telemetry-id.json` when telemetry is declined. */ +export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { + const path = telemetryIdPath(configDir); + if (memo?.path === path) memo = null; + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + unlinkSync(path); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + return false; + } +} + +export function computeInstallDayHash(installId: string, day: string): string { + return createHash("sha256").update(`${installId}:${day}`).digest("hex"); +} + +export function resetTelemetryInstallIdMemoForTests(): void { + memo = null; +} diff --git a/src/telemetry/payload.ts b/src/telemetry/payload.ts new file mode 100644 index 00000000000..628f434f2af --- /dev/null +++ b/src/telemetry/payload.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { packageVersion } from "../lib/package-version"; +import { detectInstallOwnership } from "../update"; +import type { DailyTelemetryAggregate } from "./aggregate"; +import { + ARCH_VALUES, + OS_VALUES, + TELEMETRY_SCHEMA_VERSION, + TELEMETRY_VERSION_RE, + validateTelemetryPayloadV1, + type TelemetryArch, + type TelemetryInstallChannel, + type TelemetryOs, + type TelemetryPayloadV1, + type TelemetryReleaseChannel, +} from "./contract"; + +export interface ResolveInstallChannelOptions { + env?: Record; + exists?: (path: string) => boolean; + detectOwnership?: () => { installer?: string }; +} + +export function resolveInstallChannel(options: ResolveInstallChannelOptions = {}): TelemetryInstallChannel { + const env = options.env ?? process.env; + if (env.OCX_DESKTOP_SUPERVISED === "1") return "desktop"; + const exists = options.exists ?? existsSync; + try { + if (exists("/.dockerenv") || exists("/run/.containerenv")) return "docker"; + } catch { /* ignore fs errors */ } + try { + const installer = (options.detectOwnership ?? detectInstallOwnership)()?.installer; + switch (installer) { + case "npm": + return "npm-global"; + case "pnpm": + return "pnpm-global"; + case "bun": + return "bun-global"; + case "mise": + return "mise"; + case "standalone": + return "standalone"; + case "source": + return "source"; + default: + return "unknown"; + } + } catch { + return "unknown"; + } +} + +export function normalizeTelemetryVersion(raw = packageVersion("0.0.0")): { + version: string; + releaseChannel: TelemetryReleaseChannel; +} { + const stripped = raw.trim().replace(/^v/, "").split("+")[0] ?? "0.0.0"; + const version = TELEMETRY_VERSION_RE.test(stripped) ? stripped : "0.0.0"; + const releaseChannel: TelemetryReleaseChannel = version.includes("-preview.") ? "preview" : "latest"; + return { version, releaseChannel }; +} + +export function resolveTelemetryOs(platform: string = process.platform): TelemetryOs { + return (OS_VALUES as readonly string[]).includes(platform) && platform !== "other" + ? (platform as TelemetryOs) + : "other"; +} + +export function resolveTelemetryArch(arch: string = process.arch): TelemetryArch { + return (ARCH_VALUES as readonly string[]).includes(arch) && arch !== "other" + ? (arch as TelemetryArch) + : "other"; +} + +export interface BuildTelemetryPayloadOptions { + aggregate: DailyTelemetryAggregate; + installDayHash: string; + newInstall: boolean; + batchId?: string; + version?: string; + releaseChannel?: TelemetryReleaseChannel; + os?: TelemetryOs; + arch?: TelemetryArch; + installChannel?: TelemetryInstallChannel; + now?: number; +} + +export function buildTelemetryPayloadV1(options: BuildTelemetryPayloadOptions): TelemetryPayloadV1 { + const versionInfo = normalizeTelemetryVersion(options.version); + const candidate: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: options.batchId ?? randomUUID(), + day: options.aggregate.day, + installDayHash: options.installDayHash, + newInstall: options.newInstall, + env: { + version: options.version ?? versionInfo.version, + releaseChannel: options.releaseChannel ?? versionInfo.releaseChannel, + os: options.os ?? resolveTelemetryOs(), + arch: options.arch ?? resolveTelemetryArch(), + installChannel: options.installChannel ?? resolveInstallChannel(), + }, + activeMinutes: options.aggregate.activeMinutes, + requests: { + completed: options.aggregate.requests.completed, + failed: options.aggregate.requests.failed, + incomplete: options.aggregate.requests.incomplete, + aborted: options.aggregate.requests.aborted, + }, + models: options.aggregate.models.map(row => ({ + providerKind: row.providerKind, + modelFamily: row.modelFamily, + requests: row.requests, + inputTokens: row.inputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + outputTokens: row.outputTokens, + reasoningTokens: row.reasoningTokens, + })), + errors: { ...options.aggregate.errors }, + }; + const validated = validateTelemetryPayloadV1(candidate, options.now); + if (!validated.ok) { + throw new Error(`Invalid telemetry payload: ${validated.error}`); + } + return validated.payload; +} diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts new file mode 100644 index 00000000000..604c535d0ec --- /dev/null +++ b/src/telemetry/scheduler.ts @@ -0,0 +1,246 @@ +import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readConfigDiagnostics } from "../config/diagnostics"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; +import type { OcxConfig } from "../types"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; +import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; +import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; + +export const TELEMETRY_STATE_FILE = "telemetry-state.json"; +export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const DAY_MS = 86_400_000; +const MAX_BACKLOG_DAYS = 7; +const STATE_RETENTION_DAYS = 30; + +export interface TelemetryState { + version: 1; + sentDays: Record; + lastSentDay: string | null; + lastSentAt: string | null; + firstReportSentForInstallId: string | null; +} + +export function telemetryStatePath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_STATE_FILE); +} + +function emptyState(): TelemetryState { + return { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + firstReportSentForInstallId: null, + }; +} + +export function readTelemetryState(configDir = getConfigDir()): TelemetryState { + const path = telemetryStatePath(configDir); + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return emptyState(); + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { + return emptyState(); + } + const sentDays: Record = {}; + for (const [day, at] of Object.entries(raw.sentDays as Record)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { + sentDays[day] = at; + } + } + return { + version: 1, + sentDays, + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + firstReportSentForInstallId: + typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, + }; + } catch { + return emptyState(); + } +} + +export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryStatePath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); + const prunedSentDays: Record = {}; + for (const [day, at] of Object.entries(state.sentDays)) { + if (day >= minKeepDay) prunedSentDays[day] = at; + } + const payload: TelemetryState = { + version: 1, + sentDays: prunedSentDays, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + firstReportSentForInstallId: state.firstReportSentForInstallId, + }; + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +export function utcMidnightMs(epochMs: number): number { + const d = new Date(epochMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} + +export function latestCompletedUtcDay(now = Date.now()): string { + return formatUtcDay(utcMidnightMs(now) - DAY_MS); +} + +export interface TelemetryTickOptions extends TelemetrySendOptions, ResolveInstallChannelOptions { + configDir?: string; + config?: Pick + readConfig?: () => Pick; + aggregateDay?: (day: string) => Promise; +} + +export interface TelemetryTickResult { + sent: boolean; + day?: string; + reason: string; + sendResult?: TelemetrySendResult; +} + +/** + * Run one telemetry tick: + * - Re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`. + * - Sends only completed UTC days on or after the UTC day of `decidedAt ?? noticeShownAt` + * (no pre-consent backfill). + * - Sends at most ONE day per tick, oldest first, at most the last 7 completed days. + */ +export async function runTelemetryTick(options: TelemetryTickOptions = {}): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const config = options.config + ?? (options.readConfig ? options.readConfig() : readConfigDiagnostics({ passive: true }).config); + const status = resolveTelemetryStatus(config, env); + if (!status.effective) { + return { sent: false, reason: status.reason }; + } + const consentTimestamp = status.decidedAt ?? status.noticeShownAt; + if (!consentTimestamp) { + return { sent: false, reason: "missing-consent-timestamp" }; + } + const consentMs = Date.parse(consentTimestamp); + if (Number.isNaN(consentMs)) { + return { sent: false, reason: "invalid-consent-timestamp" }; + } + const consentDay = formatUtcDay(consentMs); + const todayMs = utcMidnightMs(now); + const state = readTelemetryState(configDir); + + let targetDay: string | null = null; + for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { + const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); + if (candidateDay < consentDay) continue; + if (state.sentDays[candidateDay]) continue; + targetDay = candidateDay; + break; + } + if (!targetDay) { + return { sent: false, reason: "up-to-date" }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + const newInstall = state.firstReportSentForInstallId !== identity.installId; + const installDayHash = computeInstallDayHash(identity.installId, targetDay); + const aggregate = await (options.aggregateDay + ? options.aggregateDay(targetDay) + : aggregateTelemetryForDay(targetDay, { signal: options.signal })); + const payload = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + now, + installChannel: resolveInstallChannel(options), + }); + const sendResult = await sendTelemetryPayload(payload, { ...options, now }); + if (!sendResult.ok) { + return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + } + const nowIso = new Date(now).toISOString(); + state.sentDays[targetDay] = nowIso; + state.lastSentDay = targetDay; + state.lastSentAt = nowIso; + state.firstReportSentForInstallId = identity.installId; + writeTelemetryState(state, configDir, now); + return { sent: true, day: targetDay, reason: "sent", sendResult }; +} + +let startupTimer: ReturnType | null = null; +let intervalTimer: ReturnType | null = null; +let detachShutdownHook: (() => void) | null = null; +let activeTick: Promise | null = null; +let running = false; + +function triggerBackgroundTick(): void { + if (!running || activeTick) return; + activeTick = runTelemetryTick() + .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) + .finally(() => { + activeTick = null; + }); +} + +export function startTelemetryScheduler(options: { startupDelayMs?: number; intervalMs?: number } = {}): () => void { + if (isTelemetryEnvOptOut(process.env) !== null) { + return () => {}; + } + if (running) return stopTelemetryScheduler; + running = true; + detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); + const startupDelay = options.startupDelayMs ?? (30_000 + Math.floor(Math.random() * 30_000)); + const intervalMs = options.intervalMs ?? TELEMETRY_TICK_INTERVAL_MS; + startupTimer = setTimeout(() => { + startupTimer = null; + triggerBackgroundTick(); + }, startupDelay); + startupTimer.unref?.(); + intervalTimer = setInterval(() => { + triggerBackgroundTick(); + }, intervalMs); + intervalTimer.unref?.(); + return stopTelemetryScheduler; +} + +export function stopTelemetryScheduler(): void { + running = false; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } + if (detachShutdownHook) { + const detach = detachShutdownHook; + detachShutdownHook = null; + detach(); + } +} + +export function nudgeTelemetryScheduler(): void { + if (!running) return; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + const timer = setTimeout(() => { + triggerBackgroundTick(); + }, 10); + timer.unref?.(); +} diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts new file mode 100644 index 00000000000..d7a4be4b4a8 --- /dev/null +++ b/src/telemetry/sender.ts @@ -0,0 +1,63 @@ +import { signalWithTimeout } from "../lib/abort"; +import { validateTelemetryPayloadV1, type TelemetryPayloadV1 } from "./contract"; + +export const TELEMETRY_COLLECTOR_ENDPOINT = "https://telemetry.opencodex.me/v1/events"; +export const TELEMETRY_SEND_TIMEOUT_MS = 10_000; + +export interface TelemetrySendOptions { + endpoint?: string; + fetch?: typeof fetch; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +} + +export interface TelemetrySendResult { + ok: boolean; + status?: number; + error?: string; +} + +let testOverrides: TelemetrySendOptions | null = null; + +export function setTelemetrySenderOverridesForTests(overrides: TelemetrySendOptions | null): void { + testOverrides = overrides; +} + +export async function sendTelemetryPayload( + payload: TelemetryPayloadV1, + options: TelemetrySendOptions = {}, +): Promise { + const now = options.now ?? testOverrides?.now ?? Date.now(); + const validated = validateTelemetryPayloadV1(payload, now); + if (!validated.ok) { + return { ok: false, error: validated.error }; + } + const endpoint = options.endpoint ?? testOverrides?.endpoint ?? TELEMETRY_COLLECTOR_ENDPOINT; + const fetchImpl = options.fetch ?? testOverrides?.fetch ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? testOverrides?.timeoutMs ?? TELEMETRY_SEND_TIMEOUT_MS; + const parentSignal = options.signal ?? testOverrides?.signal; + + const deadline = signalWithTimeout(timeoutMs, parentSignal); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(validated.payload), + signal: deadline.signal, + }); + if (!response.ok) { + return { ok: false, status: response.status, error: `collector responded ${response.status}` }; + } + return { ok: true, status: response.status }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + deadline.cleanup(); + } +} diff --git a/src/types.ts b/src/types.ts index 8da67a3db8b..9937eeb1465 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,3 +146,5 @@ export type { CodexAccountCredentials, CodexAccountCredentialRecord, } from "./types/accounts"; + +export type { OcxTelemetryConfig, OcxTelemetryDecision } from "./types/telemetry"; diff --git a/src/types/config.ts b/src/types/config.ts index 1bc480ef5c4..87dfc40cd36 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ import type { CodexAccount } from "./accounts"; import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { OcxFallbackConfig } from "./fallback"; +import type { OcxTelemetryConfig } from "./telemetry"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow } from "./anthropic-account-pool"; export type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackKind, OcxFallbackSwitches } from "./fallback"; @@ -574,6 +575,7 @@ export interface OcxConfig { skills?: OcxSkillsConfig; /** Opt-in process-local aggregate request metrics on the authenticated management plane. */ metricsExport?: { enabled?: boolean }; + telemetry?: OcxTelemetryConfig; /** * Opt-in recovery of a managed service proxy that stays alive but stops answering /healthz: * a separate sentinel process terminates it so its supervisor restarts it (#6671). diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts new file mode 100644 index 00000000000..e9ea8039b7c --- /dev/null +++ b/src/types/telemetry.ts @@ -0,0 +1,15 @@ +export type OcxTelemetryDecision = "accepted" | "declined"; + +/** + * Anonymous product telemetry consent state. + * + * Absence of `decision` means undecided. No payload is sent until either `decision` is + * `"accepted"` or a human has seen the disclosure (`noticeShownAt` recorded via interactive + * TTY or dashboard consent session). The collector endpoint is a code constant and is never + * persisted here. + */ +export interface OcxTelemetryConfig { + decision?: OcxTelemetryDecision; + decidedAt?: string; + noticeShownAt?: string; +} diff --git a/tests/preload.ts b/tests/preload.ts index 0e66597830a..6f94c5eac4a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -76,6 +76,7 @@ process.env.OCX_TEST_HOME_GUARD = "1"; // Lets a test assert one preload per process rather than assuming Bun's scheduling. process.env.OCX_TEST_PRELOAD_PID = String(process.pid); process.env.OCX_DISABLE_UPDATE_CHECK = "1"; +process.env.OCX_TELEMETRY = "0"; process.env.OPENCODEX_KIRO_MODEL_DISCOVERY = "0"; if (!isTestHomeGuardArmed() || !protectedHomeForTests()) { From 643dd4f259bc45237cfee1ad1ad643f1eec5047b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 03/29] feat(telemetry): add management API routes, CLI commands, and consent prompts --- AGENTS_INSTALL.md | 10 + scripts/generate-ocx-skill-surface.ts | 2 +- .../ocx/references/01_management_surface.md | 18 +- .../references/01_surface_observe-system.md | 55 ++++- src/cli/capabilities-observe-system.ts | 30 +++ src/cli/config-command.ts | 11 +- src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/init.ts | 4 + src/cli/registry.ts | 10 + src/cli/star-prompt.ts | 2 + src/cli/telemetry.ts | 188 ++++++++++++++++++ src/server/management-api.ts | 7 + src/server/management/route-registry.ts | 4 + src/server/management/telemetry-routes.ts | 20 ++ src/service/cli.ts | 1 + src/telemetry/routes.ts | 172 ++++++++++++++++ 17 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 src/cli/telemetry.ts create mode 100644 src/server/management/telemetry-routes.ts create mode 100644 src/telemetry/routes.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index c4a80069fd7..e5489ddc853 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,16 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +### Telemetry + +Anonymous daily usage telemetry defaults to on only after a human has seen the +disclosure notice (in the GUI or an interactive TTY prompt). An agent must never +enable telemetry or acknowledge the telemetry disclosure on the user's behalf: +`PUT /api/telemetry`, `POST /api/telemetry/notice`, `ocx telemetry enable`, and +`ocx config set telemetry` refuse agent-driven callers with +`agent_consent_required`. Opting out (`ocx telemetry disable` or +`OCX_TELEMETRY=0`) is always permitted. + ## Where things are installed An agent asked to install, inspect, repair, or remove opencodex needs to know what is written diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index 750ea21e566..155e866656b 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -21,7 +21,7 @@ const DOMAINS = [ { name: "accounts", roots: ["account", "auth", "login", "logout"] }, { name: "agents-routing", roots: ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"] }, { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, - { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, + { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"] }, { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"] }, { name: "lab", roots: ["lab"] }, ]; diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index d49db6cfa0b..ab478e6c196 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 43 | | [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 99 | +| [observe-system](01_surface_observe-system.md) | 102 | | [access-remote](01_surface_access-remote.md) | 34 | | [lab](01_surface_lab.md) | 21 | @@ -1371,6 +1371,18 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-companion-timeline) +### `ocx telemetry status` + +[Read-oriented task](01_surface_observe-system.md#ocx-telemetry-status) + +### `ocx telemetry enable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-enable) + +### `ocx telemetry disable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-disable) + ### `ocx disconnect` [State-changing task](01_surface_access-remote.md#ocx-disconnect) @@ -1529,6 +1541,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 370 -- of those, state-changing: 228 +- declared capabilities: 373 +- of those, state-changing: 230 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 363e5d2b026..2e132e543d2 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 99. +Declared capabilities: 102. ### `ocx update` @@ -1871,3 +1871,56 @@ State-changing: no. JSON mode: `payload`. - start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic. - Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference. + +### `ocx telemetry status` + +Usage: `ocx telemetry status [--json]` + +Inspect anonymous telemetry consent status and the next daily payload preview. + +State-changing: no. JSON mode: `payload`. + +| Method | Route | +|---|---| +| GET | `/api/telemetry` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the telemetry status and preview as JSON. | + +- Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped. +- Never creates or transmits a telemetry install identifier. + +### `ocx telemetry enable` + +Usage: `ocx telemetry enable [--json]` + +Opt in to anonymous daily usage telemetry. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json. +- Refuses with agent_consent_required when called from an agent-driven session. + +### `ocx telemetry disable` + +Usage: `ocx telemetry disable [--json]` + +Opt out of anonymous daily usage telemetry and delete the local telemetry identifier. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = declined in local config.json and removes telemetry-id.json. +- Always permitted, including in agent-driven sessions. diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 9f413835d54..4692f318666 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -729,4 +729,34 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic.","Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference."], }, + { + command: ["telemetry", "status"], + usage: "ocx telemetry status [--json]", + summary: "Inspect anonymous telemetry consent status and the next daily payload preview.", + routes: [{"method": "GET", "path": "/api/telemetry"}], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the telemetry status and preview as JSON."}], + mutates: false, + json: "payload", + details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Never creates or transmits a telemetry install identifier."], + }, + { + command: ["telemetry", "enable"], + usage: "ocx telemetry enable [--json]", + summary: "Opt in to anonymous daily usage telemetry.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json.", "Refuses with agent_consent_required when called from an agent-driven session."], + }, + { + command: ["telemetry", "disable"], + usage: "ocx telemetry disable [--json]", + summary: "Opt out of anonymous daily usage telemetry and delete the local telemetry identifier.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = declined in local config.json and removes telemetry-id.json.", "Always permitted, including in agent-driven sessions."], + }, ]; diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 8cd643a8d8e..1a03189d27b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -9,6 +9,7 @@ import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; import { redactUrlForLog } from "../lib/redact"; +import { isAgentDriven } from "./agent-driven"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -258,6 +259,9 @@ export async function handleConfigCommand(argv: string[]): Promise { const raw = action === "set" ? args.shift() : undefined; if (!path || (action === "set" && raw === undefined)) throw new CliUsageError(action === "unset" ? "config path is required" : "config path and value are required", USAGE); rejectArgs(args, USAGE); + if (pathSegments(path)[0] === "telemetry") { + throw new CliUsageError("telemetry cannot be modified via ocx config; use `ocx telemetry enable` or `ocx telemetry disable`", USAGE); + } // #1835/#1838: the read used to happen OUTSIDE the mutation lock, so a concurrent // edit landing between it and the save was reverted by this whole-snapshot write. // `mutatePersistedConfig` reruns this callback against the latest validated disk @@ -332,7 +336,12 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { readCliConfigForMutation(); - const imported = validate(loadInput(path)); + const rawInput = loadInput(path); + if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + delete (rawInput as Record).telemetry; + console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); + } + const imported = validate(rawInput); withConfigMutationLockSync(() => { const client = readClientConnectionState(); if (client.kind === "connected" && (imported.runtimeRole !== "client" diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1a9fd889871..6cbe047f2a1 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -913,6 +913,10 @@ const commandRunners: Record = { const { handleCommandcodeCommand } = await import("./integrations"); return await handleCommandcodeCommand(deps.args.slice(1)); }, + telemetry: async deps => { + const { handleTelemetryCommand } = await import("./telemetry"); + return await handleTelemetryCommand(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 67780c76753..ffa5a3b711f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -102,6 +102,7 @@ Usage: ocx config [sub] Validated configuration show/get/set/import/export ocx advisor Advisor sidecar settings and context-sharing consent ocx companion Menu-bar and widget companion usage settings + ocx telemetry Anonymous usage telemetry status, preview, enable, and disable ocx lab Inspect Lab evidence and control local automation ocx codex-provider Confirm custom Codex provider routing: status|allow|revoke ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) diff --git a/src/cli/init.ts b/src/cli/init.ts index b8866eccb3e..9bb41103e09 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,6 +9,7 @@ import { redactUserPath } from "../lib/redact"; import { enrichProviderFromCatalog } from "../oauth/key-providers"; import { deriveInitProviders } from "../providers/derive"; import { pinSponsorsWithinKind } from "../providers/sponsor-order"; +import { maybeShowTelemetryPrompt } from "./telemetry"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** Parse a setup TCP port; only an empty answer selects the default. */ @@ -293,4 +294,7 @@ export async function runInit(): Promise { } finally { prompt.close(); } + if (configCreated && !process.exitCode) { + await maybeShowTelemetryPrompt(); + } } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a9f48283878..749de00e31e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -834,6 +834,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx __gui-update-worker [channel] [restart]", summary: "Hidden internal: run the GUI update worker.", }, + { + name: "telemetry", + usage: "ocx telemetry [--json]", + summary: "Inspect or change anonymous daily usage telemetry consent and preview the payload.", + details: [ + "status [--json] Show effective state, reason, install channel, last sent day, and exact preview payload.", + "enable [--json] Opt in to anonymous telemetry (refused with agent_consent_required when agent-driven).", + "disable [--json] Opt out of anonymous telemetry and delete telemetry-id.json.", + ], + }, ]; export function findCommand(name: string): CliCommandEntry | undefined { diff --git a/src/cli/star-prompt.ts b/src/cli/star-prompt.ts index 3135e86bc6b..46131ec4124 100644 --- a/src/cli/star-prompt.ts +++ b/src/cli/star-prompt.ts @@ -177,6 +177,8 @@ function printAgentDeferral(): void { */ export async function maybeShowStarPrompt(): Promise { try { + const { maybeShowTelemetryPrompt } = await import("./telemetry"); + await maybeShowTelemetryPrompt(); let isTty = false; try { isTty = depsForTests?.isTty ? depsForTests.isTty() : isatty(0) && isatty(1); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts new file mode 100644 index 00000000000..60ffd2f652d --- /dev/null +++ b/src/cli/telemetry.ts @@ -0,0 +1,188 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { isatty } from "node:tty"; +import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { resolveTelemetryStatus } from "../telemetry/consent"; +import { deleteTelemetryInstallId } from "../telemetry/install-id"; +import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; +import { currentVersion } from "../update/index"; +import { isAgentDriven } from "./agent-driven"; +import { interactiveConfirm } from "./interactive-confirm"; +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx telemetry [status] [--json] + ocx telemetry enable [--json] + ocx telemetry disable [--json]`; + +export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; +const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export function isTelemetryDeferralCurrent(record: string | null, version: string, now: number): boolean { + if (!record) return false; + const match = /^(\S+)\s+(\S+)\s*$/.exec(record.trim()); + if (!match) return false; + const at = Date.parse(match[1]!); + if (Number.isNaN(at)) return false; + const age = now - at; + if (version !== "?" && match[2] === version) return age >= 0; + return age >= 0 && age < DEFERRAL_MAX_AGE_MS; +} + +export interface TelemetryPromptTestDeps { + interactiveConfirm?: typeof interactiveConfirm; + isTty?: () => boolean; +} + +let promptDepsForTests: TelemetryPromptTestDeps | null = null; + +export function setTelemetryPromptDepsForTests(deps: TelemetryPromptTestDeps | null): void { + promptDepsForTests = deps; +} + +async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { + const nowIso = new Date().toISOString(); + const dir = getConfigDir(); + await withConfigDirHardeningSettled(dir, () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + return { changed, value: next }; + }), + ); + if (decision === "declined") { + deleteTelemetryInstallId(dir); + } +} + +/** + * One-time telemetry consent prompt for interactive starts (`ocx start`, `ocx service install`, + * `ocx init`). Under an agent-driven session, non-TTY, or `OCX_SERVICE=1`, prints a one-line + * notice at most once per version and records neither `noticeShownAt` nor `decision`. + */ +export async function maybeShowTelemetryPrompt(): Promise { + try { + const diagnostics = readConfigDiagnostics({ passive: true }); + const status = resolveTelemetryStatus(diagnostics.config, process.env); + if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { + return; + } + let isTty = false; + try { + isTty = promptDepsForTests?.isTty ? promptDepsForTests.isTty() : isatty(0) && isatty(1); + } catch { + /* best-effort */ + } + const dir = getConfigDir(); + if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { + const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); + let record: string | null = null; + try { + if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + } catch { /* best-effort */ } + const version = currentVersion(); + if (isTelemetryDeferralCurrent(record, version, Date.now())) return; + console.log( + " ℹ️ Anonymous telemetry is awaiting human consent (run 'ocx telemetry status' or open the dashboard; disable anytime with 'ocx telemetry disable' or OCX_TELEMETRY=0).", + ); + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + recordOwnedConfigPath(dir, deferralPath); + writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + } catch { /* best-effort */ } + return; + } + const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; + const yes = await ask({ + question: "\n Share anonymous usage statistics? [Y/n]", + defaultYes: true, + }); + await persistTelemetryDecision(yes ? "accepted" : "declined"); + } catch { + /* never disrupt startup or setup */ + } +} + +function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { + return [ + `Telemetry: ${snapshot.effective ? "enabled" : "disabled"} (${snapshot.reason})`, + `Decision: ${snapshot.decision ?? "undecided"}`, + ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), + `Install channel: ${snapshot.installChannel}`, + `Last sent day: ${snapshot.lastSentDay ?? "never"}`, + `Preview (${snapshot.preview.day}):`, + JSON.stringify(snapshot.preview, null, 2), + ]; +} + +async function readLiveOrLocalSnapshot(deps: RuntimeApiDeps): Promise { + try { + return await runtimeRequest("/api/telemetry", {}, deps) as TelemetryStatusSnapshot; + } catch { + const config = readConfigDiagnostics({ passive: true }).config; + return await buildTelemetryStatusSnapshot(config); + } +} + +export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const sub = (args.shift() ?? "status").toLowerCase(); + if (sub === "enable" && isAgentDriven()) { + const error = { + ok: false, + code: "agent_consent_required", + error: "Refused: telemetry consent must be enabled by a human user in their own terminal or dashboard.", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } + return runCliAction(async () => { + if (args.includes("--json")) { + throw new CliUsageError("telemetry flags may only be specified once", USAGE); + } + if (sub === "status") { + rejectArgs(args, USAGE); + const snapshot = await readLiveOrLocalSnapshot(deps); + printData(snapshot, wantsJson, formatStatusLines(snapshot)); + return; + } + if (sub === "enable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("accepted"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, ["Anonymous telemetry enabled."]); + return; + } + if (sub === "disable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("declined"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, [ + "Anonymous telemetry disabled and local telemetry identifier removed.", + ]); + return; + } + throw new CliUsageError(`unknown telemetry command ${sub}`, USAGE); + }); +} + +export const TELEMETRY_USAGE = USAGE; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 58cb7d08c7a..5568ecaa2c2 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -238,6 +238,12 @@ async function handleLinkRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/telemetry")) return null; + const { handleTelemetryRoutes } = await import("./management/telemetry-routes"); + return handleTelemetryRoutes(ctx); +} + export async function handleManagementAPI( req: Request, url: URL, @@ -393,6 +399,7 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleTelemetryRoutesOnDemand(ctx)) ?? (await handleUsageTimelineRoutes(ctx)) ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 6655a587d94..119c45f0156 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -451,6 +451,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false }, { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true }, + // server/management/telemetry-routes + { method: "GET", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: false }, + { method: "PUT", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: changing telemetry consent over HTTP requires a human dashboard session; CLI callers use ocx telemetry enable/disable." } }, + { method: "POST", path: "/api/telemetry/notice", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "Recording that the disclosure notice was seen activates default-on telemetry and requires a human dashboard session." } }, // --- Further routes an equality scan of their own file cannot see. --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/src/server/management/telemetry-routes.ts b/src/server/management/telemetry-routes.ts new file mode 100644 index 00000000000..3739c5380b7 --- /dev/null +++ b/src/server/management/telemetry-routes.ts @@ -0,0 +1,20 @@ +import type { ManagementContext } from "./context"; +import { + handleGetTelemetryRoute, + handlePostTelemetryNoticeRoute, + handlePutTelemetryRoute, +} from "../../telemetry/routes"; + +export async function handleTelemetryRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname === "/api/telemetry" && req.method === "GET") { + return handleGetTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry" && req.method === "PUT") { + return handlePutTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry/notice" && req.method === "POST") { + return handlePostTelemetryNoticeRoute(ctx); + } + return null; +} diff --git a/src/service/cli.ts b/src/service/cli.ts index 3a1c198055d..4daf72dce17 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,6 +362,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise | null | undefined, + options: { now?: number; env?: Record; configDir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const status = resolveTelemetryStatus(config, env); + const installChannel = resolveInstallChannel({ env }); + const state = readTelemetryState(configDir); + const day = latestCompletedUtcDay(now); + const aggregate = await aggregateTelemetryForDay(day); + const identity = readTelemetryInstallId(configDir); + const installDayHash = identity + ? computeInstallDayHash(identity.installId, day) + : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; + const newInstall = identity + ? state.firstReportSentForInstallId !== identity.installId + : true; + const preview = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + installChannel, + now, + }); + return { + ...status, + installChannel, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + preview, + }; +} + +function consentRefusedResponse(ctx: ManagementContext): Response { + return jsonResponse( + { + ok: false, + code: "agent_consent_required", + error: "Telemetry consent requires a human dashboard session.", + message: + "Refused: this request carries no human dashboard session. Telemetry consent must be chosen by the user in the dashboard or an interactive terminal.", + }, + 403, + ctx.req, + ctx.config, + ); +} + +export async function handleGetTelemetryRoute(ctx: ManagementContext): Promise { + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse(snapshot, 200, ctx.req, ctx.config); +} + +export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return jsonResponse({ error: "invalid JSON body", code: "invalid_json" }, 400, ctx.req, ctx.config); + } + const rawDecision = body && typeof body === "object" && !Array.isArray(body) + ? (body as { decision?: unknown }).decision + : undefined; + if (rawDecision !== "accepted" && rawDecision !== "declined") { + return jsonResponse( + { error: "decision must be \"accepted\" or \"declined\"", code: "invalid_decision" }, + 400, + ctx.req, + ctx.config, + ); + } + const decision: OcxTelemetryDecision = rawDecision; + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + committed = structuredClone(next); + return { changed, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + if (decision === "declined") { + deleteTelemetryInstallId(); + } + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} + +export async function handlePostTelemetryNoticeRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + if (prev?.noticeShownAt && !Number.isNaN(Date.parse(prev.noticeShownAt))) { + committed = structuredClone(prev); + return { changed: false, value: prev }; + } + const next: OcxTelemetryConfig = { + ...(prev?.decision ? { decision: prev.decision } : {}), + ...(prev?.decidedAt ? { decidedAt: prev.decidedAt } : {}), + noticeShownAt: nowIso, + }; + fresh.telemetry = next; + committed = structuredClone(next); + return { changed: true, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} From c47ff3de3714352a512f452829afd3f564cd822c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 04/29] test(telemetry): add client telemetry test suite and structure ownership --- scripts/test-layout/layout-usage.json | 6 +- structure/INDEX.md | 5 + structure/manifest.json | 12 + structure/telemetry.md | 54 ++ .../ci-workflows/skill-ocx-generated.test.ts | 2 +- tests/cli/cli-telemetry.test.ts | 200 ++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/server/server-telemetry-routes.test.ts | 177 +++++++ tests/usage/usage-telemetry-client.test.ts | 468 ++++++++++++++++++ .../usage-telemetry-core-boundary.test.ts | 62 +++ 10 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 structure/telemetry.md create mode 100644 tests/cli/cli-telemetry.test.ts create mode 100644 tests/server/server-telemetry-routes.test.ts create mode 100644 tests/usage/usage-telemetry-client.test.ts create mode 100644 tests/usage/usage-telemetry-core-boundary.test.ts diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 0aa98678b73..29b328c527a 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -10,5 +10,9 @@ "request-log-identity-bounds.test.ts": "usage", "safe-response-headers.test.ts": "server", "turn-state-handles.test.ts": "server", - "turn-state-handles-e2e.test.ts": "server" + "turn-state-handles-e2e.test.ts": "server", + "usage-telemetry-client.test.ts": "usage", + "usage-telemetry-core-boundary.test.ts": "usage", + "server-telemetry-routes.test.ts": "server", + "cli-telemetry.test.ts": "cli" } diff --git a/structure/INDEX.md b/structure/INDEX.md index bab3fd26a1b..c2c508ce8a8 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -147,6 +147,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/t3code.md`](clients/t3code.md) | T3 Code settings.json Claude provider-instance ownership, proxy environment, custom models and loopback marker rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | +| [`telemetry.md`](telemetry.md) | Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI. | ### Tier 6 — Operations and process @@ -256,6 +257,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/status-link.ts` | [`remote-link.md`](remote-link.md) | +| `src/cli/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/cli/update-restart.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/client/hub-client.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | @@ -448,6 +450,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | +| `src/server/management/telemetry-routes.ts` | [`telemetry.md`](telemetry.md) | | `src/server/management/usage-aggregate-cache.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/messages-native-tool-guard.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | | `src/server/messages-native.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | @@ -507,6 +510,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`runtime.md`](runtime.md) | | `src/storage/` | [`runtime.md`](runtime.md) | +| `src/telemetry/` | [`telemetry.md`](telemetry.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | @@ -514,6 +518,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | +| `src/types/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/guarded-runtime-ops.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/manifest.json b/structure/manifest.json index b6da2b17b1a..9252acff98c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1426,6 +1426,18 @@ "src/oauth/api-key-pool-preserve.ts", "gui/src/account-pause-display.ts" ] + }, + { + "path": "telemetry.md", + "tier": 5, + "title": "Telemetry", + "scope": "Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI.", + "documents": [ + "src/telemetry/", + "src/types/telemetry.ts", + "src/server/management/telemetry-routes.ts", + "src/cli/telemetry.ts" + ] } ], "contracts": { diff --git a/structure/telemetry.md b/structure/telemetry.md new file mode 100644 index 00000000000..6adefcb16db --- /dev/null +++ b/structure/telemetry.md @@ -0,0 +1,54 @@ +# Telemetry + +Anonymous, default-on daily usage telemetry owned by `src/telemetry/`, `src/types/telemetry.ts`, +`src/server/management/telemetry-routes.ts`, and `src/cli/telemetry.ts`. + +## Wire contract and vocabularies + +`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, semver build metadata, and more than 64 model rows. + +## Consent and agent boundary + +`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`). + +Only a human can transition an undecided install to active sending: + +- `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated + from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) + require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. +- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in + `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always + permitted. +- Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and + `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice + and record neither `noticeShownAt` nor `decision`. + +## Identity, aggregation, and scheduling + +`src/telemetry/install-id.ts` manages `/telemetry-id.json` (`0o600`, exclusive +no-follow creation) on the first send and deletes it when telemetry is declined. 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, 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`. `src/telemetry/scheduler.ts` runs an unref'd background +timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on +each tick and sending at most one completed post-consent day per tick. diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index bfa715af2e0..a82ecb33381 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -14,7 +14,7 @@ const GROUPS: Record = { lifecycle: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"], "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], "agents-routing": ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], - "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], + "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"], "access-remote": ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"], lab: ["lab"], }; const FILENAMES = [INDEX, ...Object.keys(GROUPS).map(domain => `01_surface_${domain}.md`)]; diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts new file mode 100644 index 00000000000..ece7f41245a --- /dev/null +++ b/tests/cli/cli-telemetry.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { + TELEMETRY_DEFERRAL_FILE, + handleTelemetryCommand, + maybeShowTelemetryPrompt, + setTelemetryPromptDepsForTests, +} from "../../src/cli/telemetry"; +import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { + ensureTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const AGENT_ENV_VARS = [ + "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT", + "CODEX_THREAD_ID", "CODEX_SHELL", "CODEX_CI", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", + "CURSOR_TRACE_ID", "CURSOR_SESSION_TOKEN", "CURSOR_AGENT", + "AIDER_CHAT", "OPENCODE_BIN_PATH", "GEMINI_CLI", + "REPL_ID", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION", "CODESPACES", +] as const; + +describe("ocx telemetry CLI and interactive prompt", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + const savedAgentEnv = new Map(); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-cli-telemetry-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + for (const key of AGENT_ENV_VARS) { + savedAgentEnv.set(key, process.env[key]); + delete process.env[key]; + } + resetTelemetryInstallIdMemoForTests(); + setTelemetryPromptDepsForTests(null); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }, null, 2) + "\n", + "utf8", + ); + }); + + afterEach(() => { + setTelemetryPromptDepsForTests(null); + resetTelemetryInstallIdMemoForTests(); + for (const [key, value] of savedAgentEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + test("status --json falls back to local snapshot when proxy is unreachable", async () => { + const lines: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + try { + const code = await handleTelemetryCommand(["status", "--json"], { + fetch: (async () => { throw new Error("connect ECONNREFUSED"); }) as unknown as typeof fetch, + }); + expect(code).toBe(0); + const parsed = JSON.parse(lines.join("\n")) as { effective: boolean; reason: string; preview: { schemaVersion: number } }; + expect(parsed.effective).toBe(false); + expect(parsed.reason).toBe("awaiting-notice"); + expect(parsed.preview.schemaVersion).toBe(1); + } finally { + log.mockRestore(); + } + }); + + test("enable and disable mutate config.json; enable refuses when agent-driven while disable succeeds", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const enableCode = await handleTelemetryCommand(["enable", "--json"]); + expect(enableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); + + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + // Agent-driven session: + process.env.CODEX_THREAD_ID = "agent-123"; + log.mockClear(); + const refusedCode = await handleTelemetryCommand(["enable", "--json"]); + expect(refusedCode).toBe(1); + expect(log.mock.calls.map(c => String(c[0])).join("\n")).toContain("agent_consent_required"); + + // Disable is always allowed, even when agent-driven: + const disableCode = await handleTelemetryCommand(["disable", "--json"]); + expect(disableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + expect(existsSync(telemetryIdPath(home))).toBe(false); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test("ocx config set/unset refuse telemetry and import drops telemetry when agent-driven", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); + expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); + + const importFile = join(home, "imported.json"); + writeFileSync( + importFile, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + process.env.CODEX_THREAD_ID = "agent-import"; + expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + } finally { + log.mockRestore(); + warn.mockRestore(); + err.mockRestore(); + } + }); + + test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + process.env.CODEX_THREAD_ID = "agent-start"; + setTelemetryPromptDepsForTests({ isTty: () => true }); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(1); + expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Second agent-driven run on the same version stays quiet: + log.mockClear(); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Interactive human run prompts with defaultYes: true and records accepted: + delete process.env.CODEX_THREAD_ID; + let observedDefaultYes: boolean | undefined; + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + observedDefaultYes = opts.defaultYes; + return opts.defaultYes; + }, + }); + await maybeShowTelemetryPrompt(); + expect(observedDefaultYes).toBe(true); + const telemetry = readConfigDiagnostics().config.telemetry; + expect(telemetry?.decision).toBe("accepted"); + expect(typeof telemetry?.decidedAt).toBe("string"); + expect(typeof telemetry?.noticeShownAt).toBe("string"); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).telemetry.decision).toBe("accepted"); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71a17ebfca7..46504f81156 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1622,5 +1622,6 @@ "messages-sse-baseline-contract.test.ts": "claude-integration", "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", - "sse-payload-rewrite-continuation.test.ts": "responses" + "sse-payload-rewrite-continuation.test.ts": "responses", + "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" } diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts new file mode 100644 index 00000000000..7c8814ec7e6 --- /dev/null +++ b/tests/server/server-telemetry-routes.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementPrincipal } from "../../src/server/management-auth"; +import type { ManagementRequestIngress } from "../../src/server/management/context"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("/api/telemetry management routes", () => { + let home: string; + let config: OcxConfig; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-routes-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + resetTelemetryInstallIdMemoForTests(); + config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + writeFileSync(join(home, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8"); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + async function call( + method: string, + pathname: string, + options: { + body?: unknown; + principal?: ManagementPrincipal; + ingress?: ManagementRequestIngress; + } = {}, + ) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const req = new Request(url, { + method, + headers: { + host: "127.0.0.1:10100", + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }); + const res = await handleManagementAPI( + req, + url, + config, + {}, + options.principal ?? "admin-token", + undefined, + options.ingress ?? { trustedLoopback: true }, + ); + expect(res).not.toBeNull(); + return { + status: res!.status, + body: (await res!.json()) as Record, + }; + } + + test("GET /api/telemetry returns status and preview with placeholder hash without creating an id", async () => { + const res = await call("GET", "/api/telemetry"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + effective: false, + reason: "awaiting-notice", + decision: null, + lastSentDay: null, + }); + const preview = res.body.preview as { installDayHash: string; schemaVersion: number }; + expect(preview.schemaVersion).toBe(1); + expect(preview.installDayHash).toBe(TELEMETRY_PREVIEW_HASH_PLACEHOLDER); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); + + test("PUT /api/telemetry and POST /api/telemetry/notice refuse admin-token and remembered-admin sessions", async () => { + for (const [principal, guiSessionIssuance] of [ + ["admin-token", null], + ["gui-session", "remembered-admin"], + ] as const) { + const putRes = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(putRes.status).toBe(403); + expect(putRes.body.code).toBe("agent_consent_required"); + + const noticeRes = await call("POST", "/api/telemetry/notice", { + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(noticeRes.status).toBe(403); + expect(noticeRes.body.code).toBe("agent_consent_required"); + } + expect(config.telemetry).toBeUndefined(); + }); + + test("POST /api/telemetry/notice accepts a human dashboard session and is idempotent", async () => { + const first = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + ok: true, + effective: true, + reason: "default-on", + decision: null, + }); + const firstNoticeAt = first.body.noticeShownAt as string; + expect(typeof firstNoticeAt).toBe("string"); + + const second = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(second.status).toBe(200); + expect(second.body.noticeShownAt).toBe(firstNoticeAt); + }); + + test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + const accepted = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(accepted.status).toBe(200); + expect(accepted.body).toMatchObject({ + ok: true, + effective: true, + reason: "accepted", + decision: "accepted", + }); + expect(config.telemetry?.decision).toBe("accepted"); + + const declined = await call("PUT", "/api/telemetry", { + body: { decision: "declined" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(declined.status).toBe(200); + expect(declined.body).toMatchObject({ + ok: true, + effective: false, + reason: "declined", + decision: "declined", + }); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts new file mode 100644 index 00000000000..e8cee325640 --- /dev/null +++ b/tests/usage/usage-telemetry-client.test.ts @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; +import { adoptPersistedTelemetry, armClaudeCodeBaseline } from "../../src/config/live-reconcile"; +import { malformedTelemetryWarning } from "../../src/config/load-degrade"; +import { + aggregateTelemetryEntriesForDay, + aggregateTelemetryForDay, + classifyModelFamily, + classifyProviderKind, + disjointTelemetryTokens, +} from "../../src/telemetry/aggregate"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { + ARCH_VALUES, + INSTALL_CHANNELS, + MODEL_FAMILIES, + OS_VALUES, + PROVIDER_KINDS, + RELEASE_CHANNELS, + REQUEST_FAILURE_CAUSES as TELEMETRY_FAILURE_CAUSES, + REQUEST_OUTCOME_CLASSES as TELEMETRY_OUTCOME_CLASSES, + TELEMETRY_SCHEMA_VERSION, + validateTelemetryPayloadV1, + type TelemetryPayloadV1, +} from "../../src/telemetry/contract"; +import { + computeInstallDayHash, + deleteTelemetryInstallId, + ensureTelemetryInstallId, + readTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; +import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; +import type { OcxConfig } from "../../src/types"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; +import { REQUEST_FAILURE_CAUSES } from "../../src/usage/telemetry-contract"; +import { usageDisplayTotalTokens } from "../../src/usage/totals"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const NOW = Date.parse("2026-11-07T12:00:00.000Z"); + +describe("telemetry contract parity and validation", () => { + test("contract module is import-free and pins outcome and failure-cause rosters", () => { + const src = readFileSync(repoPath("src", "telemetry", "contract.ts"), "utf8"); + expect(src).not.toMatch(/^\s*(?:import|export\s+.*from)\s+/m); + expect([...TELEMETRY_OUTCOME_CLASSES]).toEqual([...REQUEST_OUTCOME_CLASSES]); + expect([...TELEMETRY_FAILURE_CAUSES]).toEqual([...REQUEST_FAILURE_CAUSES]); + expect(PROVIDER_KINDS).toContain("anthropic"); + expect(MODEL_FAMILIES).toContain("claude-opus"); + expect(INSTALL_CHANNELS).toContain("npm-global"); + expect(RELEASE_CHANNELS).toEqual(["latest", "preview"]); + expect(OS_VALUES).toEqual(["darwin", "linux", "win32", "other"]); + expect(ARCH_VALUES).toEqual(["x64", "arm64", "other"]); + }); + + test("validateTelemetryPayloadV1 accepts a canonical payload and rejects extra keys, unknown roster values, semver metadata, and bad dates", () => { + const base: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: "11111111-2222-4333-8444-555555555555", + day: "2026-11-06", + installDayHash: "a".repeat(64), + newInstall: false, + env: { + version: "2.40.1", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }, + activeMinutes: 312, + requests: { completed: 840, failed: 12, incomplete: 3, aborted: 5 }, + models: [ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 400, + inputTokens: 1000, + cacheReadTokens: 2000, + cacheWriteTokens: 500, + outputTokens: 300, + reasoningTokens: 100, + }, + ], + errors: { "rate-limit": 7 }, + }; + + expect(validateTelemetryPayloadV1(base, NOW)).toEqual({ ok: true, payload: base }); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(true); + + expect(validateTelemetryPayloadV1({ ...base, extra: 1 }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, extra: 1 } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1+build.9" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, installChannel: "brew" as never } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-02-30" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-10-20" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-11-09" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, errors: { "unknown-cause": 1 } as never }, NOW).ok).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [{ ...base.models[0]!, providerKind: "custom-vendor" as never }] }, + NOW, + ).ok, + ).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [base.models[0]!, base.models[0]!] }, + NOW, + ).ok, + ).toBe(false); + }); +}); + +describe("consent status matrix and config schema wiring", () => { + test("resolveTelemetryStatus follows the precedence table", () => { + expect(resolveTelemetryStatus(undefined, {})).toEqual({ + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt: null, + noticeShownAt: null, + envOverride: null, + }); + + expect(resolveTelemetryStatus({ noticeShownAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "default-on", + decision: null, + }); + + expect(resolveTelemetryStatus({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "accepted", + decision: "accepted", + }); + + expect( + resolveTelemetryStatus( + { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" }, + {}, + ), + ).toMatchObject({ + effective: false, + reason: "declined", + decision: "declined", + }); + + for (const val of ["0", "false", "off", "no", " FALSE "]) { + expect( + resolveTelemetryStatus({ decision: "accepted" }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), + ).toMatchObject({ + effective: false, + reason: "env-ocx-telemetry", + envOverride: "OCX_TELEMETRY", + }); + } + + for (const val of ["1", "true", "yes", " TRUE "]) { + expect(resolveTelemetryStatus({ decision: "accepted" }, { DO_NOT_TRACK: val })).toMatchObject({ + effective: false, + reason: "env-do-not-track", + envOverride: "DO_NOT_TRACK", + }); + } + }); + + test("config diagnostics, load-degrade, and live-reconcile handle telemetry config", () => { + expect(telemetryConfigError({ telemetry: { decision: "accepted" } })).toBeNull(); + expect(telemetryConfigError({ telemetry: { endpoint: "https://evil.example" } })).toContain("schema_invalid: telemetry"); + expect(malformedTelemetryWarning({ telemetry: { decision: "maybe" } })).toContain("telemetry.decision ignored"); + + const live = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; + armClaudeCodeBaseline(live); + adoptPersistedTelemetry(live, { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + expect(live.telemetry).toEqual({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + adoptPersistedTelemetry(live, undefined); + expect(live.telemetry).toBeUndefined(); + }); +}); + +describe("install identity, aggregation, disjoint tokens, and scheduler", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-unit-")); + process.env.OPENCODEX_HOME = home; + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + removeTreeWithRetry(home); + }); + + test("telemetry-id.json is created with 0o600 mode and deleted on decline", () => { + expect(readTelemetryInstallId(home)).toBeNull(); + const first = ensureTelemetryInstallId(home); + expect(first.freshlyCreated).toBe(true); + const idFile = telemetryIdPath(home); + expect(existsSync(idFile)).toBe(true); + if (process.platform !== "win32") { + expect(statSync(idFile).mode & 0o777).toBe(0o600); + } + const second = ensureTelemetryInstallId(home); + expect(second.freshlyCreated).toBe(false); + expect(second.identity.installId).toBe(first.identity.installId); + + const hashA = computeInstallDayHash(first.identity.installId, "2026-11-05"); + const hashB = computeInstallDayHash(first.identity.installId, "2026-11-06"); + expect(hashA).toMatch(/^[0-9a-f]{64}$/); + expect(hashA).not.toBe(hashB); + + expect(deleteTelemetryInstallId(home)).toBe(true); + expect(existsSync(idFile)).toBe(false); + expect(readTelemetryInstallId(home)).toBeNull(); + }); + + test("aggregates a fixture ledger and preserves the disjoint token bucket identity", async () => { + const day = "2026-11-06"; + const baseTs = Date.parse("2026-11-06T10:00:00.000Z"); + const fixtureEntries: PersistedUsageEntry[] = [ + { + requestId: "req-1", + timestamp: baseTs + 5_000, + provider: "anthropic-p123456", + model: "claude-opus-4-6", + status: 200, + terminalStatus: "completed", + durationMs: 1200, + usageStatus: "reported", + usage: { + inputTokens: 1000, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + }, + }, + { + requestId: "req-2", + timestamp: baseTs + 25_000, + provider: "my-custom-secret-provider", + model: "gpt-5.3-codex", + status: 429, + terminalStatus: "failed", + failureCause: "rate-limit", + durationMs: 300, + usageStatus: "reported", + usage: { + inputTokens: 500, + outputTokens: 50, + }, + }, + { + requestId: "req-3", + timestamp: baseTs + 90_000, + provider: "openai", + model: "o3-mini", + status: 499, + closeReason: "client_cancel", + failureCause: "client-cancelled", + durationMs: 100, + usageStatus: "unreported", + }, + { + requestId: "req-other-day", + timestamp: Date.parse("2026-11-05T23:59:59.000Z"), + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 100, + usageStatus: "reported", + usage: { inputTokens: 9999, outputTokens: 9999 }, + }, + ]; + + writeFileSync( + join(home, "usage.jsonl"), + fixtureEntries.map(row => JSON.stringify(row)).join("\n") + "\n", + "utf8", + ); + + for (const entry of fixtureEntries) { + if (!entry.usage) continue; + const buckets = disjointTelemetryTokens(entry.usage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(entry.usage)!); + } + + const agg = await aggregateTelemetryForDay(day); + expect(agg.activeMinutes).toBe(2); + expect(agg.requests).toEqual({ completed: 1, failed: 1, incomplete: 0, aborted: 1 }); + expect(agg.errors).toEqual({ "rate-limit": 1, "client-cancelled": 1 }); + expect(agg.models).toEqual([ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 1, + inputTokens: 250, + cacheReadTokens: 600, + cacheWriteTokens: 150, + outputTokens: 280, + reasoningTokens: 120, + }, + { + providerKind: "other", + modelFamily: "gpt-5", + requests: 1, + inputTokens: 500, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 50, + reasoningTokens: 0, + }, + { + providerKind: "openai", + modelFamily: "o-series", + requests: 1, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + ]); + expect(JSON.stringify(agg)).not.toContain("my-custom-secret-provider"); + expect(classifyProviderKind("google-vertex")).toBe("vertex"); + expect(classifyModelFamily("deepseek/deepseek-r1")).toBe("deepseek"); + expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); + }); + + test("installChannel mapping covers desktop, docker, and package managers", () => { + expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); + expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "npm" }) })).toBe("npm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "pnpm" }) })).toBe("pnpm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "bun" }) })).toBe("bun-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mise" }) })).toBe("mise"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "standalone" }) })).toBe("standalone"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "source" }) })).toBe("source"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mystery" }) })).toBe("unknown"); + }); + + test("scheduler sends nothing when not effective, skips pre-consent days, and sends at most one day per tick oldest first", async () => { + const sentPayloads: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentPayloads.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + // 1. Undecided without notice -> nothing sent, no id created. + const resAwaiting = await runTelemetryTick({ + configDir: home, + config: {}, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(resAwaiting.sent).toBe(false); + expect(resAwaiting.reason).toBe("awaiting-notice"); + expect(readTelemetryInstallId(home)).toBeNull(); + expect(sentPayloads).toHaveLength(0); + + // 2. Consent given on 2026-11-05 -> 2026-11-04 and earlier are never backfilled; + // first tick sends 2026-11-05 (oldest post-consent day) with newInstall=true, + // second tick sends 2026-11-06 with newInstall=false, + // third tick finds nothing left. + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-05T15:30:00.000Z", + noticeShownAt: "2026-11-05T15:30:00.000Z", + }, + }; + const tick1 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick1).toMatchObject({ sent: true, day: "2026-11-05" }); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0]!.day).toBe("2026-11-05"); + expect(sentPayloads[0]!.newInstall).toBe(true); + + const tick2 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick2).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentPayloads).toHaveLength(2); + expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.newInstall).toBe(false); + + const tick3 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("sendTelemetryPayload enforces its timeout and does not retry", async () => { + const agg = aggregateTelemetryEntriesForDay("2026-11-06", []); + const payload = buildTelemetryPayloadV1({ + aggregate: agg, + installDayHash: "b".repeat(64), + newInstall: false, + now: NOW, + }); + let calls = 0; + const slowFetch = ((_url: string | URL | Request, init?: RequestInit) => { + calls += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted by timeout")); + }); + }); + }) as unknown as typeof fetch; + const result = await sendTelemetryPayload(payload, { + fetch: slowFetch, + timeoutMs: 25, + now: NOW, + }); + expect(result.ok).toBe(false); + expect(calls).toBe(1); + }); + + test("readConfigDiagnostics passively reads persisted telemetry config", () => { + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + const diag = readConfigDiagnostics({ passive: true }); + expect(diag.error).toBeNull(); + expect(diag.config.telemetry?.decision).toBe("accepted"); + }); +}); diff --git a/tests/usage/usage-telemetry-core-boundary.test.ts b/tests/usage/usage-telemetry-core-boundary.test.ts new file mode 100644 index 00000000000..0088bef8342 --- /dev/null +++ b/tests/usage/usage-telemetry-core-boundary.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; + +const PROTECTED = [ + "src/router.ts", + "src/server/lifecycle.ts", + "src/server/responses/core.ts", + "src/server/management-api.ts", +] as const; + +const repoRoot = resolveRepoRoot(); + +const STATIC_IMPORT_RE = + /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/gm; + +function resolveSpec(spec: string, fromFile: string): string | null { + if (!spec.startsWith(".")) return null; + const base = resolve(dirname(fromFile), spec); + for (const candidate of [`${base}.ts`, join(base, "index.ts"), `${base}.mts`, `${base}.mjs`]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function firstTelemetryPath(entry: string): string[] | null { + const start = resolve(repoRoot, entry); + const previous = new Map([[start, null]]); + const queue = [start]; + while (queue.length > 0) { + const current = queue.shift()!; + if (!existsSync(current)) continue; + const source = readFileSync(current, "utf8"); + STATIC_IMPORT_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = STATIC_IMPORT_RE.exec(source)) !== null) { + const spec = match[1] ?? match[2] ?? match[3]; + if (!spec) continue; + const next = resolveSpec(spec, current); + if (!next || previous.has(next)) continue; + previous.set(next, current); + if (next.replaceAll("\\", "/").includes("/src/telemetry/")) { + const chain: string[] = []; + let node: string | null = next; + while (node) { + chain.push(node.slice(repoRoot.length + 1).replaceAll("\\", "/")); + node = previous.get(node) ?? null; + } + return chain.reverse(); + } + queue.push(next); + } + } + return null; +} + +describe("telemetry core load-time boundary", () => { + test.each(PROTECTED)("%s has no load-time import path to src/telemetry/", entry => { + expect(firstTelemetryPath(entry)).toBeNull(); + }); +}); From b5eddc370f4b3a399aab57801c557a179e19b7e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:56:25 +0900 Subject: [PATCH 05/29] fix(telemetry): remove state installId leak, harden writes, and preserve declined config on agent import --- src/cli/config-command.ts | 13 +- src/cli/telemetry.ts | 10 +- src/service/cli.ts | 1 - src/telemetry/aggregate.ts | 8 +- src/telemetry/install-id.ts | 47 +++++++- src/telemetry/routes.ts | 2 +- src/telemetry/scheduler.ts | 41 +++++-- tests/cli/cli-telemetry.test.ts | 84 +++++++++++-- tests/server/server-telemetry-routes.test.ts | 25 +++- tests/usage/usage-telemetry-client.test.ts | 119 ++++++++++++++++++- 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 1a03189d27b..001496c574b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -335,9 +335,10 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { - readCliConfigForMutation(); + const current = readCliConfigForMutation(); + const agentDriven = isAgentDriven(); const rawInput = loadInput(path); - if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + if (agentDriven && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { delete (rawInput as Record).telemetry; console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); } @@ -348,6 +349,14 @@ export async function handleConfigCommand(argv: string[]): Promise { || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } + if (agentDriven) { + const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; + if (persistedTelemetry !== undefined) { + imported.telemetry = structuredClone(persistedTelemetry); + } else { + delete imported.telemetry; + } + } saveConfig(imported); }); }); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 60ffd2f652d..6433278018c 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; @@ -95,7 +96,10 @@ export async function maybeShowTelemetryPrompt(): Promise { const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); let record: string | null = null; try { - if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + const stat = lstatSync(deferralPath); + if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1024) { + record = readFileSync(deferralPath, "utf8"); + } } catch { /* best-effort */ } const version = currentVersion(); if (isTelemetryDeferralCurrent(record, version, Date.now())) return; @@ -105,7 +109,7 @@ export async function maybeShowTelemetryPrompt(): Promise { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); recordOwnedConfigPath(dir, deferralPath); - writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(deferralPath, `${new Date().toISOString()} ${version}\n`); } catch { /* best-effort */ } return; } diff --git a/src/service/cli.ts b/src/service/cli.ts index 4daf72dce17..3a1c198055d 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,7 +362,6 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise> = Object.freeze({ openai: "openai", @@ -183,6 +184,11 @@ export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTe } const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); const outputTokens = Math.max(0, totalOutput - reasoningTokens); + const displayTotal = nonNegInt(usageDisplayTotalTokens(usage)); + const accounted = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens; + if (displayTotal > accounted) { + inputTokens += displayTotal - accounted; + } return { inputTokens, cacheReadTokens, diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index a69e3882c37..2c2a7dd1e0e 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -13,6 +13,7 @@ import { writeSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -22,6 +23,7 @@ export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); export interface TelemetryInstallIdentity { installId: string; createdAt: string; + firstReportSent?: boolean; } const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -44,7 +46,11 @@ function parseTelemetryId(text: string): TelemetryInstallIdentity | null { if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { return null; } - return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + return { + installId: raw.installId.toLowerCase(), + createdAt: raw.createdAt, + firstReportSent: raw.firstReportSent === true, + }; } catch { return null; } @@ -141,6 +147,7 @@ export function ensureTelemetryInstallId( const identity: TelemetryInstallIdentity = { installId: randomUUID().toLowerCase(), createdAt: new Date().toISOString(), + firstReportSent: false, }; const content = `${JSON.stringify({ version: 1, ...identity })}\n`; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; @@ -173,19 +180,47 @@ export function ensureTelemetryInstallId( return { identity, freshlyCreated: true }; } +/** Mark `firstReportSent: true` inside `/telemetry-id.json` after the first report succeeds. */ +export function markTelemetryFirstReportSent(configDir = getConfigDir()): void { + const existing = readTelemetryInstallId(configDir); + if (!existing || existing.firstReportSent === true) return; + const path = telemetryIdPath(configDir); + const updated: TelemetryInstallIdentity = { + installId: existing.installId, + createdAt: existing.createdAt, + firstReportSent: true, + }; + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + atomicWriteFileNoFollow(path, `${JSON.stringify({ version: 1, ...updated })}\n`); + memo = { path, identity: updated }; +} + /** Delete `/telemetry-id.json` when telemetry is declined. */ export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { const path = telemetryIdPath(configDir); if (memo?.path === path) memo = null; + let deleted = false; try { const stat = lstatSync(path); - if (stat.isSymbolicLink() || !stat.isFile()) return false; - unlinkSync(path); - return true; + if (!stat.isSymbolicLink() && stat.isFile()) { + unlinkSync(path); + deleted = true; + } } catch (error) { - if (errorCode(error) === "ENOENT") return false; - return false; + if (errorCode(error) !== "ENOENT") deleted = false; } + const statePath = join(configDir, "telemetry-state.json"); + try { + const stat = lstatSync(statePath); + if (!stat.isSymbolicLink() && stat.isFile() && stat.size <= 64 * 1024) { + const raw = JSON.parse(readFileSync(statePath, "utf8")) as Record; + if (raw && typeof raw === "object" && !Array.isArray(raw) && "firstReportSentForInstallId" in raw) { + delete raw.firstReportSentForInstallId; + atomicWriteFileNoFollow(statePath, `${JSON.stringify(raw, null, 2)}\n`); + } + } + } catch { /* best-effort */ } + return deleted; } export function computeInstallDayHash(installId: string, day: string): string { diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 49707f420e2..65be7e621eb 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -41,7 +41,7 @@ export async function buildTelemetryStatusSnapshot( ? computeInstallDayHash(identity.installId, day) : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; const newInstall = identity - ? state.firstReportSentForInstallId !== identity.installId + ? identity.firstReportSent !== true : true; const preview = buildTelemetryPayloadV1({ aggregate, diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 604c535d0ec..4d98a9562d3 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -1,5 +1,6 @@ -import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -7,7 +8,7 @@ import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; -import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { computeInstallDayHash, ensureTelemetryInstallId, markTelemetryFirstReportSent } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; @@ -22,7 +23,6 @@ export interface TelemetryState { sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; - firstReportSentForInstallId: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -35,7 +35,6 @@ function emptyState(): TelemetryState { sentDays: {}, lastSentDay: null, lastSentAt: null, - firstReportSentForInstallId: null, }; } @@ -59,8 +58,6 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { sentDays, lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, - firstReportSentForInstallId: - typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, }; } catch { return emptyState(); @@ -81,9 +78,8 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig sentDays: prunedSentDays, lastSentDay: state.lastSentDay, lastSentAt: state.lastSentAt, - firstReportSentForInstallId: state.firstReportSentForInstallId, }; - writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } export function utcMidnightMs(epochMs: number): number { @@ -155,7 +151,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = state.firstReportSentForInstallId !== identity.installId; + const newInstall = identity.firstReportSent !== true; const installDayHash = computeInstallDayHash(identity.installId, targetDay); const aggregate = await (options.aggregateDay ? options.aggregateDay(targetDay) @@ -171,23 +167,29 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom if (!sendResult.ok) { return { sent: false, day: targetDay, reason: "send-failed", sendResult }; } + markTelemetryFirstReportSent(configDir); const nowIso = new Date(now).toISOString(); state.sentDays[targetDay] = nowIso; state.lastSentDay = targetDay; state.lastSentAt = nowIso; - state.firstReportSentForInstallId = identity.installId; writeTelemetryState(state, configDir, now); return { sent: true, day: targetDay, reason: "sent", sendResult }; } let startupTimer: ReturnType | null = null; let intervalTimer: ReturnType | null = null; +let nudgeTimer: ReturnType | null = null; let detachShutdownHook: (() => void) | null = null; let activeTick: Promise | null = null; let running = false; function triggerBackgroundTick(): void { - if (!running || activeTick) return; + if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } + if (activeTick) return; activeTick = runTelemetryTick() .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) .finally(() => { @@ -226,6 +228,10 @@ export function stopTelemetryScheduler(): void { clearInterval(intervalTimer); intervalTimer = null; } + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } if (detachShutdownHook) { const detach = detachShutdownHook; detachShutdownHook = null; @@ -235,12 +241,21 @@ export function stopTelemetryScheduler(): void { export function nudgeTelemetryScheduler(): void { if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; } - const timer = setTimeout(() => { + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } + nudgeTimer = setTimeout(() => { + nudgeTimer = null; triggerBackgroundTick(); }, 10); - timer.unref?.(); + nudgeTimer.unref?.(); } diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index ece7f41245a..eaea6062012 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; +import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; import { TELEMETRY_DEFERRAL_FILE, handleTelemetryCommand, @@ -11,10 +12,12 @@ import { } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { - ensureTelemetryInstallId, + readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath, } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import { repoPath } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const AGENT_ENV_VARS = [ @@ -62,6 +65,7 @@ describe("ocx telemetry CLI and interactive prompt", () => { afterEach(() => { setTelemetryPromptDepsForTests(null); + setStarPromptDepsForTests(null); resetTelemetryInstallIdMemoForTests(); for (const [key, value] of savedAgentEnv) { if (value === undefined) delete process.env[key]; @@ -103,7 +107,14 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(enableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); - ensureTelemetryInstallId(home); + const tick = await runTelemetryTick({ + configDir: home, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; expect(existsSync(telemetryIdPath(home))).toBe(true); // Agent-driven session: @@ -118,6 +129,12 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(disableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } } finally { log.mockRestore(); err.mockRestore(); @@ -132,9 +149,9 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); - const importFile = join(home, "imported.json"); + const importAccepted = join(home, "imported-accepted.json"); writeFileSync( - importFile, + importAccepted, JSON.stringify({ port: 10100, defaultProvider: "openai", @@ -150,9 +167,37 @@ describe("ocx telemetry CLI and interactive prompt", () => { "utf8", ); process.env.CODEX_THREAD_ID = "agent-import"; - expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + // Direction 1: undecided on disk -> agent import does not inject telemetry + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + + // Direction 2: declined on disk -> agent import preserves declined whether input has telemetry or omits it + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + const declinedBefore = readConfigDiagnostics().config.telemetry; + expect(declinedBefore?.decision).toBe("declined"); + + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + + const importWithoutTelemetry = join(home, "imported-no-telemetry.json"); + writeFileSync( + importWithoutTelemetry, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }), + "utf8", + ); + expect(await handleConfigCommand(["import", importWithoutTelemetry, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); } finally { log.mockRestore(); warn.mockRestore(); @@ -163,11 +208,18 @@ describe("ocx telemetry CLI and interactive prompt", () => { test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { + // A symlinked .telemetry-deferred marker is neither trusted on read nor followed on write: + const victim = join(home, "deferral-victim.txt"); + writeFileSync(victim, "preserve-deferral-victim", "utf8"); + const deferralPath = join(home, TELEMETRY_DEFERRAL_FILE); + symlinkSync(victim, deferralPath); + process.env.CODEX_THREAD_ID = "agent-start"; setTelemetryPromptDepsForTests({ isTty: () => true }); await maybeShowTelemetryPrompt(); expect(log.mock.calls.length).toBe(1); - expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readFileSync(victim, "utf8")).toBe("preserve-deferral-victim"); + expect(lstatSync(deferralPath).isSymbolicLink()).toBe(false); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); // Second agent-driven run on the same version stays quiet: @@ -197,4 +249,22 @@ describe("ocx telemetry CLI and interactive prompt", () => { log.mockRestore(); } }); + + test("service/cli.ts does not duplicate maybeShowTelemetryPrompt after maybeShowStarPrompt and the prompt runs once", async () => { + const serviceCliSource = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); + expect(serviceCliSource).toContain("await maybeShowStarPrompt()"); + expect(serviceCliSource).not.toContain("maybeShowTelemetryPrompt"); + + let telemetryPromptCount = 0; + setStarPromptDepsForTests({ isTty: () => true, ghAvailable: () => false }); + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + telemetryPromptCount += 1; + return opts.defaultYes; + }, + }); + await maybeShowStarPrompt(); + expect(telemetryPromptCount).toBe(1); + }); }); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 7c8814ec7e6..9062a351829 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; -import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -143,9 +144,6 @@ describe("/api/telemetry management routes", () => { }); test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { - ensureTelemetryInstallId(home); - expect(existsSync(telemetryIdPath(home))).toBe(true); - const accepted = await call("PUT", "/api/telemetry", { body: { decision: "accepted" }, principal: "gui-session", @@ -160,6 +158,17 @@ describe("/api/telemetry management routes", () => { }); expect(config.telemetry?.decision).toBe("accepted"); + const tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(existsSync(telemetryIdPath(home))).toBe(true); + const declined = await call("PUT", "/api/telemetry", { body: { decision: "declined" }, principal: "gui-session", @@ -173,5 +182,11 @@ describe("/api/telemetry management routes", () => { decision: "declined", }); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e8cee325640..e9ae6782ac4 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -35,7 +35,15 @@ import { telemetryIdPath, } from "../../src/telemetry/install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; -import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { + nudgeTelemetryScheduler, + readTelemetryState, + runTelemetryTick, + startTelemetryScheduler, + stopTelemetryScheduler, + telemetryStatePath, + writeTelemetryState, +} from "../../src/telemetry/scheduler"; import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; @@ -346,6 +354,35 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); }); + test("disjointTelemetryTokens legacy fallback keeps 5-bucket sum equal to usageDisplayTotalTokens and ACCOUNT_SUFFIX_RE matches k + 32 hex", () => { + const legacyUsage = { + inputTokens: 250, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + totalTokens: 1400, + }; + const buckets = disjointTelemetryTokens(legacyUsage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(legacyUsage)!); + expect(buckets.inputTokens).toBe(750); + expect(buckets.cacheReadTokens).toBe(100); + expect(buckets.cacheWriteTokens).toBe(150); + expect(buckets.outputTokens).toBe(280); + expect(buckets.reasoningTokens).toBe(120); + + expect(classifyProviderKind(`openai-k${"a1b2c3d4".repeat(4)}`)).toBe("openai"); + expect(classifyProviderKind("openai-p123456")).toBe("openai"); + expect(classifyProviderKind("openai-o123456")).toBe("openai"); + expect(classifyProviderKind("openai-k123456")).toBe("other"); + }); + test("installChannel mapping covers desktop, docker, and package managers", () => { expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); @@ -422,6 +459,82 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => }); expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + + // Deleting the install id after a successful tick leaves no trace of the raw installId in configDir. + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(deleteTelemetryInstallId(home)).toBe(true); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } + }); + + test("writeTelemetryState replaces a symlinked telemetry-state.json without following it", () => { + const victim = join(home, "victim.txt"); + writeFileSync(victim, "preserve-me", "utf8"); + const stateFile = telemetryStatePath(home); + symlinkSync(victim, stateFile); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(true); + + writeTelemetryState( + { + version: 1, + sentDays: { "2026-11-06": "2026-11-07T00:00:00.000Z" }, + lastSentDay: "2026-11-06", + lastSentAt: "2026-11-07T00:00:00.000Z", + } as never, + home, + NOW, + ); + + expect(readFileSync(victim, "utf8")).toBe("preserve-me"); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(false); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("nudgeTelemetryScheduler clears previous nudge timer and runtime env opt-out stops the scheduler in both nudge and tick", async () => { + const prevEnv = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + const clearSpy = spyOn(globalThis, "clearTimeout"); + try { + startTelemetryScheduler({ startupDelayMs: 60_000, intervalMs: 60_000 }); + clearSpy.mockClear(); + // First nudge clears startupTimer (1 call); second and third nudges clear the prior nudgeTimer. + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBeGreaterThanOrEqual(3); + + // Setting OCX_TELEMETRY=0 at runtime stops the scheduler when nudged. + process.env.OCX_TELEMETRY = "0"; + nudgeTelemetryScheduler(); + delete process.env.OCX_TELEMETRY; + + // Because the scheduler stopped, a subsequent nudge is a no-op and sets no new timer. + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + + // Restart and set DO_NOT_TRACK=1 before startupTimer fires triggerBackgroundTick: + startTelemetryScheduler({ startupDelayMs: 5, intervalMs: 60_000 }); + process.env.DO_NOT_TRACK = "1"; + await new Promise(resolve => setTimeout(resolve, 25)); + delete process.env.DO_NOT_TRACK; + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + } finally { + stopTelemetryScheduler(); + clearSpy.mockRestore(); + if (prevEnv === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevEnv; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + } }); test("sendTelemetryPayload enforces its timeout and does not retry", async () => { From 3e4597d1ba3c85d4049d6c01e3b3c15036d72b8f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 02:58:44 +0900 Subject: [PATCH 06/29] docs(devlog): record PR review round 1 decisions for the telemetry stack --- .../261107_telemetry_dashboard/000_plan.md | 10 ++- .../010_client_core.md | 2 +- .../060_review_round.md | 76 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/261107_telemetry_dashboard/060_review_round.md diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index e694f22d51a..317491c152e 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -1,8 +1,8 @@ -# Opt-in telemetry and the opencodex.me usage dashboard +# Default-on (opt-out) telemetry and the opencodex.me usage dashboard ## Objective and scope -Add anonymous, opt-in product telemetry to the opencodex proxy and a public, project-wide +Add anonymous, default-on (opt-out) product telemetry to the opencodex proxy and a public, project-wide analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform pings, active time, request and token totals, and error counts; a collector stores daily aggregates; the dashboard renders installs, active time, usage, errors and downloads. @@ -91,3 +91,9 @@ Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in `src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers there (010). + +## PR review round 1 + +Reviewer feedback on the open stack is recorded, with owner decisions D1–D20, in +[060_review_round.md](060_review_round.md). Those decisions supersede conflicting text in 000–050, including +the public privacy statement change that must land with the first layer able to send. diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md index 62a3cb13885..d6d39999357 100644 --- a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -21,7 +21,7 @@ client cannot emit a key or value the collector rejects and the two cannot drift - `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, llama, other. -- `version` must match `^\d+\.\d+\.\d+(-preview\.\d+)?$` (no build metadata). +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$` (no build metadata). ## State model diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md new file mode 100644 index 00000000000..eceb29c3fc1 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -0,0 +1,76 @@ +# 060 — PR review round 1 + +Review feedback on the stack (#8293, #8300, #8303, #8304, #8305, #8308) from Ingwannu, CodeRabbit and the +Codex reviewer. These decisions supersede any conflicting text in 000–050. Each lands on the layer that +owns the code, and each layer's PR description records it. + +## Why the privacy statement changes (blocking) + +`README.md` (and its seven translations under `readme/`) and `docs-site/src/content/docs/reference/code-signing.md` +carry the SignPath Foundation sentence "This program will not transfer any information to other networked +systems unless specifically requested…". With default-on telemetry that sentence would be false. SignPath's +conditions (https://signpath.org/terms.html) allow software that sends data to systems the user did not +specify only if the behaviour is described in a privacy policy, shown during installation, and can be +disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first +layer that can send), and layer 5 links it to the telemetry guide. The interactive install-time prompt and +the first-run dashboard popup are the "shown during installation, can be disabled" points. + +## Decisions + +D1 Consent-day cutoff: only UTC days strictly AFTER the UTC day of (decidedAt ?? noticeShownAt) are ever sent. +D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted. +D3 Version regex everywhere (contract): ^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ ; test vectors 2.40.1, 2.6.15-preview.20260701, 2.6.15-preview.20260701.1, 2.7.9-preview.20260712.2; reject build metadata. +D4 ocx telemetry enable: requires an interactive human terminal (isatty stdin AND stdout, not OCX_SERVICE, not isAgentDriven); prints the disclosure block, then asks a confirm (default yes) and records accepted; otherwise exits non-zero with agent_consent_required / tty_required pointing to the dashboard. disable always allowed. +D5 Disclosure block printed before every CLI Y/n prompt (ocx start/init/service install and enable): what is sent, what is never sent, that aggregates are public at opencodex.me/dashboard, how to see the exact payload (ocx telemetry status --json), how to turn it off (ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, Proxy details toggle). A CLI test pins the text. +D6 ocx config import: for EVERY caller, preserve the persisted telemetry block unchanged (ignore imported telemetry). Tests: undecided and declined installs. +D7 Re-read persisted consent and env immediately before transmission; if not effective, abort without sending. Ticks carry an AbortSignal cancelled by stop and by consent changes (nudge). +D8 Aggregation accumulates counters inside onEntry for the target day only; never retains ledger rows. +D9 CLI enable/disable: if mutatePersistedConfig returns unavailable (or throws), exit non-zero with an actionable error, print no success line, and do not delete the id unless the decline persisted. +D10 GET /api/telemetry preview uses the same day the scheduler would send next (oldest eligible unsent day). If none is eligible, preview the latest completed day and return previewEligible:false (field added); never imply it will be sent. +D11 Client honors HTTP 429 Retry-After (and 503): persist retryAfterUntil in telemetry-state.json, skip ticks until then; spreads sends with jitter. +D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. +D13 GUI popup dismissal (Escape/backdrop/close) records NOTHING; only Continue (PUT /api/telemetry) records decision and notice; the popup returns on the next app start while undecided. Remove POST /api/telemetry/notice entirely (server route, registry entry, capability exemptions, GUI call, tests, docs). +D14 Stats semantics: activeInstalls in totals and installs in breakdowns are INSTALL-DAYS (sum of daily reporting installs); add totals.avgDailyActiveInstalls (install-days / days in range, rounded to 1 decimal). Dashboard shows "Avg daily active installs" as the KPI with install-days in the tooltip. +D15 Small-cohort protection (collector): add an installs column (count of contributing reports) to daily_models and daily_errors (and dailyByProvider derivation); in every breakdown (providers, models, versions, platforms, channels, errors, dailyByProvider per day) fold rows with fewer than 10 install-days in the selected range into "other"; drop "other" if it is itself below 10; totals/daily/heatmap unchanged. Document the residual subtraction risk. +D16 Rate limit: 120 reports/hour per salted IP hash (shared IP / CGNAT capacity); 429 with Retry-After. +D17 Dashboard labels all figures "self-reported by opencodex installs"; docs note reports are unauthenticated and can be forged within rate limits. +D18 Collector-first rollout: any roster/contract change must be deployed to the collector before a client release emits it (README of collector + structure doc). +D19 Guide discloses the salted hourly IP hash for rate limiting (pruned after 2 hours). +D20 Dashboard: GitHub release download totals follow pagination (per_page=100, follow Link rel=next up to 10 pages, mark "partial" if capped); applying an empty snapshot clears the heatmap. + + +## Finding → decision map + +| Finding | Source | Decision | Layer | +| --- | --- | --- | --- | +| Published privacy statement becomes false | Ingwannu (blocking) | D12 | 2, 5 | +| Env opt-out days are backfilled | Ingwannu (blocking) | D2 | 2 | +| Consent day usage before the notice | Ingwannu | D1 | 2 | +| Preview tags rejected by the version regex | Ingwannu | D3 | 2 (contract) | +| activeInstalls over a range is a sum | Ingwannu | D14 | 4, 6 | +| CLI prompt is not a disclosure | Ingwannu | D5 | 2 | +| Opt-in wording | CodeRabbit | title/objective now say default-on (opt-out) | 1 | +| Roster changes break older collectors | CodeRabbit | D18 | 4, 5 | +| `ocx telemetry enable` without a TTY | CodeRabbit | D4 | 2 | +| Imported consent can backdate sending | CodeRabbit | D6 | 2 | +| Identity kept under an env opt-out | CodeRabbit | D2 | 2 | +| Preview day differs from the next send | CodeRabbit | D10 | 2, 3 | +| Automated dismissal records the notice | CodeRabbit | D13 | 2, 3 | +| Atomic dedup and rollup | CodeRabbit | already in #8304 (single conditional batch) | 4 | +| Forged reports | CodeRabbit | D17 (labelled self-reported; rate limit) | 4, 5, 6 | +| More than 64 model rows | CodeRabbit | already folded into `other`/`other` in #8300 | 2 | +| Shared-IP capacity and Retry-After | CodeRabbit | D11, D16 | 2, 4 | +| Small-cohort exposure | CodeRabbit | D15 | 4, 6 | +| IP-hash disclosure | CodeRabbit | D19 | 5 | +| Consent re-check before sending | Codex P1 | D7 | 2 | +| Aggregation keeps the whole ledger | Codex P2 | D8 | 2 | +| CLI reports unpersisted changes | Codex P2 | D9 | 2 | +| Public docs for the controls | Codex P1 (#8300, #8303) | already in #8305 | 5 | +| Release pagination, heatmap reset | Codex P2 (#8308) | D20 | 6 | + +## Residual risks + +- Reports are unauthenticated. Rate limits and the self-reported label bound, but do not prevent, forged + figures. +- Folding small rows into `other` limits single-install exposure; a suppressed value can still be + approximated by subtracting published rows from published totals when only one row is folded. From 9e0284d392b095e781abd32f024b67634bc5c33c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 03:19:00 +0900 Subject: [PATCH 07/29] fix(telemetry): apply review round 1 client core, consent, and privacy decisions --- AGENTS_INSTALL.md | 2 +- README.md | 2 +- .../content/docs/reference/code-signing.md | 14 +- .../content/docs/tr/reference/code-signing.md | 16 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +- .../references/01_surface_observe-system.md | 8 +- src/cli/capabilities-observe-system.ts | 6 +- src/cli/config-command.ts | 18 +- src/cli/registry.ts | 2 +- src/cli/telemetry.ts | 82 +++- src/server/background-lifecycle.ts | 3 + src/server/management/route-registry.ts | 1 - src/server/management/telemetry-routes.ts | 4 - src/telemetry/aggregate.ts | 125 ++++--- src/telemetry/contract.ts | 2 +- src/telemetry/routes.ts | 50 +-- src/telemetry/scheduler.ts | 282 +++++++++++--- src/telemetry/sender.ts | 32 ++ structure/telemetry.md | 44 ++- tests/cli/cli-telemetry.test.ts | 123 +++++- tests/server/server-telemetry-routes.test.ts | 110 ++++-- tests/usage/usage-telemetry-client.test.ts | 353 +++++++++++++++++- 29 files changed, 1037 insertions(+), 270 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index e5489ddc853..62402cc7f71 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -57,7 +57,7 @@ The following instructions concern **starring the repository on GitHub**. Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: -`PUT /api/telemetry`, `POST /api/telemetry/notice`, `ocx telemetry enable`, and +`PUT /api/telemetry`, `ocx telemetry enable`, and `ocx config set telemetry` refuse agent-driven callers with `agent_consent_required`. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always permitted. diff --git a/README.md b/README.md index 8197f747220..e7f3df50651 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index a85a1b381cf..356c5937c68 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -30,11 +30,15 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy -This program will not transfer any information to other networked systems unless specifically -requested by the user or the person installing or operating it. In practice, OpenCodex forwards -requests to the providers you configure. To look for new versions it also reads release -metadata from GitHub and the npm registry, and those checks carry none of your prompts, -credentials or usage data. +OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, +outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person +installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and +has not declined; it can be turned off at install time in that prompt and anytime with +`ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide +aggregates are public; otherwise data goes only where the user configures. In practice, +OpenCodex forwards requests to the providers you configure. To look for new versions it also +reads release metadata from GitHub and the npm registry, and those update checks carry none of +your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index d88d0866aed..890146fa9bf 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -31,11 +31,17 @@ imzalanmasını yasaklar. ### Gizlilik -Bu program; kullanıcı, kuran kişi veya çalıştıran kişi açıkça istemedikçe başka -ağ sistemlerine bilgi aktarmaz. OpenCodex, istekleri yapılandırdığınız sağlayıcılara -iletir. Yeni sürümler için GitHub ve npm kayıt deposundan yayın meta verilerini -de okur; bu kontroller istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi -taşımaz. +OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli +açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, +sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya +yolları asla dahil edilmez) gönderir; kurulum sırasında bu istemden ve istediğiniz +zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy +details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun +dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada +OpenCodex, istekleri yapılandırdığınız sağlayıcılara iletir. Yeni sürümler için +GitHub ve npm kayıt deposundan yayın meta verilerini de okur; bu güncelleme +kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi taşımaz. ## İndirmeyi doğrulama diff --git a/readme/README.fr.md b/readme/README.fr.md index 7613afe8b65..cc8d08dff5f 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index b396a9d0fa1..5762cd62484 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 5a3530c674e..8693f1e68ff 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 0b8428934ce..368a5899490 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index dd1eda9f03b..fe0aae5f81f 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index a56eac15ba2..7c1aa50300d 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 78cacab3696..8cc2b645b03 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 80ed6b2aa0c..86421b84262 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" } } } diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 2e132e543d2..97b2c18f044 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -1876,7 +1876,7 @@ State-changing: no. JSON mode: `payload`. Usage: `ocx telemetry status [--json]` -Inspect anonymous telemetry consent status and the next daily payload preview. +Inspect anonymous telemetry consent status and the daily payload preview. State-changing: no. JSON mode: `payload`. @@ -1889,7 +1889,7 @@ State-changing: no. JSON mode: `payload`. | `--json` | boolean | Emit the telemetry status and preview as JSON. | - Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped. -- Never creates or transmits a telemetry install identifier. +- Previews the oldest eligible unsent day, or the latest completed day with previewEligible = false when no day is eligible. Never creates or transmits a telemetry install identifier. ### `ocx telemetry enable` @@ -1905,8 +1905,8 @@ Drives no management route. |---|---|---| | `--json` | boolean | Emit the updated telemetry status as JSON. | -- Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json. -- Refuses with agent_consent_required when called from an agent-driven session. +- Requires an interactive human terminal, prints the telemetry disclosure block, and asks for confirmation before recording telemetry.decision = accepted. +- Refuses with agent_consent_required when agent-driven and with tty_required when non-interactive or running under OCX_SERVICE. ### `ocx telemetry disable` diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 4692f318666..2be0edcea69 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -732,12 +732,12 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ { command: ["telemetry", "status"], usage: "ocx telemetry status [--json]", - summary: "Inspect anonymous telemetry consent status and the next daily payload preview.", + summary: "Inspect anonymous telemetry consent status and the daily payload preview.", routes: [{"method": "GET", "path": "/api/telemetry"}], flags: [{"name": "--json", "value": "boolean", "summary": "Emit the telemetry status and preview as JSON."}], mutates: false, json: "payload", - details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Never creates or transmits a telemetry install identifier."], + details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Previews the oldest eligible unsent day, or the latest completed day with previewEligible = false when no day is eligible. Never creates or transmits a telemetry install identifier."], }, { command: ["telemetry", "enable"], @@ -747,7 +747,7 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], mutates: true, json: "envelope", - details: ["Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json.", "Refuses with agent_consent_required when called from an agent-driven session."], + details: ["Requires an interactive human terminal, prints the telemetry disclosure block, and asks for confirmation before recording telemetry.decision = accepted.", "Refuses with agent_consent_required when agent-driven and with tty_required when non-interactive or running under OCX_SERVICE."], }, { command: ["telemetry", "disable"], diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 001496c574b..9ff69f6b0df 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -9,7 +9,6 @@ import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; import { redactUrlForLog } from "../lib/redact"; -import { isAgentDriven } from "./agent-driven"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -336,11 +335,10 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { const current = readCliConfigForMutation(); - const agentDriven = isAgentDriven(); const rawInput = loadInput(path); - if (agentDriven && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { delete (rawInput as Record).telemetry; - console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); + console.warn("Note: ignored telemetry block during config import; use `ocx telemetry` to manage telemetry consent."); } const imported = validate(rawInput); withConfigMutationLockSync(() => { @@ -349,13 +347,11 @@ export async function handleConfigCommand(argv: string[]): Promise { || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } - if (agentDriven) { - const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; - if (persistedTelemetry !== undefined) { - imported.telemetry = structuredClone(persistedTelemetry); - } else { - delete imported.telemetry; - } + const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; + if (persistedTelemetry !== undefined) { + imported.telemetry = structuredClone(persistedTelemetry); + } else { + delete imported.telemetry; } saveConfig(imported); }); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 749de00e31e..592d6c766fd 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -840,7 +840,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ summary: "Inspect or change anonymous daily usage telemetry consent and preview the payload.", details: [ "status [--json] Show effective state, reason, install channel, last sent day, and exact preview payload.", - "enable [--json] Opt in to anonymous telemetry (refused with agent_consent_required when agent-driven).", + "enable [--json] Confirm and opt in to anonymous telemetry in an interactive terminal (refused with agent_consent_required or tty_required otherwise).", "disable [--json] Opt out of anonymous telemetry and delete telemetry-id.json.", ], }, diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 6433278018c..b89cfd2fa5e 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -30,6 +30,19 @@ const USAGE = `Usage: export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ + " Anonymous daily usage telemetry:", + " • Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", + " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", + " • See the exact payload: ocx telemetry status --json", + " • Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", +]); + +export function printTelemetryDisclosure(): void { + console.log(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); +} + export function isTelemetryDeferralCurrent(record: string | null, version: string, now: number): boolean { if (!record) return false; const match = /^(\S+)\s+(\S+)\s*$/.exec(record.trim()); @@ -52,10 +65,18 @@ export function setTelemetryPromptDepsForTests(deps: TelemetryPromptTestDeps | n promptDepsForTests = deps; } +function isInteractiveTty(): boolean { + try { + return promptDepsForTests?.isTty ? promptDepsForTests.isTty() : Boolean(isatty(0) && isatty(1)); + } catch { + return false; + } +} + async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { const nowIso = new Date().toISOString(); const dir = getConfigDir(); - await withConfigDirHardeningSettled(dir, () => + const outcome = await withConfigDirHardeningSettled(dir, () => mutatePersistedConfig(fresh => { const prev = fresh.telemetry; const next: OcxTelemetryConfig = { @@ -68,6 +89,11 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise return { changed, value: next }; }), ); + if (outcome.status === "unavailable") { + throw new Error( + `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, + ); + } if (decision === "declined") { deleteTelemetryInstallId(dir); } @@ -85,12 +111,7 @@ export async function maybeShowTelemetryPrompt(): Promise { if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { return; } - let isTty = false; - try { - isTty = promptDepsForTests?.isTty ? promptDepsForTests.isTty() : isatty(0) && isatty(1); - } catch { - /* best-effort */ - } + const isTty = isInteractiveTty(); const dir = getConfigDir(); if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); @@ -113,6 +134,7 @@ export async function maybeShowTelemetryPrompt(): Promise { } catch { /* best-effort */ } return; } + printTelemetryDisclosure(); const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; const yes = await ask({ question: "\n Share anonymous usage statistics? [Y/n]", @@ -131,7 +153,10 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, - `Preview (${snapshot.preview.day}):`, + `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, + snapshot.previewEligible + ? `Preview (${snapshot.preview.day}, next eligible send):` + : `Preview (${snapshot.preview.day}, sample only — not eligible to send):`, JSON.stringify(snapshot.preview, null, 2), ]; } @@ -149,15 +174,29 @@ export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDep const args = [...argv]; const wantsJson = takeFlag(args, "--json"); const sub = (args.shift() ?? "status").toLowerCase(); - if (sub === "enable" && isAgentDriven()) { - const error = { - ok: false, - code: "agent_consent_required", - error: "Refused: telemetry consent must be enabled by a human user in their own terminal or dashboard.", - }; - if (wantsJson) console.log(JSON.stringify(error, null, 2)); - else console.error(`${error.code}: ${error.error}`); - return 1; + if (sub === "enable") { + if (isAgentDriven()) { + const error = { + ok: false, + code: "agent_consent_required", + error: + "Refused: telemetry consent must be enabled by a human user in their own interactive terminal or in the dashboard (Proxy details).", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } + if (Boolean(process.env.OCX_SERVICE) || !isInteractiveTty()) { + const error = { + ok: false, + code: "tty_required", + error: + "Refused: 'ocx telemetry enable' requires an interactive human terminal (TTY stdin and stdout, not OCX_SERVICE). Enable telemetry in an interactive terminal or in the dashboard (Proxy details).", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } } return runCliAction(async () => { if (args.includes("--json")) { @@ -171,6 +210,15 @@ export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDep } if (sub === "enable") { rejectArgs(args, USAGE); + printTelemetryDisclosure(); + const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; + const yes = await ask({ + question: "\n Share anonymous usage statistics? [Y/n]", + defaultYes: true, + }); + if (!yes) { + throw new Error("Telemetry enable cancelled: confirmation was declined."); + } await persistTelemetryDecision("accepted"); const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); printData({ ok: true, ...snapshot }, wantsJson, ["Anonymous telemetry enabled."]); diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 2661fff01d3..abfcc5e67d9 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,6 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; +import { recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -143,6 +144,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // Optional background loop. }); + } else { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 119c45f0156..5f1f20f7a11 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -454,7 +454,6 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/telemetry-routes { method: "GET", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: false }, { method: "PUT", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: changing telemetry consent over HTTP requires a human dashboard session; CLI callers use ocx telemetry enable/disable." } }, - { method: "POST", path: "/api/telemetry/notice", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "Recording that the disclosure notice was seen activates default-on telemetry and requires a human dashboard session." } }, // --- Further routes an equality scan of their own file cannot see. --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/src/server/management/telemetry-routes.ts b/src/server/management/telemetry-routes.ts index 3739c5380b7..834a17af9e9 100644 --- a/src/server/management/telemetry-routes.ts +++ b/src/server/management/telemetry-routes.ts @@ -1,7 +1,6 @@ import type { ManagementContext } from "./context"; import { handleGetTelemetryRoute, - handlePostTelemetryNoticeRoute, handlePutTelemetryRoute, } from "../../telemetry/routes"; @@ -13,8 +12,5 @@ export async function handleTelemetryRoutes(ctx: ManagementContext): Promise, -): DailyTelemetryAggregate { +export interface DailyTelemetryAccumulator { + onEntry: (entry: PersistedUsageEntry) => void; + finalize: () => DailyTelemetryAggregate; +} + +/** + * Streaming accumulator for one completed UTC day. + * + * Accumulates counters directly inside `onEntry` for entries falling inside `day` + * and never retains `PersistedUsageEntry` rows in memory. + */ +export function createDailyTelemetryAccumulator(day: string): DailyTelemetryAccumulator { const dayStartMs = parseCalendarUtcDayMs(day); if (dayStartMs === null) { throw new Error(`Invalid UTC day: ${day}`); @@ -244,9 +252,9 @@ export function aggregateTelemetryEntriesForDay( row.reasoningTokens += tokens.reasoningTokens; }; - for (const entry of entries) { + const onEntry = (entry: PersistedUsageEntry): void => { if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { - continue; + return; } minutes.add(Math.floor(entry.timestamp / 60_000)); const outcome = classifyRequestOutcome(entry); @@ -264,64 +272,77 @@ export function aggregateTelemetryEntriesForDay( } else { addModelAttribution(entry.provider, entry.model, entry.usage); } - } + }; - let models = [...modelMap.values()].sort((a, b) => { - if (b.requests !== a.requests) return b.requests - a.requests; - const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; - const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; - if (bTokens !== aTokens) return bTokens - aTokens; - if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); - return a.modelFamily.localeCompare(b.modelFamily); - }); + const finalize = (): DailyTelemetryAggregate => { + let models = [...modelMap.values()].sort((a, b) => { + if (b.requests !== a.requests) return b.requests - a.requests; + const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; + const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; + if (bTokens !== aTokens) return bTokens - aTokens; + if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); + return a.modelFamily.localeCompare(b.modelFamily); + }); - if (models.length > TELEMETRY_MAX_MODELS) { - const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); - const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); - let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); - if (!otherRow) { - otherRow = { - providerKind: "other", - modelFamily: "other", - requests: 0, - inputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - }; - kept.push(otherRow); - } - for (const row of overflow) { - otherRow.requests += row.requests; - otherRow.inputTokens += row.inputTokens; - otherRow.cacheReadTokens += row.cacheReadTokens; - otherRow.cacheWriteTokens += row.cacheWriteTokens; - otherRow.outputTokens += row.outputTokens; - otherRow.reasoningTokens += row.reasoningTokens; + if (models.length > TELEMETRY_MAX_MODELS) { + const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); + const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); + let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); + if (!otherRow) { + otherRow = { + providerKind: "other", + modelFamily: "other", + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + kept.push(otherRow); + } + for (const row of overflow) { + otherRow.requests += row.requests; + otherRow.inputTokens += row.inputTokens; + otherRow.cacheReadTokens += row.cacheReadTokens; + otherRow.cacheWriteTokens += row.cacheWriteTokens; + otherRow.outputTokens += row.outputTokens; + otherRow.reasoningTokens += row.reasoningTokens; + } + models = kept; } - models = kept; - } - return { - day, - activeMinutes: Math.min(1440, minutes.size), - requests, - models, - errors, + return { + day, + activeMinutes: Math.min(1440, minutes.size), + requests: { ...requests }, + models, + errors: { ...errors }, + }; }; + + return { onEntry, finalize }; +} + +export function aggregateTelemetryEntriesForDay( + day: string, + entries: Iterable, +): DailyTelemetryAggregate { + const accumulator = createDailyTelemetryAccumulator(day); + for (const entry of entries) { + accumulator.onEntry(entry); + } + return accumulator.finalize(); } export async function aggregateTelemetryForDay( day: string, options: { signal?: AbortSignal } = {}, ): Promise { - const entries: PersistedUsageEntry[] = []; + const accumulator = createDailyTelemetryAccumulator(day); await scanUsageLedgerCooperatively({ signal: options.signal, - onEntry: entry => { - entries.push(entry); - }, + onEntry: accumulator.onEntry, }); - return aggregateTelemetryEntriesForDay(day, entries); + return accumulator.finalize(); } diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 2e645963f9a..20d23d1dcc5 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -8,7 +8,7 @@ export const TELEMETRY_SCHEMA_VERSION = 1 as const; export const TELEMETRY_MAX_MODELS = 64; -export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d+)?$/; +export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$/; export const PROVIDER_KINDS = Object.freeze([ "openai", diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 65be7e621eb..13fb34bdd59 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -15,12 +15,18 @@ import { readTelemetryInstallId, } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "./payload"; -import { latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState } from "./scheduler"; +import { + latestCompletedUtcDay, + nudgeTelemetryScheduler, + readTelemetryState, + resolveNextEligibleTelemetryDay, +} from "./scheduler"; export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { installChannel: TelemetryInstallChannel; lastSentDay: string | null; lastSentAt: string | null; + previewEligible: boolean; preview: TelemetryPayloadV1; } @@ -34,7 +40,9 @@ export async function buildTelemetryStatusSnapshot( const status = resolveTelemetryStatus(config, env); const installChannel = resolveInstallChannel({ env }); const state = readTelemetryState(configDir); - const day = latestCompletedUtcDay(now); + const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); + const previewEligible = nextEligibleDay !== null; + const day = nextEligibleDay ?? latestCompletedUtcDay(now); const aggregate = await aggregateTelemetryForDay(day); const identity = readTelemetryInstallId(configDir); const installDayHash = identity @@ -55,6 +63,7 @@ export async function buildTelemetryStatusSnapshot( installChannel, lastSentDay: state.lastSentDay, lastSentAt: state.lastSentAt, + previewEligible, preview, }; } @@ -133,40 +142,3 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise { - if (!hasHumanDashboardSession(ctx)) { - return consentRefusedResponse(ctx); - } - const nowIso = new Date().toISOString(); - let committed: OcxTelemetryConfig | undefined; - const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => - mutatePersistedConfig(fresh => { - const prev = fresh.telemetry; - if (prev?.noticeShownAt && !Number.isNaN(Date.parse(prev.noticeShownAt))) { - committed = structuredClone(prev); - return { changed: false, value: prev }; - } - const next: OcxTelemetryConfig = { - ...(prev?.decision ? { decision: prev.decision } : {}), - ...(prev?.decidedAt ? { decidedAt: prev.decidedAt } : {}), - noticeShownAt: nowIso, - }; - fresh.telemetry = next; - committed = structuredClone(next); - return { changed: true, value: next }; - }), - ); - if (outcome.status === "unavailable") { - return jsonResponse( - { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, - 503, - ctx.req, - ctx.config, - ); - } - adoptPersistedTelemetry(ctx.config, committed); - nudgeTelemetryScheduler(); - const snapshot = await buildTelemetryStatusSnapshot(ctx.config); - return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); -} diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 4d98a9562d3..61ac771cc48 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -7,13 +7,22 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; -import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; -import { computeInstallDayHash, ensureTelemetryInstallId, markTelemetryFirstReportSent } from "./install-id"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; +import { parseCalendarUtcDayMs } from "./contract"; +import { + computeInstallDayHash, + deleteTelemetryInstallId, + ensureTelemetryInstallId, + markTelemetryFirstReportSent, +} from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; export const TELEMETRY_STATE_FILE = "telemetry-state.json"; export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; +export const TELEMETRY_STARTUP_BASE_DELAY_MS = 30_000; +export const TELEMETRY_STARTUP_JITTER_MS = 60_000; +export const TELEMETRY_INTERVAL_JITTER_MS = 15 * 60 * 1000; const DAY_MS = 86_400_000; const MAX_BACKLOG_DAYS = 7; const STATE_RETENTION_DAYS = 30; @@ -23,6 +32,8 @@ export interface TelemetryState { sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; + ineligibleThroughDay: string | null; + retryAfterUntil: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -35,6 +46,8 @@ function emptyState(): TelemetryState { sentDays: {}, lastSentDay: null, lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, }; } @@ -53,11 +66,21 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { sentDays[day] = at; } } + const ineligibleThroughDay = + typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null + ? raw.ineligibleThroughDay + : null; + const retryAfterUntil = + typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) + ? raw.retryAfterUntil + : null; return { version: 1, sentDays, lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + ineligibleThroughDay, + retryAfterUntil, }; } catch { return emptyState(); @@ -70,18 +93,34 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); const prunedSentDays: Record = {}; - for (const [day, at] of Object.entries(state.sentDays)) { + for (const [day, at] of Object.entries(state.sentDays ?? {})) { if (day >= minKeepDay) prunedSentDays[day] = at; } const payload: TelemetryState = { version: 1, sentDays: prunedSentDays, - lastSentDay: state.lastSentDay, - lastSentAt: state.lastSentAt, + lastSentDay: state.lastSentDay ?? null, + lastSentAt: state.lastSentAt ?? null, + ineligibleThroughDay: state.ineligibleThroughDay ?? null, + retryAfterUntil: state.retryAfterUntil ?? null, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } +/** + * Persist `ineligibleThroughDay = today's UTC day` in `telemetry-state.json` (atomic no-follow) + * and delete `telemetry-id.json` whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. + */ +export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { + const todayDay = formatUtcDay(utcMidnightMs(now)); + const state = readTelemetryState(configDir); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + export function utcMidnightMs(epochMs: number): number { const d = new Date(epochMs); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); @@ -95,11 +134,58 @@ export function latestCompletedUtcDay(now = Date.now()): string { return formatUtcDay(utcMidnightMs(now) - DAY_MS); } +/** + * Resolve the oldest completed UTC day (within the 7-day window) that is eligible to be sent next: + * - `status.effective` must be true. + * - Strictly AFTER the UTC day of `decidedAt ?? noticeShownAt` (D1). + * - Strictly AFTER `state.ineligibleThroughDay` when set (D2). + * - Not already recorded in `state.sentDays`. + */ +export function resolveNextEligibleTelemetryDay( + status: TelemetryConsentStatus, + state: TelemetryState, + now = Date.now(), +): { day: string | null; reason: string } { + if (!status.effective) { + return { day: null, reason: status.reason }; + } + const consentTimestamp = status.decidedAt ?? status.noticeShownAt; + if (!consentTimestamp) { + return { day: null, reason: "missing-consent-timestamp" }; + } + const consentMs = Date.parse(consentTimestamp); + if (Number.isNaN(consentMs)) { + return { day: null, reason: "invalid-consent-timestamp" }; + } + const consentDay = formatUtcDay(consentMs); + const todayMs = utcMidnightMs(now); + + for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { + const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); + if (candidateDay <= consentDay) continue; + if (state.ineligibleThroughDay && candidateDay <= state.ineligibleThroughDay) continue; + if (state.sentDays[candidateDay]) continue; + return { day: candidateDay, reason: "eligible" }; + } + return { day: null, reason: "up-to-date" }; +} + +export function computeTelemetryStartupDelayMs(random: () => number = Math.random): number { + return TELEMETRY_STARTUP_BASE_DELAY_MS + Math.floor(random() * TELEMETRY_STARTUP_JITTER_MS); +} + +export function computeTelemetryIntervalDelayMs( + baseMs = TELEMETRY_TICK_INTERVAL_MS, + random: () => number = Math.random, +): number { + return baseMs + Math.floor(random() * TELEMETRY_INTERVAL_JITTER_MS); +} + export interface TelemetryTickOptions extends TelemetrySendOptions, ResolveInstallChannelOptions { configDir?: string; config?: Pick readConfig?: () => Pick; - aggregateDay?: (day: string) => Promise; + aggregateDay?: (day: string, options?: { signal?: AbortSignal }) => Promise; } export interface TelemetryTickResult { @@ -109,71 +195,150 @@ export interface TelemetryTickResult { sendResult?: TelemetrySendResult; } +const activeTickControllers = new Set(); + +function abortActiveTicks(reason: string): void { + for (const controller of activeTickControllers) { + controller.abort(new Error(reason)); + } + activeTickControllers.clear(); +} + /** * Run one telemetry tick: * - Re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`. - * - Sends only completed UTC days on or after the UTC day of `decidedAt ?? noticeShownAt` - * (no pre-consent backfill). + * - Persists `ineligibleThroughDay` and deletes the install id whenever an env opt-out is observed. + * - Sends only completed UTC days strictly after the UTC day of `decidedAt ?? noticeShownAt` + * and strictly after `ineligibleThroughDay`. + * - Re-checks consent and env immediately before transmission and cancels in-flight work on stop/nudge. * - Sends at most ONE day per tick, oldest first, at most the last 7 completed days. */ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Promise { const now = options.now ?? Date.now(); - const env = options.env ?? process.env; const configDir = options.configDir ?? getConfigDir(); - const config = options.config - ?? (options.readConfig ? options.readConfig() : readConfigDiagnostics({ passive: true }).config); - const status = resolveTelemetryStatus(config, env); - if (!status.effective) { + const readCurrentEnv = (): Record => options.env ?? process.env; + const readCurrentConfig = (): Pick | undefined => { + if (options.readConfig) return options.readConfig(); + const diskConfig = readConfigDiagnostics({ passive: true }).config; + if (options.config) { + if (diskConfig.telemetry !== undefined) { + const currentEnv = readCurrentEnv(); + const optStatus = resolveTelemetryStatus(options.config, currentEnv); + const diskStatus = resolveTelemetryStatus(diskConfig, currentEnv); + return !optStatus.effective ? options.config : !diskStatus.effective ? diskConfig : options.config; + } + return options.config; + } + return diskConfig; + }; + + const initialEnv = readCurrentEnv(); + if (isTelemetryEnvOptOut(initialEnv) !== null) { + recordTelemetryEnvOptOut(configDir, now); + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); return { sent: false, reason: status.reason }; } - const consentTimestamp = status.decidedAt ?? status.noticeShownAt; - if (!consentTimestamp) { - return { sent: false, reason: "missing-consent-timestamp" }; - } - const consentMs = Date.parse(consentTimestamp); - if (Number.isNaN(consentMs)) { - return { sent: false, reason: "invalid-consent-timestamp" }; + + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); + if (!status.effective) { + return { sent: false, reason: status.reason }; } - const consentDay = formatUtcDay(consentMs); - const todayMs = utcMidnightMs(now); - const state = readTelemetryState(configDir); - let targetDay: string | null = null; - for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { - const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); - if (candidateDay < consentDay) continue; - if (state.sentDays[candidateDay]) continue; - targetDay = candidateDay; - break; + const state = readTelemetryState(configDir); + if (state.retryAfterUntil) { + const retryUntilMs = Date.parse(state.retryAfterUntil); + if (!Number.isNaN(retryUntilMs) && now < retryUntilMs) { + return { sent: false, reason: "retry-after-backoff" }; + } } + + const { day: targetDay, reason: eligibilityReason } = resolveNextEligibleTelemetryDay(status, state, now); if (!targetDay) { - return { sent: false, reason: "up-to-date" }; + return { sent: false, reason: eligibilityReason }; } - const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = identity.firstReportSent !== true; - const installDayHash = computeInstallDayHash(identity.installId, targetDay); - const aggregate = await (options.aggregateDay - ? options.aggregateDay(targetDay) - : aggregateTelemetryForDay(targetDay, { signal: options.signal })); - const payload = buildTelemetryPayloadV1({ - aggregate, - installDayHash, - newInstall, - now, - installChannel: resolveInstallChannel(options), - }); - const sendResult = await sendTelemetryPayload(payload, { ...options, now }); - if (!sendResult.ok) { - return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + const tickController = new AbortController(); + activeTickControllers.add(tickController); + const parentSignal = options.signal; + const onParentAbort = () => tickController.abort(parentSignal?.reason); + if (parentSignal?.aborted) { + activeTickControllers.delete(tickController); + return { sent: false, day: targetDay, reason: "aborted" }; + } + parentSignal?.addEventListener("abort", onParentAbort, { once: true }); + + try { + let aggregate: DailyTelemetryAggregate; + try { + aggregate = await (options.aggregateDay + ? options.aggregateDay(targetDay, { signal: tickController.signal }) + : aggregateTelemetryForDay(targetDay, { signal: tickController.signal })); + } catch (error) { + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted" }; + } + throw error; + } + + const preSendEnv = readCurrentEnv(); + if (isTelemetryEnvOptOut(preSendEnv) !== null) { + recordTelemetryEnvOptOut(configDir, now); + const latestStatus = resolveTelemetryStatus(readCurrentConfig(), preSendEnv); + return { sent: false, day: targetDay, reason: latestStatus.reason }; + } + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted" }; + } + const preSendStatus = resolveTelemetryStatus(readCurrentConfig(), preSendEnv); + if (!preSendStatus.effective) { + return { sent: false, day: targetDay, reason: preSendStatus.reason }; + } + const preSendState = readTelemetryState(configDir); + const recheckedEligibility = resolveNextEligibleTelemetryDay(preSendStatus, preSendState, now); + if (recheckedEligibility.day !== targetDay) { + return { sent: false, day: targetDay, reason: recheckedEligibility.reason }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + const newInstall = identity.firstReportSent !== true; + const installDayHash = computeInstallDayHash(identity.installId, targetDay); + const payload = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + now, + installChannel: resolveInstallChannel({ ...options, env: preSendEnv }), + }); + const sendResult = await sendTelemetryPayload(payload, { + ...options, + signal: tickController.signal, + now, + }); + if (!sendResult.ok) { + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted", sendResult }; + } + if (typeof sendResult.retryAfterMs === "number" && sendResult.retryAfterMs > 0) { + const freshState = readTelemetryState(configDir); + freshState.retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); + writeTelemetryState(freshState, configDir, now); + return { sent: false, day: targetDay, reason: "retry-after-backoff", sendResult }; + } + return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + } + markTelemetryFirstReportSent(configDir); + const freshState = readTelemetryState(configDir); + const nowIso = new Date(now).toISOString(); + freshState.sentDays[targetDay] = nowIso; + freshState.lastSentDay = targetDay; + freshState.lastSentAt = nowIso; + freshState.retryAfterUntil = null; + writeTelemetryState(freshState, configDir, now); + return { sent: true, day: targetDay, reason: "sent", sendResult }; + } finally { + parentSignal?.removeEventListener("abort", onParentAbort); + activeTickControllers.delete(tickController); } - markTelemetryFirstReportSent(configDir); - const nowIso = new Date(now).toISOString(); - state.sentDays[targetDay] = nowIso; - state.lastSentDay = targetDay; - state.lastSentAt = nowIso; - writeTelemetryState(state, configDir, now); - return { sent: true, day: targetDay, reason: "sent", sendResult }; } let startupTimer: ReturnType | null = null; @@ -186,6 +351,7 @@ let running = false; function triggerBackgroundTick(): void { if (!running) return; if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } stopTelemetryScheduler(); return; } @@ -199,13 +365,14 @@ function triggerBackgroundTick(): void { export function startTelemetryScheduler(options: { startupDelayMs?: number; intervalMs?: number } = {}): () => void { if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } return () => {}; } if (running) return stopTelemetryScheduler; running = true; detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); - const startupDelay = options.startupDelayMs ?? (30_000 + Math.floor(Math.random() * 30_000)); - const intervalMs = options.intervalMs ?? TELEMETRY_TICK_INTERVAL_MS; + const startupDelay = options.startupDelayMs ?? computeTelemetryStartupDelayMs(); + const intervalMs = options.intervalMs ?? computeTelemetryIntervalDelayMs(); startupTimer = setTimeout(() => { startupTimer = null; triggerBackgroundTick(); @@ -220,6 +387,7 @@ export function startTelemetryScheduler(options: { startupDelayMs?: number; inte export function stopTelemetryScheduler(): void { running = false; + abortActiveTicks("Telemetry scheduler stopped"); if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; @@ -240,8 +408,10 @@ export function stopTelemetryScheduler(): void { } export function nudgeTelemetryScheduler(): void { + abortActiveTicks("Telemetry consent changed"); if (!running) return; if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } stopTelemetryScheduler(); return; } diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts index d7a4be4b4a8..cc42909a08b 100644 --- a/src/telemetry/sender.ts +++ b/src/telemetry/sender.ts @@ -3,6 +3,9 @@ import { validateTelemetryPayloadV1, type TelemetryPayloadV1 } from "./contract" export const TELEMETRY_COLLECTOR_ENDPOINT = "https://telemetry.opencodex.me/v1/events"; export const TELEMETRY_SEND_TIMEOUT_MS = 10_000; +export const TELEMETRY_DEFAULT_RETRY_AFTER_MS = 15 * 60 * 1000; +export const TELEMETRY_RETRY_JITTER_MS = 5 * 60 * 1000; +const MAX_RETRY_AFTER_MS = 7 * 86_400_000; export interface TelemetrySendOptions { endpoint?: string; @@ -16,6 +19,24 @@ export interface TelemetrySendResult { ok: boolean; status?: number; error?: string; + retryAfterMs?: number; +} + +/** + * Parse an HTTP `Retry-After` header value (delta-seconds or HTTP-date) into milliseconds. + */ +export function parseRetryAfterMs(headerValue: string | null | undefined, now = Date.now()): number | null { + if (typeof headerValue !== "string") return null; + const trimmed = headerValue.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS); + } + const targetMs = Date.parse(trimmed); + if (Number.isNaN(targetMs) || targetMs <= now) return null; + return Math.min(targetMs - now, MAX_RETRY_AFTER_MS); } let testOverrides: TelemetrySendOptions | null = null; @@ -49,6 +70,17 @@ export async function sendTelemetryPayload( signal: deadline.signal, }); if (!response.ok) { + if (response.status === 429 || response.status === 503) { + const headerRetryMs = parseRetryAfterMs(response.headers?.get?.("retry-after"), now); + const retryAfterMs = headerRetryMs + ?? (TELEMETRY_DEFAULT_RETRY_AFTER_MS + Math.floor(Math.random() * TELEMETRY_RETRY_JITTER_MS)); + return { + ok: false, + status: response.status, + error: `collector responded ${response.status}`, + retryAfterMs, + }; + } return { ok: false, status: response.status, error: `collector responded ${response.status}` }; } return { ok: true, status: response.status }; diff --git a/structure/telemetry.md b/structure/telemetry.md index 6adefcb16db..82dc673dde6 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -9,7 +9,10 @@ Anonymous, default-on daily usage telemetry owned by `src/telemetry/`, `src/type 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, semver build metadata, and more than 64 model rows. +out-of-window or non-calendar `day` values, versions not matching +`^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$` (no build metadata), and more than 64 model rows. +Any roster or wire-contract addition must be deployed to the collector before a client release +emits the new values (collector-first rollout). ## Consent and agent boundary @@ -25,12 +28,17 @@ via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. Only a human can transition an undecided install to active sending: -- `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated +- `PUT /api/telemetry` in `src/telemetry/routes.ts` (delegated from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) - require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. -- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in - `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always - permitted. + requires `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. + `GET /api/telemetry` previews the oldest eligible unsent day (or the latest completed day with + `previewEligible: false` when no day is eligible). +- `ocx telemetry enable` in `src/cli/telemetry.ts` requires an interactive human terminal + (`isatty` stdin and stdout, not `OCX_SERVICE`, not agent-driven), prints the disclosure block, + and asks for confirmation before recording `accepted`; otherwise it exits non-zero with + `agent_consent_required` or `tty_required`. `ocx telemetry disable` is always permitted. + `ocx config set`/`unset` in `src/cli/config-command.ts` refuse `telemetry`, and `ocx config import` + preserves the persisted `telemetry` block unchanged for every caller. - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. @@ -38,17 +46,25 @@ Only a human can transition an undecided install to active sending: ## Identity, aggregation, and scheduling `src/telemetry/install-id.ts` manages `/telemetry-id.json` (`0o600`, exclusive -no-follow creation) on the first send and deletes it when telemetry is declined. Only +no-follow creation) on the first send and deletes it when telemetry is declined or when an +`OCX_TELEMETRY`/`DO_NOT_TRACK` environment 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, 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`. +completed UTC day, accumulating counters directly inside `onEntry` 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`. `src/telemetry/scheduler.ts` runs an unref'd background -timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on -each tick and sending at most one completed post-consent day per tick. +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) in +`telemetry-state.json` and deletes `telemetry-id.json`. Each tick sends at most one completed UTC +day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after +`ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env +immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index eaea6062012..e8b25bf6a0a 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; import { + TELEMETRY_CLI_DISCLOSURE_LINES, TELEMETRY_DEFERRAL_FILE, handleTelemetryCommand, maybeShowTelemetryPrompt, @@ -12,6 +13,7 @@ import { } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { + ensureTelemetryInstallId, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath, @@ -103,8 +105,42 @@ describe("ocx telemetry CLI and interactive prompt", () => { const log = spyOn(console, "log").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); try { + // D4: Non-TTY or OCX_SERVICE refuses enable with tty_required pointing to the dashboard: + setTelemetryPromptDepsForTests({ isTty: () => false }); + const nonTtyCode = await handleTelemetryCommand(["enable", "--json"]); + expect(nonTtyCode).toBe(1); + const nonTtyBody = JSON.parse(log.mock.calls.map(c => String(c[0])).join("\n")) as { code: string; error: string }; + expect(nonTtyBody.code).toBe("tty_required"); + expect(nonTtyBody.error.toLowerCase()).toContain("dashboard"); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + log.mockClear(); + process.env.OCX_SERVICE = "1"; + setTelemetryPromptDepsForTests({ isTty: () => true }); + const serviceCode = await handleTelemetryCommand(["enable", "--json"]); + expect(serviceCode).toBe(1); + expect(log.mock.calls.map(c => String(c[0])).join("\n")).toContain("tty_required"); + delete process.env.OCX_SERVICE; + + // Interactive human terminal: prints disclosure block, prompts with defaultYes=true, and records accepted: + log.mockClear(); + let confirmQuestion = ""; + let confirmDefaultYes: boolean | undefined; + let disclosureBeforePrompt = ""; + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + disclosureBeforePrompt = log.mock.calls.map(c => String(c[0])).join("\n"); + confirmQuestion = opts.question; + confirmDefaultYes = opts.defaultYes; + return true; + }, + }); const enableCode = await handleTelemetryCommand(["enable", "--json"]); expect(enableCode).toBe(0); + expect(confirmDefaultYes).toBe(true); + expect(confirmQuestion).toContain("Share anonymous usage statistics?"); + expect(disclosureBeforePrompt).toBe(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); const tick = await runTelemetryTick({ @@ -136,12 +172,13 @@ describe("ocx telemetry CLI and interactive prompt", () => { } } } finally { + delete process.env.OCX_SERVICE; log.mockRestore(); err.mockRestore(); } }); - test("ocx config set/unset refuse telemetry and import drops telemetry when agent-driven", async () => { + test("D6: ocx config set/unset refuse telemetry and import preserves persisted telemetry for every caller (human and agent)", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const warn = spyOn(console, "warn").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); @@ -166,13 +203,19 @@ describe("ocx telemetry CLI and interactive prompt", () => { }), "utf8", ); + // 1a. Human caller (not agent-driven) on undecided install -> imported telemetry is ignored, disk stays undecided: + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + + // 1b. Agent-driven caller on undecided install -> imported telemetry is ignored, disk stays undecided: + warn.mockClear(); process.env.CODEX_THREAD_ID = "agent-import"; - // Direction 1: undecided on disk -> agent import does not inject telemetry expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); - // Direction 2: declined on disk -> agent import preserves declined whether input has telemetry or omits it + // 2. Declined on disk -> both human and agent callers preserve declined whether input has telemetry or omits it: expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); const declinedBefore = readConfigDiagnostics().config.telemetry; expect(declinedBefore?.decision).toBe("declined"); @@ -180,6 +223,10 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + delete process.env.CODEX_THREAD_ID; + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + const importWithoutTelemetry = join(home, "imported-no-telemetry.json"); writeFileSync( importWithoutTelemetry, @@ -228,18 +275,30 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(log.mock.calls.length).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); - // Interactive human run prompts with defaultYes: true and records accepted: + // Interactive human run prints the disclosure block before prompting with defaultYes: true and records accepted: delete process.env.CODEX_THREAD_ID; let observedDefaultYes: boolean | undefined; + let printedBeforeConfirm = ""; setTelemetryPromptDepsForTests({ isTty: () => true, interactiveConfirm: async opts => { + printedBeforeConfirm = log.mock.calls.map(c => String(c[0])).join("\n"); observedDefaultYes = opts.defaultYes; return opts.defaultYes; }, }); await maybeShowTelemetryPrompt(); expect(observedDefaultYes).toBe(true); + expect(printedBeforeConfirm).toBe(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); + // D5: Pin required elements of the disclosure text: + expect(printedBeforeConfirm).toContain("telemetry.opencodex.me"); + expect(printedBeforeConfirm).toContain("Never sent: prompts, model outputs"); + expect(printedBeforeConfirm).toContain("opencodex.me/dashboard"); + expect(printedBeforeConfirm).toContain("ocx telemetry status --json"); + expect(printedBeforeConfirm).toContain("ocx telemetry disable"); + expect(printedBeforeConfirm).toContain("OCX_TELEMETRY=0"); + expect(printedBeforeConfirm).toContain("DO_NOT_TRACK=1"); + expect(printedBeforeConfirm).toContain("Proxy details"); const telemetry = readConfigDiagnostics().config.telemetry; expect(telemetry?.decision).toBe("accepted"); expect(typeof telemetry?.decidedAt).toBe("string"); @@ -250,6 +309,62 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); + test("D9: enable and disable exit non-zero with no success line and preserve telemetry-id.json when config.json is unavailable", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const { identity } = ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + // Corrupt config.json so mutatePersistedConfig returns { status: "unavailable", reason: "malformed" }: + writeFileSync(join(home, "config.json"), "{ malformed json\n", "utf8"); + + const disableCode = await handleTelemetryCommand(["disable"]); + expect(disableCode).not.toBe(0); + expect(log.mock.calls).toHaveLength(0); + expect(err.mock.calls.map(c => String(c[0])).join("\n")).toContain("config.json is invalid"); + // Install id must NOT be deleted when the decline failed to persist: + expect(existsSync(telemetryIdPath(home))).toBe(true); + expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); + + log.mockClear(); + err.mockClear(); + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async () => true, + }); + const enableCode = await handleTelemetryCommand(["enable"]); + expect(enableCode).not.toBe(0); + // Only the disclosure block was printed before confirm; no "Anonymous telemetry enabled." success line: + expect(log.mock.calls.map(c => String(c[0])).join("\n")).not.toContain("Anonymous telemetry enabled"); + expect(err.mock.calls.map(c => String(c[0])).join("\n")).toContain("config.json is invalid"); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test("D12: README.md, translated READMEs, and code-signing.md state the telemetry privacy policy accurately", () => { + for (const rel of [ + "README.md", + "readme/README.fr.md", + "readme/README.ja.md", + "readme/README.ko.md", + "readme/README.ru.md", + "readme/README.tr.md", + "readme/README.zh-CN.md", + "readme/README.zh-TW.md", + "docs-site/src/content/docs/reference/code-signing.md", + ]) { + const text = readFileSync(repoPath(rel), "utf8"); + expect(text).not.toContain("will not transfer any information to other networked systems"); + expect(text).toContain("telemetry.opencodex.me"); + expect(text).toContain("ocx telemetry disable"); + expect(text).toContain("OCX_TELEMETRY=0"); + expect(text).toContain("DO_NOT_TRACK=1"); + } + }); + test("service/cli.ts does not duplicate maybeShowTelemetryPrompt after maybeShowStarPrompt and the prompt runs once", async () => { const serviceCliSource = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); expect(serviceCliSource).toContain("await maybeShowStarPrompt()"); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 9062a351829..c22ae8a8d93 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -6,7 +6,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; -import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; +import { latestCompletedUtcDay, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -90,14 +91,75 @@ describe("/api/telemetry management routes", () => { reason: "awaiting-notice", decision: null, lastSentDay: null, + previewEligible: false, }); - const preview = res.body.preview as { installDayHash: string; schemaVersion: number }; + const preview = res.body.preview as { installDayHash: string; schemaVersion: number; day: string }; expect(preview.schemaVersion).toBe(1); expect(preview.installDayHash).toBe(TELEMETRY_PREVIEW_HASH_PLACEHOLDER); + expect(preview.day).toBe(latestCompletedUtcDay()); expect(existsSync(telemetryIdPath(home))).toBe(false); }); - test("PUT /api/telemetry and POST /api/telemetry/notice refuse admin-token and remembered-admin sessions", async () => { + test("D10: GET /api/telemetry preview uses the oldest eligible unsent day and marks previewEligible=false when none is eligible", async () => { + const now = Date.parse("2026-11-07T12:00:00.000Z"); + + // 1. Consent given today (2026-11-07) or yesterday (2026-11-06) -> no completed day is strictly after consent day yet. + const sameDaySnap = await buildTelemetryStatusSnapshot( + { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-06T18:00:00.000Z", + noticeShownAt: "2026-11-06T18:00:00.000Z", + }, + }, + { now, env: {}, configDir: home }, + ); + expect(sameDaySnap.previewEligible).toBe(false); + expect(sameDaySnap.preview.day).toBe("2026-11-06"); + + // 2. Consent given on 2026-11-04 -> eligible unsent days are 2026-11-05 and 2026-11-06; + // preview shows 2026-11-05 (the exact day the scheduler will send next) with previewEligible=true. + const eligibleConfig = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-04T18:00:00.000Z", + noticeShownAt: "2026-11-04T18:00:00.000Z", + }, + }; + const firstSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(firstSnap.previewEligible).toBe(true); + expect(firstSnap.preview.day).toBe("2026-11-05"); + + // Scheduler sends 2026-11-05; next preview moves to 2026-11-06 with previewEligible=true: + const tick1 = await runTelemetryTick({ + configDir: home, + config: eligibleConfig, + env: {}, + now, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick1).toMatchObject({ sent: true, day: "2026-11-05" }); + + const secondSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(secondSnap.previewEligible).toBe(true); + expect(secondSnap.preview.day).toBe("2026-11-06"); + + // Scheduler sends 2026-11-06; now no eligible day remains, so previewEligible=false on 2026-11-06: + const tick2 = await runTelemetryTick({ + configDir: home, + config: eligibleConfig, + env: {}, + now, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick2).toMatchObject({ sent: true, day: "2026-11-06" }); + + const thirdSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(thirdSnap.previewEligible).toBe(false); + expect(thirdSnap.preview.day).toBe("2026-11-06"); + }); + + test("PUT /api/telemetry refuses admin-token and remembered-admin sessions, and POST /api/telemetry/notice is removed (D13)", async () => { for (const [principal, guiSessionIssuance] of [ ["admin-token", null], ["gui-session", "remembered-admin"], @@ -109,38 +171,24 @@ describe("/api/telemetry management routes", () => { }); expect(putRes.status).toBe(403); expect(putRes.body.code).toBe("agent_consent_required"); - - const noticeRes = await call("POST", "/api/telemetry/notice", { - principal, - ingress: { trustedLoopback: true, guiSessionIssuance }, - }); - expect(noticeRes.status).toBe(403); - expect(noticeRes.body.code).toBe("agent_consent_required"); } expect(config.telemetry).toBeUndefined(); - }); - - test("POST /api/telemetry/notice accepts a human dashboard session and is idempotent", async () => { - const first = await call("POST", "/api/telemetry/notice", { - principal: "gui-session", - ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, - }); - expect(first.status).toBe(200); - expect(first.body).toMatchObject({ - ok: true, - effective: true, - reason: "default-on", - decision: null, - }); - const firstNoticeAt = first.body.noticeShownAt as string; - expect(typeof firstNoticeAt).toBe("string"); - const second = await call("POST", "/api/telemetry/notice", { - principal: "gui-session", - ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + const noticeUrl = new URL("http://127.0.0.1:10100/api/telemetry/notice"); + const noticeReq = new Request(noticeUrl, { + method: "POST", + headers: { host: "127.0.0.1:10100" }, }); - expect(second.status).toBe(200); - expect(second.body.noticeShownAt).toBe(firstNoticeAt); + const noticeRes = await handleManagementAPI( + noticeReq, + noticeUrl, + config, + {}, + "gui-session", + undefined, + { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + ); + expect(noticeRes).toBeNull(); }); test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e9ae6782ac4..0b6f8dd3d6d 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -10,6 +10,7 @@ import { aggregateTelemetryForDay, classifyModelFamily, classifyProviderKind, + createDailyTelemetryAccumulator, disjointTelemetryTokens, } from "../../src/telemetry/aggregate"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; @@ -34,17 +35,25 @@ import { resetTelemetryInstallIdMemoForTests, telemetryIdPath, } from "../../src/telemetry/install-id"; -import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; +import { buildTelemetryPayloadV1, normalizeTelemetryVersion, resolveInstallChannel } from "../../src/telemetry/payload"; import { + computeTelemetryIntervalDelayMs, + computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, readTelemetryState, + recordTelemetryEnvOptOut, runTelemetryTick, startTelemetryScheduler, stopTelemetryScheduler, telemetryStatePath, writeTelemetryState, } from "../../src/telemetry/scheduler"; -import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; +import { + parseRetryAfterMs, + sendTelemetryPayload, + setTelemetrySenderOverridesForTests, +} from "../../src/telemetry/sender"; +import { acquireServerBackgroundLifecycle } from "../../src/server/background-lifecycle"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -55,6 +64,14 @@ import { repoPath } from "../helpers/repo-root"; const NOW = Date.parse("2026-11-07T12:00:00.000Z"); +function rmIfExists(path: string): void { + try { + if (lstatSync(path)) { + unlinkSync(path); + } + } catch { /* ignore */ } +} + describe("telemetry contract parity and validation", () => { test("contract module is import-free and pins outcome and failure-cause rosters", () => { const src = readFileSync(repoPath("src", "telemetry", "contract.ts"), "utf8"); @@ -101,11 +118,32 @@ describe("telemetry contract parity and validation", () => { }; expect(validateTelemetryPayloadV1(base, NOW)).toEqual({ ok: true, payload: base }); - expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(true); + for (const validPreview of [ + "2.6.15-preview.20260701", + "2.6.15-preview.20260701.1", + "2.7.9-preview.20260712.2", + ]) { + expect( + validateTelemetryPayloadV1( + { ...base, env: { ...base.env, version: validPreview, releaseChannel: "preview" } }, + NOW, + ).ok, + ).toBe(true); + expect(normalizeTelemetryVersion(validPreview)).toEqual({ + version: validPreview, + releaseChannel: "preview", + }); + } + expect(normalizeTelemetryVersion("2.40.1")).toEqual({ + version: "2.40.1", + releaseChannel: "latest", + }); expect(validateTelemetryPayloadV1({ ...base, extra: 1 }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, extra: 1 } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1+build.9" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.6.15-preview.20260701+build.1", releaseChannel: "preview" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, installChannel: "brew" as never } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, day: "2026-02-30" }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, day: "2026-10-20" }, NOW).ok).toBe(false); @@ -352,6 +390,12 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(classifyProviderKind("google-vertex")).toBe("vertex"); expect(classifyModelFamily("deepseek/deepseek-r1")).toBe("deepseek"); expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); + + // D8: createDailyTelemetryAccumulator accumulates inside onEntry for target day only and retains no rows. + const acc = createDailyTelemetryAccumulator(day); + expect(Object.keys(acc).sort()).toEqual(["finalize", "onEntry"]); + for (const row of fixtureEntries) acc.onEntry(row); + expect(acc.finalize()).toEqual(agg); }); test("disjointTelemetryTokens legacy fallback keeps 5-bucket sum equal to usageDisplayTotalTokens and ACCOUNT_SUFFIX_RE matches k + 32 hex", () => { @@ -419,11 +463,13 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => // first tick sends 2026-11-05 (oldest post-consent day) with newInstall=true, // second tick sends 2026-11-06 with newInstall=false, // third tick finds nothing left. + // 2. D1: Consent given on 2026-11-04 -> 2026-11-04 (the consent day itself) and earlier are NEVER sent; + // only UTC days strictly AFTER 2026-11-04 (i.e. 2026-11-05, 2026-11-06) are eligible. const config = { telemetry: { decision: "accepted" as const, - decidedAt: "2026-11-05T15:30:00.000Z", - noticeShownAt: "2026-11-05T15:30:00.000Z", + decidedAt: "2026-11-04T15:30:00.000Z", + noticeShownAt: "2026-11-04T15:30:00.000Z", }, }; const tick1 = await runTelemetryTick({ @@ -469,6 +515,301 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); } } + + // Consent given on 2026-11-06 (yesterday) when today is 2026-11-07 -> 2026-11-06 is the consent day itself, + // so no completed UTC day is strictly after 2026-11-06 yet. + const sameDayHome = mkdtempSync(join(tmpdir(), "ocx-telemetry-sameday-")); + try { + const resSameDay = await runTelemetryTick({ + configDir: sameDayHome, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-06T23:59:00.000Z", + noticeShownAt: "2026-11-06T23:59:00.000Z", + }, + }, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(resSameDay).toEqual({ sent: false, reason: "up-to-date" }); + } finally { + removeTreeWithRetry(sameDayHome); + } + }); + + test("D2: env opt-out at startup or tick persists ineligibleThroughDay, deletes the install id, never backfills opt-out days, and mints a fresh id later", async () => { + const sentPayloads: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentPayloads.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-01T10:00:00.000Z", + noticeShownAt: "2026-11-01T10:00:00.000Z", + }, + }; + + // Day 2026-11-03: sends 2026-11-02 and mints initial installId + const day3Now = Date.parse("2026-11-03T12:00:00.000Z"); + const firstTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: day3Now, + fetch: mockFetch, + }); + expect(firstTick).toMatchObject({ sent: true, day: "2026-11-02" }); + const originalInstallId = readTelemetryInstallId(home)!.installId; + expect(typeof originalInstallId).toBe("string"); + + // User starts proxy in background-lifecycle with OCX_TELEMETRY=0 on 2026-11-03: + const prevTelemetry = process.env.OCX_TELEMETRY; + process.env.OCX_TELEMETRY = "0"; + try { + const lease = acquireServerBackgroundLifecycle(() => {}, { + port: 0, + defaultProvider: "openai", + providers: {}, + } as unknown as OcxConfig); + await lease.release(); + } finally { + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + } + expect(readTelemetryInstallId(home)).toBeNull(); + expect(existsSync(telemetryIdPath(home))).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).not.toBeNull(); + + // Reset state to simulate consecutive opt-out days 2026-11-03, 2026-11-04, 2026-11-05: + writeTelemetryState( + { + version: 1, + sentDays: { "2026-11-02": "2026-11-03T12:00:00.000Z" }, + lastSentDay: "2026-11-02", + lastSentAt: "2026-11-03T12:00:00.000Z", + ineligibleThroughDay: null, + retryAfterUntil: null, + }, + home, + day3Now, + ); + ensureTelemetryInstallId(home); + expect(readTelemetryInstallId(home)).not.toBeNull(); + + for (const [dayIso, env] of [ + ["2026-11-03T18:00:00.000Z", { OCX_TELEMETRY: "0" }], + ["2026-11-04T18:00:00.000Z", { DO_NOT_TRACK: "1" }], + ["2026-11-05T18:00:00.000Z", { OCX_TELEMETRY: "false" }], + ] as const) { + const optOutTick = await runTelemetryTick({ + configDir: home, + config, + env, + now: Date.parse(dayIso), + fetch: mockFetch, + }); + expect(optOutTick.sent).toBe(false); + expect(readTelemetryInstallId(home)).toBeNull(); + } + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); + + // User removes OCX_TELEMETRY=0 on 2026-11-06: days 2026-11-03..2026-11-05 are <= ineligibleThroughDay, so nothing is sent on 2026-11-06! + const day6Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-06T12:00:00.000Z"), + fetch: mockFetch, + }); + expect(day6Tick).toEqual({ sent: false, reason: "up-to-date" }); + expect(sentPayloads).toHaveLength(1); + + // On 2026-11-07, day 2026-11-06 (strictly after ineligibleThroughDay 2026-11-05) is sent with a fresh installId and newInstall=true: + const day7Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(day7Tick).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentPayloads).toHaveLength(2); + expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.newInstall).toBe(true); + const rotatedInstallId = readTelemetryInstallId(home)!.installId; + expect(rotatedInstallId).not.toBe(originalInstallId); + + // Also verify recordTelemetryEnvOptOut replaces a symlinked telemetry-state.json without following it: + const victim = join(home, "optout-victim.txt"); + writeFileSync(victim, "keep-safe", "utf8"); + const stateFile = telemetryStatePath(home); + rmIfExists(stateFile); + symlinkSync(victim, stateFile); + recordTelemetryEnvOptOut(home, NOW); + expect(readFileSync(victim, "utf8")).toBe("keep-safe"); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + }); + + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { + let fetchCalls = 0; + const mockFetch = (async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + // 1. Consent changes to declined while aggregateDay is running -> aborts before sending or minting an id. + let liveDecision: "accepted" | "declined" = "accepted"; + const abortedByDecline = await runTelemetryTick({ + configDir: home, + readConfig: () => ({ + telemetry: { + decision: liveDecision, + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }), + env: {}, + now: NOW, + aggregateDay: async day => { + liveDecision = "declined"; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(abortedByDecline).toEqual({ sent: false, day: "2026-11-05", reason: "declined" }); + expect(fetchCalls).toBe(0); + expect(readTelemetryInstallId(home)).toBeNull(); + + // 2. Env opt-out set while aggregateDay is running -> aborts before sending and records ineligibleThroughDay. + const envBag: Record = {}; + const abortedByEnv = await runTelemetryTick({ + configDir: home, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }, + env: envBag, + now: NOW, + aggregateDay: async day => { + envBag.OCX_TELEMETRY = "0"; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(abortedByEnv).toEqual({ sent: false, day: "2026-11-05", reason: "env-ocx-telemetry" }); + expect(fetchCalls).toBe(0); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + + // Reset state for signal cancellation checks: + writeTelemetryState( + { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, + }, + home, + NOW, + ); + + // 3. nudgeTelemetryScheduler() cancels an in-flight tick's AbortSignal. + let observedSignalAborted = false; + const abortedByNudge = await runTelemetryTick({ + configDir: home, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }, + env: {}, + now: NOW, + aggregateDay: async (day, opts) => { + nudgeTelemetryScheduler(); + observedSignalAborted = opts?.signal?.aborted === true; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(observedSignalAborted).toBe(true); + expect(abortedByNudge).toEqual({ sent: false, day: "2026-11-05", reason: "aborted" }); + expect(fetchCalls).toBe(0); + }); + + test("D11: honors HTTP 429 and 503 Retry-After, persists retryAfterUntil, skips ticks until elapsed, and applies jitter", async () => { + expect(parseRetryAfterMs("120", NOW)).toBe(120_000); + expect(parseRetryAfterMs(new Date(NOW + 90_000).toUTCString(), NOW)).toBe(90_000); + expect(parseRetryAfterMs("invalid", NOW)).toBeNull(); + expect(computeTelemetryStartupDelayMs(() => 0.5)).toBe(60_000); + expect(computeTelemetryIntervalDelayMs(1000, () => 0.5)).toBe(1000 + 7.5 * 60 * 1000); + + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }; + let calls = 0; + const rateLimitedTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: (async () => { + calls += 1; + return new Response("rate limited", { + status: 429, + headers: { "Retry-After": "120" }, + }); + }) as unknown as typeof fetch, + }); + expect(rateLimitedTick.sent).toBe(false); + expect(rateLimitedTick.reason).toBe("retry-after-backoff"); + expect(calls).toBe(1); + const stateAfter429 = readTelemetryState(home); + expect(stateAfter429.retryAfterUntil).toBe(new Date(NOW + 120_000).toISOString()); + + // Tick before retryAfterUntil is skipped without calling fetch: + const skippedTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW + 60_000, + fetch: (async () => { + calls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(skippedTick).toEqual({ sent: false, reason: "retry-after-backoff" }); + expect(calls).toBe(1); + + // Tick after retryAfterUntil succeeds and clears retryAfterUntil: + const recoveredTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW + 121_000, + fetch: (async () => { + calls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(recoveredTick).toMatchObject({ sent: true, day: "2026-11-05" }); + expect(calls).toBe(2); + expect(readTelemetryState(home).retryAfterUntil).toBeNull(); }); test("writeTelemetryState replaces a symlinked telemetry-state.json without following it", () => { From 8f0b86e5e8bce44d938026ad3f87a364e971e992 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:26:42 +0900 Subject: [PATCH 08/29] test(telemetry): prove env opt-out days of a long-running proxy are never sent A proxy started under OCX_TELEMETRY=0 reads the env once and runs no ticks, so it records ineligibleThroughDay only for its start day. These tests drive the real background lifecycle with a mocked clock for an opt-out period of several days, ended by a crash and by a clean stop. They fail on this commit: the first normal tick sends 2026-11-03, a day the proxy ran opted out. --- tests/usage/usage-telemetry-client.test.ts | 85 ++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 0b6f8dd3d6d..f602b56cd20 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -618,7 +618,8 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); - // User removes OCX_TELEMETRY=0 on 2026-11-06: days 2026-11-03..2026-11-05 are <= ineligibleThroughDay, so nothing is sent on 2026-11-06! + // User removes OCX_TELEMETRY=0 on 2026-11-06. Nothing marked the end of the opt-out, so the first + // tick without it ends the period on its own day: 2026-11-03..2026-11-06 are never sent. const day6Tick = await runTelemetryTick({ configDir: home, config, @@ -627,9 +628,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => fetch: mockFetch, }); expect(day6Tick).toEqual({ sent: false, reason: "up-to-date" }); + expect(readTelemetryState(home)).toMatchObject({ ineligibleThroughDay: "2026-11-06", envOptOutActiveSince: null }); expect(sentPayloads).toHaveLength(1); - // On 2026-11-07, day 2026-11-06 (strictly after ineligibleThroughDay 2026-11-05) is sent with a fresh installId and newInstall=true: const day7Tick = await runTelemetryTick({ configDir: home, config, @@ -637,9 +638,19 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => now: NOW, fetch: mockFetch, }); - expect(day7Tick).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(day7Tick).toEqual({ sent: false, reason: "up-to-date" }); + + // On 2026-11-08, day 2026-11-07 (strictly after ineligibleThroughDay 2026-11-06) is sent with a fresh installId and newInstall=true: + const day8Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-08T12:00:00.000Z"), + fetch: mockFetch, + }); + expect(day8Tick).toMatchObject({ sent: true, day: "2026-11-07" }); expect(sentPayloads).toHaveLength(2); - expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.day).toBe("2026-11-07"); expect(sentPayloads[1]!.newInstall).toBe(true); const rotatedInstallId = readTelemetryInstallId(home)!.installId; expect(rotatedInstallId).not.toBe(originalInstallId); @@ -656,6 +667,70 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); + describe("D2: a proxy running under an env opt-out for several days", () => { + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-10-30T10:00:00.000Z", + noticeShownAt: "2026-10-30T10:00:00.000Z", + }, + }; + const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + let sentDays: string[]; + let prevTelemetry: string | undefined; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const tickAt = (iso: string) => + runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + // The proxy reads the env once, when its background lifecycle starts; it runs no ticks while opted out. + const startOptedOutProxy = (iso: string) => { + setSystemTime(new Date(iso)); + process.env.OCX_TELEMETRY = "0"; + try { + return acquireServerBackgroundLifecycle(() => {}, proxyConfig); + } finally { + delete process.env.OCX_TELEMETRY; + } + }; + + beforeEach(() => { + sentDays = []; + prevTelemetry = process.env.OCX_TELEMETRY; + }); + + afterEach(() => { + setSystemTime(); + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + }); + + test("never sends its opt-out days when it did not stop cleanly before a normal proxy ticks", async () => { + const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); + try { + // Monday through Thursday ran opted out. A normal proxy takes over on Friday. + setSystemTime(new Date("2026-11-06T12:00:00.000Z")); + expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); + expect((await tickAt("2026-11-07T12:00:00.000Z")).sent).toBe(false); + expect(await tickAt("2026-11-08T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-07" }); + expect(sentDays).toEqual(["2026-11-07"]); + } finally { + await lease.release(); + } + }); + + test("never sends its opt-out days after it stops cleanly", async () => { + const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); + setSystemTime(new Date("2026-11-05T20:00:00.000Z")); + await lease.release(); + setSystemTime(new Date("2026-11-06T12:00:00.000Z")); + expect(await tickAt("2026-11-06T12:00:00.000Z")).toEqual({ sent: false, reason: "up-to-date" }); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentDays).toEqual(["2026-11-06"]); + }); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From a09bb51698716537aafd20593c7ff65ac71963bc Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:26:49 +0900 Subject: [PATCH 09/29] fix(telemetry): keep a long-running env opt-out period ineligible until it ends Observing OCX_TELEMETRY=0 or DO_NOT_TRACK=1 now also persists an envOptOutActiveSince marker in telemetry-state.json. While it is set no day is eligible. The opted-out proxy's clean stop, or the first tick that runs without the opt-out, closes the period: every day through the closing day stays ineligible and the marker is cleared. A crashed opted-out proxy therefore costs at most the restart day and never leaks its opt-out days. SPONSORS.md no longer says opencodex collects no telemetry. --- SPONSORS.md | 5 +++- src/server/background-lifecycle.ts | 9 +++++- src/telemetry/scheduler.ts | 44 ++++++++++++++++++++++++++++-- structure/telemetry.md | 8 ++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/SPONSORS.md b/SPONSORS.md index 97506d9ce9a..bac68f3552c 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -95,7 +95,10 @@ draft agreement. request reaches. - No relaxation of the [security review](./MAINTAINERS.md) that applies to authentication, credentials, or workflow changes. -- No access to user data, request logs, or telemetry; opencodex does not collect any. +- No access to user data, request logs, or individual telemetry reports. The only telemetry opencodex + collects is the anonymous daily usage summary described in the + [privacy statement](./README.md#code-signing-policy), which users can turn off; its project-wide + aggregates are public, and sponsors see nothing beyond them. - No say over unrelated issues, pull requests, or the release schedule. ## Current sponsors diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index abfcc5e67d9..2eeb91221d1 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,7 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; -import { recordTelemetryEnvOptOut } from "../telemetry/scheduler"; +import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -63,6 +63,8 @@ let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; +// This process started under an env opt-out; its clean stop closes the opt-out period (D2). +let telemetryEnvOptOutObserved = false; function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); @@ -145,6 +147,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { // Optional background loop. }); } else { + telemetryEnvOptOutObserved = true; try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; @@ -168,6 +171,10 @@ function stopProcessLoops(): void { const stopTelemetry = stopTelemetryLoop; stopTelemetryLoop = null; stopTelemetry?.(); + if (telemetryEnvOptOutObserved) { + telemetryEnvOptOutObserved = false; + try { endTelemetryEnvOptOut(); } catch { /* best-effort; the marker stays and the next tick ends it */ } + } const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 61ac771cc48..9583e9bf0ef 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -34,6 +34,13 @@ export interface TelemetryState { lastSentAt: string | null; ineligibleThroughDay: string | null; retryAfterUntil: string | null; + /** + * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the + * next tick that runs without the opt-out. While it is set, every day up to the current one is + * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks + * that could keep `ineligibleThroughDay` current (D2). + */ + envOptOutActiveSince?: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -48,6 +55,7 @@ function emptyState(): TelemetryState { lastSentAt: null, ineligibleThroughDay: null, retryAfterUntil: null, + envOptOutActiveSince: null, }; } @@ -74,6 +82,10 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) ? raw.retryAfterUntil : null; + const envOptOutActiveSince = + typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) + ? raw.envOptOutActiveSince + : null; return { version: 1, sentDays, @@ -81,6 +93,7 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, ineligibleThroughDay, retryAfterUntil, + envOptOutActiveSince, }; } catch { return emptyState(); @@ -103,13 +116,15 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig lastSentAt: state.lastSentAt ?? null, ineligibleThroughDay: state.ineligibleThroughDay ?? null, retryAfterUntil: state.retryAfterUntil ?? null, + envOptOutActiveSince: state.envOptOutActiveSince ?? null, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } /** - * Persist `ineligibleThroughDay = today's UTC day` in `telemetry-state.json` (atomic no-follow) - * and delete `telemetry-id.json` whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. + * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in + * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an + * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. */ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { const todayDay = formatUtcDay(utcMidnightMs(now)); @@ -117,6 +132,24 @@ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date. if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { state.ineligibleThroughDay = todayDay; } + state.envOptOutActiveSince ??= new Date(now).toISOString(); + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + +/** + * End an observed env opt-out: every day up to today stays ineligible and the marker is cleared, so + * only days strictly after today can be sent. Called by a clean shutdown of an opted-out proxy and + * by the first tick that runs without the opt-out. + */ +export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.envOptOutActiveSince) return; + const todayDay = formatUtcDay(utcMidnightMs(now)); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } + state.envOptOutActiveSince = null; writeTelemetryState(state, configDir, now); deleteTelemetryInstallId(configDir); } @@ -139,6 +172,7 @@ export function latestCompletedUtcDay(now = Date.now()): string { * - `status.effective` must be true. * - Strictly AFTER the UTC day of `decidedAt ?? noticeShownAt` (D1). * - Strictly AFTER `state.ineligibleThroughDay` when set (D2). + * - Nothing while an env opt-out marker is still set: that period may have run until today (D2). * - Not already recorded in `state.sentDays`. */ export function resolveNextEligibleTelemetryDay( @@ -149,6 +183,9 @@ export function resolveNextEligibleTelemetryDay( if (!status.effective) { return { day: null, reason: status.reason }; } + if (state.envOptOutActiveSince) { + return { day: null, reason: "env-opt-out-pending" }; + } const consentTimestamp = status.decidedAt ?? status.noticeShownAt; if (!consentTimestamp) { return { day: null, reason: "missing-consent-timestamp" }; @@ -244,6 +281,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, reason: status.reason }; } + if (readTelemetryState(configDir).envOptOutActiveSince) { + endTelemetryEnvOptOut(configDir, now); + } const state = readTelemetryState(configDir); if (state.retryAfterUntil) { const retryUntilMs = Date.parse(state.retryAfterUntil); diff --git a/structure/telemetry.md b/structure/telemetry.md index 82dc673dde6..b42c4f87ec2 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -63,8 +63,12 @@ with a 10-second `signalWithTimeout` and parses `Retry-After` on HTTP 429/503 re `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) in -`telemetry-state.json` and deletes `telemetry-id.json`. Each tick sends at most one completed UTC +`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 reads the env once and runs no ticks, so the marker stands for the whole +period it ran: while it is set no day is eligible, and `endTelemetryEnvOptOut` closes the period +(ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick +that runs without the opt-out. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after `ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. From b6c9314fa227e4774e7502ef725a0c8eae0c04c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:27:50 +0900 Subject: [PATCH 10/29] docs(devlog): plan the env opt-out marker, privacy statement files, and the open SignPath condition - 000: the no-backfill rule covers env opt-out days and the disclosure day; the privacy statement changes in layer 2. - 040: lists every file that carries the published privacy statement (README, seven translations, both code-signing pages, SPONSORS.md) and says they land in layer 2. - 060: D2 adds the envOptOutActiveSince marker for a proxy that runs opted out for days; records SignPath's install-time condition verbatim as an open owner decision instead of assuming first-run disclosure satisfies it. --- .../261107_telemetry_dashboard/000_plan.md | 7 +++++- .../040_docs_privacy.md | 18 +++++++++++++++ .../060_review_round.md | 23 +++++++++++++++---- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 317491c152e..63da28bb8c8 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -41,7 +41,12 @@ footer and in the PR descriptions. - The collector stores no IP address and no raw install id: reports carry a per-day install hash, rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per install and day counts. -- No backfill: days before the disclosure are never sent; at most one day is sent per scheduler tick. +- No backfill: only UTC days strictly after the day of the disclosure or decision are sent, days + covered by an `OCX_TELEMETRY=0` / `DO_NOT_TRACK=1` opt-out are never sent (060 D2), and at most one + day is sent per scheduler tick. +- The published privacy statement (README and its seven translations, the docs-site code-signing + page in English and Turkish, `SPONSORS.md`) describes default-on telemetry and every way to turn it + off. It changes in layer 2, the first layer that can send, never later (060 D12). - The collector URL is a constant; it is not configurable from `config.json`. - Telemetry code stays off the core request path: `src/router.ts`, `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md index b50b0589b7e..8f399f4ff06 100644 --- a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -3,6 +3,24 @@ Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the docs build rejects links to missing pages. +## Privacy statement (lands in layer 2, not here) + +The project's published privacy statement must stop saying that nothing is sent before the first +layer that can send merges, so these files change in layer 2 (060 D12) and layer 5 only links them to +the guide: + +- `README.md` (Code signing policy → Privacy) and the same line in `readme/README.fr.md`, + `README.ja.md`, `README.ko.md`, `README.ru.md`, `README.tr.md`, `README.zh-CN.md` and + `README.zh-TW.md`, with `readme/i18n-manifest.json` refreshed. +- `docs-site/src/content/docs/reference/code-signing.md` and its Turkish copy + `docs-site/src/content/docs/tr/reference/code-signing.md` (the Privacy section, including the + "carry none of your … usage data" sentence). +- `SPONSORS.md` ("opencodex does not collect any" telemetry). + +The statement replaces SignPath's "will not transfer any information …" sentence, which SignPath +allows only as an alternative to linking a privacy policy. Whether first-run disclosure satisfies +SignPath's install-time conditions is an open owner decision (060, "SignPath conditions"). + ## Public docs - `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md index eceb29c3fc1..b85b03b89a9 100644 --- a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -12,13 +12,28 @@ systems unless specifically requested…". With default-on telemetry that senten conditions (https://signpath.org/terms.html) allow software that sends data to systems the user did not specify only if the behaviour is described in a privacy policy, shown during installation, and can be disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first -layer that can send), and layer 5 links it to the telemetry guide. The interactive install-time prompt and -the first-run dashboard popup are the "shown during installation, can be disabled" points. +layer that can send), and layer 5 links it to the telemetry guide. + +## SignPath conditions (open owner decision) + +SignPath Foundation's conditions (https://signpath.org/terms, read 2026-11-08), "Respect user privacy and +security": "Software that collects user data and transfers it to systems not specified by the user must +a) describe this behavior in a privacy policy, b) display this policy during installation and c) include +installation options to disable these functions." Its website condition allows either a privacy-policy link +or the "will not transfer" sentence. + +(a) is met by the D12 statement. (b) and (c) are not clearly met. SignPath signs the standalone `ocx.exe`, +which has no installer, and the MSI, whose installer shows no policy and has no disable option. The CLI +prompt and the dashboard popup appear on first run, after installation. The release workflow still uses the +`test-signing` policy, so no production-signed build exists yet. Round 1 treated first-run disclosure as +"during installation"; that reading is not confirmed. The owner decides whether to add an MSI dialog +(policy and a disable checkbox) before switching to `release-signing`, to ask SignPath first, or to +accept first-run disclosure. No layer that can send merges before that decision. ## Decisions D1 Consent-day cutoff: only UTC days strictly AFTER the UTC day of (decidedAt ?? noticeShownAt) are ever sent. -D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted. +D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day and an envOptOutActiveSince marker in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. A proxy started under the opt-out reads the env once and runs no ticks, so the marker covers the whole time it runs: while it is set nothing is eligible, and the period closes (ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick without the opt-out. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted; a proxy that runs opted out from Monday to Thursday, ended by a crash and by a clean stop, never lets those days be sent (failing-first in layer 2). D3 Version regex everywhere (contract): ^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ ; test vectors 2.40.1, 2.6.15-preview.20260701, 2.6.15-preview.20260701.1, 2.7.9-preview.20260712.2; reject build metadata. D4 ocx telemetry enable: requires an interactive human terminal (isatty stdin AND stdout, not OCX_SERVICE, not isAgentDriven); prints the disclosure block, then asks a confirm (default yes) and records accepted; otherwise exits non-zero with agent_consent_required / tty_required pointing to the dashboard. disable always allowed. D5 Disclosure block printed before every CLI Y/n prompt (ocx start/init/service install and enable): what is sent, what is never sent, that aggregates are public at opencodex.me/dashboard, how to see the exact payload (ocx telemetry status --json), how to turn it off (ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, Proxy details toggle). A CLI test pins the text. @@ -28,7 +43,7 @@ D8 Aggregation accumulates counters inside onEntry for the target day only; neve D9 CLI enable/disable: if mutatePersistedConfig returns unavailable (or throws), exit non-zero with an actionable error, print no success line, and do not delete the id unless the decline persisted. D10 GET /api/telemetry preview uses the same day the scheduler would send next (oldest eligible unsent day). If none is eligible, preview the latest completed day and return previewEligible:false (field added); never imply it will be sent. D11 Client honors HTTP 429 Retry-After (and 503): persist retryAfterUntil in telemetry-state.json, skip ticks until then; spreads sends with jitter. -D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. +D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md and its tr copy, SPONSORS.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. D13 GUI popup dismissal (Escape/backdrop/close) records NOTHING; only Continue (PUT /api/telemetry) records decision and notice; the popup returns on the next app start while undecided. Remove POST /api/telemetry/notice entirely (server route, registry entry, capability exemptions, GUI call, tests, docs). D14 Stats semantics: activeInstalls in totals and installs in breakdowns are INSTALL-DAYS (sum of daily reporting installs); add totals.avgDailyActiveInstalls (install-days / days in range, rounded to 1 decimal). Dashboard shows "Avg daily active installs" as the KPI with install-days in the tooltip. D15 Small-cohort protection (collector): add an installs column (count of contributing reports) to daily_models and daily_errors (and dailyByProvider derivation); in every breakdown (providers, models, versions, platforms, channels, errors, dailyByProvider per day) fold rows with fewer than 10 install-days in the selected range into "other"; drop "other" if it is itself below 10; totals/daily/heatmap unchanged. Document the residual subtraction risk. From 11a0f8ff10fbebf62582600a7acf0555e7bbdd2a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:44:55 +0900 Subject: [PATCH 11/29] test(telemetry): pin that an unreachable or undeployed collector fails a tick quietly --- tests/usage/usage-telemetry-client.test.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index f602b56cd20..c0dcd5106e7 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -731,6 +731,32 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => }); }); + test("an unreachable or undeployed collector fails the tick quietly and leaves the day for a later retry", async () => { + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-01T10:00:00.000Z", + noticeShownAt: "2026-11-01T10:00:00.000Z", + }, + }; + const consoleSpies = [spyOn(console, "error"), spyOn(console, "warn"), spyOn(console, "log")]; + try { + const dnsFailure = (async () => { + throw new TypeError("getaddrinfo ENOTFOUND telemetry.opencodex.me"); + }) as unknown as typeof fetch; + const notDeployed = (async () => new Response("Not Found", { status: 404 })) as unknown as typeof fetch; + for (const fetchImpl of [dnsFailure, notDeployed]) { + const result = await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: fetchImpl }); + expect(result).toMatchObject({ sent: false, day: "2026-11-02", reason: "send-failed" }); + expect(readTelemetryState(home).sentDays).toEqual({}); + expect(readTelemetryState(home).retryAfterUntil).toBeNull(); + } + for (const spy of consoleSpies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of consoleSpies) spy.mockRestore(); + } + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From ed53c2e99c28a1d3a8617a2b38fb38474df10bc1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:54:01 +0900 Subject: [PATCH 12/29] test(telemetry): prove zero sends before noticeShownAt across ten undecided days --- tests/usage/usage-telemetry-client.test.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index c0dcd5106e7..e520969a731 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -439,6 +439,40 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mystery" }) })).toBe("unknown"); }); + test("nothing is ever sent before noticeShownAt, however long the proxy runs undecided", async () => { + let fetchCalls = 0; + const sentDays: string[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + fetchCalls += 1; + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + // Undecided installs with no recorded notice: an absent block, an empty block, and explicit nulls. + const undisclosed: Array> = [ + {}, + { telemetry: {} }, + { telemetry: { decision: null, decidedAt: null, noticeShownAt: null } }, + ] as Array>; + for (const config of undisclosed) { + for (let day = 0; day < 10; day++) { + for (const hour of ["00:30", "06:30", "12:30", "18:30"]) { + const now = Date.parse(`2026-11-${String(day + 1).padStart(2, "0")}T${hour}:00.000Z`); + const result = await runTelemetryTick({ configDir: home, config, env: {}, now, fetch: mockFetch }); + expect(result).toEqual({ sent: false, reason: "awaiting-notice" }); + } + } + } + expect(fetchCalls).toBe(0); + expect(readTelemetryInstallId(home)).toBeNull(); + expect(readTelemetryState(home).sentDays).toEqual({}); + + // The notice is shown on 2026-11-10; only completed days after it are ever sent. + const disclosed = { telemetry: { decision: null, decidedAt: null, noticeShownAt: "2026-11-10T09:00:00.000Z" } } as Pick; + expect((await runTelemetryTick({ configDir: home, config: disclosed, env: {}, now: Date.parse("2026-11-11T12:00:00.000Z"), fetch: mockFetch })).sent).toBe(false); + expect(await runTelemetryTick({ configDir: home, config: disclosed, env: {}, now: Date.parse("2026-11-12T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true, day: "2026-11-11" }); + expect(sentDays).toEqual(["2026-11-11"]); + }); + test("scheduler sends nothing when not effective, skips pre-consent days, and sends at most one day per tick oldest first", async () => { const sentPayloads: TelemetryPayloadV1[] = []; const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { From b4dd9340487e286ffce144f6d573fb1341438350 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:00:45 +0900 Subject: [PATCH 13/29] fix(telemetry): let ocx telemetry disable create config.json on a fresh install A decline that arrives before the first start (the Windows installer opt-out, or a user running ocx telemetry disable first) failed with 'config.json is missing'. A missing config is now created the way a first start creates it, carrying the decision; a concurrent first start that wins leaves a file to patch instead. --- src/cli/telemetry.ts | 19 +++++++++++++++---- tests/cli/cli-telemetry.test.ts | 21 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index b89cfd2fa5e..4fb890460f5 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,7 +1,7 @@ import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; -import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { getConfigDir, getDefaultConfig, initializePersistedConfigIfMissing, mutatePersistedConfig, readConfigDiagnostics } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -76,7 +76,7 @@ function isInteractiveTty(): boolean { async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { const nowIso = new Date().toISOString(); const dir = getConfigDir(); - const outcome = await withConfigDirHardeningSettled(dir, () => + const mutate = () => mutatePersistedConfig(fresh => { const prev = fresh.telemetry; const next: OcxTelemetryConfig = { @@ -87,8 +87,19 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise const changed = JSON.stringify(prev) !== JSON.stringify(next); fresh.telemetry = next; return { changed, value: next }; - }), - ); + }); + const outcome = await withConfigDirHardeningSettled(dir, () => { + const first = mutate(); + if (first.status !== "unavailable" || first.reason !== "missing") return first; + // A fresh install has no config.json yet; the Windows installer's opt-out arrives exactly + // then. Create it the way a first start would, carrying the decision. A concurrent first + // start that wins the race leaves a file to patch instead. + const created = initializePersistedConfigIfMissing({ + ...getDefaultConfig(), + telemetry: { decision, decidedAt: nowIso, noticeShownAt: nowIso }, + }); + return created === "created" ? { status: "committed" as const, value: null } : mutate(); + }); if (outcome.status === "unavailable") { throw new Error( `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index e8b25bf6a0a..3f6d897db6e 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; @@ -12,6 +12,7 @@ import { setTelemetryPromptDepsForTests, } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { ensureTelemetryInstallId, readTelemetryInstallId, @@ -309,6 +310,24 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); + test("disable on a fresh install with no config.json creates it with the decline", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + // The Windows installer's opt-out runs before the first proxy start has written any config. + unlinkSync(join(home, "config.json")); + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + const saved = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as { telemetry?: { decision?: string } }; + expect(saved.telemetry?.decision).toBe("declined"); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + expect(resolveTelemetryStatus(readConfigDiagnostics().config, {}).effective).toBe(false); + // A second decline leaves the created file in place. + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + } finally { + log.mockRestore(); + } + }); + test("D9: enable and disable exit non-zero with no success line and preserve telemetry-id.json when config.json is unavailable", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); From 0e3c03a041ae77eb6131e836df46952f9bdf8835 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:04:37 +0900 Subject: [PATCH 14/29] docs(devlog): record the owner's SignPath decision (D21) and the Windows installer layer --- .../261107_telemetry_dashboard/000_plan.md | 1 + .../040_docs_privacy.md | 5 +++-- .../060_review_round.md | 22 +++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 63da28bb8c8..36750627e48 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -58,6 +58,7 @@ footer and in the PR descriptions. | --- | --- | --- | | 1 | this unit | Docs-only roadmap. | | 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 2b | [060_review_round.md](060_review_round.md) D21 | Windows installer page (disclosure, policy link, opt-out checkbox) and the desktop app carrying an installer opt-out into the config. | | 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | | 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | | 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md index 8f399f4ff06..7d944d29330 100644 --- a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -18,8 +18,9 @@ the guide: - `SPONSORS.md` ("opencodex does not collect any" telemetry). The statement replaces SignPath's "will not transfer any information …" sentence, which SignPath -allows only as an alternative to linking a privacy policy. Whether first-run disclosure satisfies -SignPath's install-time conditions is an open owner decision (060, "SignPath conditions"). +allows only as an alternative to linking a privacy policy. How the stack meets SignPath's +install-time conditions is recorded in 060 ("SignPath conditions", D21); layer 2b adds the Windows +installer option to these files. ## Public docs diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md index b85b03b89a9..1567b0c898d 100644 --- a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -14,7 +14,7 @@ specify only if the behaviour is described in a privacy policy, shown during ins disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first layer that can send), and layer 5 links it to the telemetry guide. -## SignPath conditions (open owner decision) +## SignPath conditions SignPath Foundation's conditions (https://signpath.org/terms, read 2026-11-08), "Respect user privacy and security": "Software that collects user data and transfers it to systems not specified by the user must @@ -26,9 +26,23 @@ or the "will not transfer" sentence. which has no installer, and the MSI, whose installer shows no policy and has no disable option. The CLI prompt and the dashboard popup appear on first run, after installation. The release workflow still uses the `test-signing` policy, so no production-signed build exists yet. Round 1 treated first-run disclosure as -"during installation"; that reading is not confirmed. The owner decides whether to add an MSI dialog -(policy and a disable checkbox) before switching to `release-signing`, to ask SignPath first, or to -accept first-run disclosure. No layer that can send merges before that decision. +"during installation"; that reading is not confirmed. + +Owner decision (2026-11-08), D21: telemetry stays default-on and the stack satisfies all three: + +- (a) The D12 privacy statement (README and translations, code-signing page in English and Turkish, + `SPONSORS.md`) and the layer 5 guide describe exactly what is sent and how to opt out. +- (b, c) MSI: a new layer between 2 and 3 adds an installer page generated from + `src/telemetry/disclosure.ts`, the same text the CLI prompt prints. It links the policy and has a + "Send anonymous usage statistics" checkbox, checked by default. Unchecked, the choice is recorded in + HKCU and the desktop app persists `telemetry.decision = "declined"` into the user's config before it + resolves, attaches to or starts any proxy. Tests pin the dialog text source and the persisted opt-out. +- (b, c) Standalone `ocx.exe` and npm installs have no installer: nothing is sent before + `noticeShownAt`, and the notice (the CLI prompt or the GUI popup) shows the policy summary and offers + disabling at that moment. A test proves zero sends before `noticeShownAt`. + +Layers that can send (2 and above) merge only after the installer layer and the zero-send test are in +the stack and have passed their reviews and CI. ## Decisions From 04abd39ee23ce83fbbe4c3e98e0311942f3de186 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:20:12 +0900 Subject: [PATCH 15/29] fix(telemetry): hold a pre-config decision instead of creating config.json b4dd934048 created a full default config.json when telemetry was declined on a fresh install, so the next ocx init kept the "existing" config and skipped provider setup. The Windows installer opt-out and the dashboard's first-run popup reach the same path; the popup even failed with 503 there. A decision made before config.json exists is now held as pendingDecision in telemetry-state.json. withPendingTelemetryDecision applies it wherever consent is resolved (scheduler ticks, the status route, the CLI prompt) until a decision is written into the config, which clears it. Tests: disable then init still sees a missing config, and the decline survives a config created later; a dashboard PUT on a fresh install returns 200 without writing config.json (503 before). Also count an id minted after an opt-out as a returning install (everReported), reuse isTelemetryEnvOptOut in background-lifecycle, and say in the disclosure that env opt-outs must reach the proxy's own environment. --- src/cli/telemetry.ts | 31 ++++----- src/server/background-lifecycle.ts | 10 +-- src/telemetry/routes.ts | 18 +++-- src/telemetry/scheduler.ts | 70 +++++++++++++++++++- structure/telemetry.md | 5 ++ tests/cli/cli-telemetry.test.ts | 39 ++++++++--- tests/server/server-telemetry-routes.test.ts | 23 ++++++- tests/usage/usage-telemetry-client.test.ts | 18 +++++ 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 4fb890460f5..78579f7abfd 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,13 +1,18 @@ import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; -import { getConfigDir, getDefaultConfig, initializePersistedConfigIfMissing, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; import { deleteTelemetryInstallId } from "../telemetry/install-id"; import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; +import { + clearPendingTelemetryDecision, + recordPendingTelemetryDecision, + withPendingTelemetryDecision, +} from "../telemetry/scheduler"; import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; import { currentVersion } from "../update/index"; import { isAgentDriven } from "./agent-driven"; @@ -36,7 +41,7 @@ export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", " • See the exact payload: ocx telemetry status --json", - " • Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", + " • Turn off anytime: ocx telemetry disable, the Proxy details toggle in the dashboard, or OCX_TELEMETRY=0 / DO_NOT_TRACK=1 in the proxy's own environment (a service does not see variables set in your shell).", ]); export function printTelemetryDisclosure(): void { @@ -88,22 +93,18 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise fresh.telemetry = next; return { changed, value: next }; }); - const outcome = await withConfigDirHardeningSettled(dir, () => { - const first = mutate(); - if (first.status !== "unavailable" || first.reason !== "missing") return first; + const outcome = await withConfigDirHardeningSettled(dir, () => mutate()); + if (outcome.status === "unavailable" && outcome.reason === "missing") { // A fresh install has no config.json yet; the Windows installer's opt-out arrives exactly - // then. Create it the way a first start would, carrying the decision. A concurrent first - // start that wins the race leaves a file to patch instead. - const created = initializePersistedConfigIfMissing({ - ...getDefaultConfig(), - telemetry: { decision, decidedAt: nowIso, noticeShownAt: nowIso }, - }); - return created === "created" ? { status: "committed" as const, value: null } : mutate(); - }); - if (outcome.status === "unavailable") { + // then. Creating the config would make `ocx init` skip setup, so hold the decision in + // telemetry-state.json; it applies until a decision is written into the config. + recordPendingTelemetryDecision({ decision, decidedAt: nowIso, noticeShownAt: nowIso }, dir); + } else if (outcome.status === "unavailable") { throw new Error( `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, ); + } else { + clearPendingTelemetryDecision(dir); } if (decision === "declined") { deleteTelemetryInstallId(dir); @@ -118,7 +119,7 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise export async function maybeShowTelemetryPrompt(): Promise { try { const diagnostics = readConfigDiagnostics({ passive: true }); - const status = resolveTelemetryStatus(diagnostics.config, process.env); + const status = resolveTelemetryStatus(withPendingTelemetryDecision(diagnostics.config), process.env); if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { return; } diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 2eeb91221d1..d52636579d1 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,6 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; +import { isTelemetryEnvOptOut } from "../telemetry/consent"; import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -66,13 +67,6 @@ let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; -function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { - const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); - if (ocx === "0" || ocx === "false" || ocx === "off" || ocx === "no") return true; - const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); - return dnt === "1" || dnt === "true" || dnt === "yes"; -} - function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); setStorageCleanupPolicyJobLiveApply(applyPolicy); @@ -137,7 +131,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { // The next poll tick retries. }); const generation = ++telemetryLoopGeneration; - if (!isTelemetryDisabledByEnv()) { + if (isTelemetryEnvOptOut(process.env) === null) { void import("../telemetry/scheduler") .then(scheduler => { if (generation !== telemetryLoopGeneration) return; diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 13fb34bdd59..13af9ffb841 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -16,10 +16,13 @@ import { } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "./payload"; import { + clearPendingTelemetryDecision, latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState, + recordPendingTelemetryDecision, resolveNextEligibleTelemetryDay, + withPendingTelemetryDecision, } from "./scheduler"; export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { @@ -37,7 +40,7 @@ export async function buildTelemetryStatusSnapshot( const now = options.now ?? Date.now(); const env = options.env ?? process.env; const configDir = options.configDir ?? getConfigDir(); - const status = resolveTelemetryStatus(config, env); + const status = resolveTelemetryStatus(withPendingTelemetryDecision(config, configDir), env); const installChannel = resolveInstallChannel({ env }); const state = readTelemetryState(configDir); const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); @@ -48,9 +51,7 @@ export async function buildTelemetryStatusSnapshot( const installDayHash = identity ? computeInstallDayHash(identity.installId, day) : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; - const newInstall = identity - ? identity.firstReportSent !== true - : true; + const newInstall = state.everReported !== true && identity?.firstReportSent !== true; const preview = buildTelemetryPayloadV1({ aggregate, installDayHash, @@ -126,13 +127,20 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise; + if (value.decision !== "accepted" && value.decision !== "declined") return null; + const stamp = (key: string): string | null => + typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; + const decidedAt = stamp("decidedAt"); + if (!decidedAt) return null; + return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; +} + export function readTelemetryState(configDir = getConfigDir()): TelemetryState { const path = telemetryStatePath(configDir); try { @@ -94,6 +116,8 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { ineligibleThroughDay, retryAfterUntil, envOptOutActiveSince, + pendingDecision: parsePendingDecision(raw.pendingDecision), + everReported: raw.everReported === true, }; } catch { return emptyState(); @@ -117,10 +141,45 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig ineligibleThroughDay: state.ineligibleThroughDay ?? null, retryAfterUntil: state.retryAfterUntil ?? null, envOptOutActiveSince: state.envOptOutActiveSince ?? null, + pendingDecision: state.pendingDecision ?? null, + everReported: state.everReported === true, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } +/** Hold a decision made before `config.json` exists. */ +export function recordPendingTelemetryDecision( + decision: OcxTelemetryConfig, + configDir = getConfigDir(), + now = Date.now(), +): void { + const state = readTelemetryState(configDir); + state.pendingDecision = structuredClone(decision); + writeTelemetryState(state, configDir, now); +} + +/** Drop the pending decision once a decision is written into `config.json`. */ +export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.pendingDecision) return; + state.pendingDecision = null; + writeTelemetryState(state, configDir, now); +} + +/** + * The telemetry block consent is resolved from: the config's own decision when it has one, + * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. + */ +export function withPendingTelemetryDecision>( + config: T | null | undefined, + configDir = getConfigDir(), +): Pick | null | undefined { + if (config?.telemetry?.decision) return config; + const pending = readTelemetryState(configDir).pendingDecision; + if (!pending) return config; + return { ...(config ?? {}), telemetry: structuredClone(pending) }; +} + /** * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an @@ -254,7 +313,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom const now = options.now ?? Date.now(); const configDir = options.configDir ?? getConfigDir(); const readCurrentEnv = (): Record => options.env ?? process.env; - const readCurrentConfig = (): Pick | undefined => { + const readRawConfig = (): Pick | undefined => { if (options.readConfig) return options.readConfig(); const diskConfig = readConfigDiagnostics({ passive: true }).config; if (options.config) { @@ -268,6 +327,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } return diskConfig; }; + // A decision held from before config.json existed applies until the config carries its own. + const readCurrentConfig = (): Pick | undefined => + withPendingTelemetryDecision(readRawConfig(), configDir) ?? undefined; const initialEnv = readCurrentEnv(); if (isTelemetryEnvOptOut(initialEnv) !== null) { @@ -340,7 +402,8 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = identity.firstReportSent !== true; + // An id minted after an opt-out is a returning install, not a new one. + const newInstall = identity.firstReportSent !== true && preSendState.everReported !== true; const installDayHash = computeInstallDayHash(identity.installId, targetDay); const payload = buildTelemetryPayloadV1({ aggregate, @@ -370,6 +433,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom const freshState = readTelemetryState(configDir); const nowIso = new Date(now).toISOString(); freshState.sentDays[targetDay] = nowIso; + freshState.everReported = true; freshState.lastSentDay = targetDay; freshState.lastSentAt = nowIso; freshState.retryAfterUntil = null; diff --git a/structure/telemetry.md b/structure/telemetry.md index b42c4f87ec2..4f1029a6aaf 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -42,6 +42,11 @@ Only a human can transition an undecided install to active sending: - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. +- A decision made before `config.json` exists (`ocx telemetry disable`, the dashboard popup on a + fresh install, the Windows installer opt-out) is held as `pendingDecision` in + `telemetry-state.json` instead of creating the config, so `ocx init` still runs setup. + `withPendingTelemetryDecision` applies it wherever consent is resolved (scheduler, status route, + CLI prompt) until a decision is written into the config, which clears it. ## Identity, aggregation, and scheduling diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index 3f6d897db6e..ee3f36415de 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -11,7 +11,9 @@ import { maybeShowTelemetryPrompt, setTelemetryPromptDepsForTests, } from "../../src/cli/telemetry"; -import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { getDefaultConfig, initializePersistedConfigIfMissing } from "../../src/config"; +import { observeInitialConfigState, readConfigDiagnostics } from "../../src/config/diagnostics"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { ensureTelemetryInstallId, @@ -310,17 +312,38 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); - test("disable on a fresh install with no config.json creates it with the decline", async () => { + test("disable on a fresh install keeps the install fresh for ocx init and the decline survives config creation", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { - // The Windows installer's opt-out runs before the first proxy start has written any config. + // The Windows installer's opt-out (or a privacy-minded user) declines before any config exists. unlinkSync(join(home, "config.json")); expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); - const saved = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as { telemetry?: { decision?: string } }; - expect(saved.telemetry?.decision).toBe("declined"); - expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); - expect(resolveTelemetryStatus(readConfigDiagnostics().config, {}).effective).toBe(false); - // A second decline leaves the created file in place. + // No config.json is created, so `ocx init` still runs provider setup. + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(observeInitialConfigState()).toBe("missing"); + expect((await buildTelemetryStatusSnapshot(readConfigDiagnostics().config, { env: {}, configDir: home }))).toMatchObject({ + effective: false, + decision: "declined", + }); + + // The first config is created later (init, OAuth login, dashboard); the decline still holds. + expect(initializePersistedConfigIfMissing(getDefaultConfig())).toBe("created"); + expect(readConfigDiagnostics().config.telemetry?.decision).toBeUndefined(); + expect((await buildTelemetryStatusSnapshot(readConfigDiagnostics().config, { env: {}, configDir: home })).effective).toBe(false); + let fetchCalls = 0; + const tick = await runTelemetryTick({ + configDir: home, + env: {}, + now: Date.parse("2026-11-12T12:00:00.000Z"), + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(false); + expect(fetchCalls).toBe(0); + + // A decision written into the config later wins and clears the pending one. expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); } finally { diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index c22ae8a8d93..e0a180e0989 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -7,7 +7,7 @@ import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; -import { latestCompletedUtcDay, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { latestCompletedUtcDay, readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -237,4 +237,23 @@ describe("/api/telemetry management routes", () => { } } }); + + test("PUT /api/telemetry on a fresh install holds the decision without creating config.json", async () => { + // A first-run popup can be answered before any config.json exists (fresh desktop install). + unlinkSync(join(home, "config.json")); + config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const declined = await call("PUT", "/api/telemetry", { + body: { decision: "declined" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(declined.status).toBe(200); + expect(declined.body).toMatchObject({ ok: true, effective: false, decision: "declined" }); + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(readTelemetryState(home).pendingDecision?.decision).toBe("declined"); + // A later GET from a process that only sees the defaults still reports the held decline. + config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e520969a731..0ce6fa8b831 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -791,6 +791,24 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("an id minted after an opt-out reports as a returning install, not a new one", async () => { + const sent: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sent.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }, + }; + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-03T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true }); + expect(sent[0]!.newInstall).toBe(true); + // A decline or env opt-out deletes the id; the next accepted send mints a fresh one. + deleteTelemetryInstallId(home); + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-04T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true }); + expect(sent[1]!.newInstall).toBe(false); + expect(readTelemetryState(home).everReported).toBe(true); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From 6d387ff40e723949c2fe92234de7095087e1a62e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:20:12 +0900 Subject: [PATCH 16/29] docs(telemetry): match the privacy statement and AGENTS_INSTALL to the code The statement no longer says telemetry can be turned off "at install time in that prompt"; the Windows installer option arrives in the next layer. AGENTS_INSTALL says ocx config set/unset telemetry refuse every caller, which is what the code does. --- AGENTS_INSTALL.md | 8 ++++---- README.md | 2 +- .../src/content/docs/reference/code-signing.md | 2 +- .../src/content/docs/tr/reference/code-signing.md | 2 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++++++------- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 62402cc7f71..fbc64bdb3dd 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -57,10 +57,10 @@ The following instructions concern **starring the repository on GitHub**. Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: -`PUT /api/telemetry`, `ocx telemetry enable`, and -`ocx config set telemetry` refuse agent-driven callers with -`agent_consent_required`. Opting out (`ocx telemetry disable` or -`OCX_TELEMETRY=0`) is always permitted. +`PUT /api/telemetry` and `ocx telemetry enable` refuse agent-driven callers with +`agent_consent_required`, and `ocx config set`/`unset telemetry` refuse every +caller. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always +permitted. ## Where things are installed diff --git a/README.md b/README.md index e7f3df50651..0dbab6f40f5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 356c5937c68..61a75e9e001 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -33,7 +33,7 @@ SignPath Foundation policy forbids signing them with the project certificate. OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and -has not declined; it can be turned off at install time in that prompt and anytime with +has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. In practice, OpenCodex forwards requests to the providers you configure. To look for new versions it also diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 890146fa9bf..1c1f53667f0 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -35,7 +35,7 @@ OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontro açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra `telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya -yolları asla dahil edilmez) gönderir; kurulum sırasında bu istemden ve istediğiniz +yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada diff --git a/readme/README.fr.md b/readme/README.fr.md index cc8d08dff5f..ab4d40ccf78 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index 5762cd62484..bce4e0b3d0b 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 8693f1e68ff..ff86a86a490 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 368a5899490..5c06c7b974e 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index fe0aae5f81f..b9c34551830 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 7c1aa50300d..8647bf6f080 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 8cc2b645b03..4a837326dfc 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 86421b84262..3ace0af263d 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" } } } From 5f0e29c10a61a978bcd66ddbbea82cc5eb9705d6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:29:42 +0900 Subject: [PATCH 17/29] fix(config): keep a telemetry decision on disk through a live settings save The CLI and the consent route write telemetry consent to config.json while a proxy may hold an older in-memory config. A later settings save from that proxy dropped a decision the snapshot never had, so the install fell back to awaiting-notice and the user was asked again. saveConfigPreservingClaudeCode now carries the on-disk telemetry block into the write and adopts it in memory. Failing-first test in tests/config/telemetry-live-save.test.ts. --- scripts/test-layout/layout.json | 2 +- src/config/live-reconcile.ts | 9 ++++ tests/config/telemetry-live-save.test.ts | 61 ++++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/config/telemetry-live-save.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1184447a5eb..9a133deb7bd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -780,7 +780,7 @@ "config-save-boundary.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", - "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", + "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", "telemetry-live-save.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index e836e31add0..2b5f34a73eb 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -636,6 +636,15 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { ); if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; + // Telemetry consent is owned by the file: the CLI and the consent route write it there while a + // proxy may hold an older snapshot. A settings save must carry the decision on disk, never drop + // or roll it back. + if (onDisk !== undefined) { + const diskTelemetry = (onDisk as Partial).telemetry; + if (diskTelemetry === undefined) delete projectedConfig.telemetry; + else projectedConfig.telemetry = structuredClone(diskTelemetry); + adoptPersistedTelemetry(config, diskTelemetry); + } const persistedBinding = (bindingBaseline || diskDeletedKeys.has("hostname")) && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline ?? { port: config.port, hostname: config.hostname }) : bindingBaseline; diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts new file mode 100644 index 00000000000..c41aaccec16 --- /dev/null +++ b/tests/config/telemetry-live-save.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Telemetry consent is written to disk by the CLI (`ocx telemetry enable|disable`, the start + * prompt) and by the consent route, often while a proxy runs with an older in-memory config. + * A later settings save from that proxy must carry the decision on disk, never drop or roll it back. + */ +const seed = (telemetry?: OcxTelemetryConfig): OcxConfig => ({ + port: 10100, + logLevel: "info", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example.test" } }, + defaultProvider: "fixture", + ...(telemetry ? { telemetry } : {}), +} as OcxConfig); + +const declined: OcxTelemetryConfig = { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }; +const accepted: OcxTelemetryConfig = { decision: "accepted", decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }; + +function liveSaveAfterDiskDecision(initial: OcxConfig, onDisk: OcxTelemetryConfig): { live: OcxConfig; saved: OcxConfig } { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(initial); + const live = loadConfig(); + armClaudeCodeBaseline(live); + // A CLI process records a decision while the proxy keeps its older in-memory config. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(onDisk); + return { changed: true, value: null }; + }).status).toBe("committed"); + live.logLevel = "debug"; + saveConfigPreservingClaudeCode(live); + live.logLevel = "warn"; + saveConfigPreservingClaudeCode(live); + return { live, saved: JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig }; + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +} + +test("a live save keeps a telemetry decision written to disk after the proxy loaded its config", () => { + const { live, saved } = liveSaveAfterDiskDecision(seed(), declined); + expect(saved.logLevel).toBe("warn"); + expect(saved.telemetry).toEqual(declined); + expect(live.telemetry).toEqual(declined); +}); + +test("a live save never rolls a newer decline on disk back to the accept it loaded", () => { + const { live, saved } = liveSaveAfterDiskDecision(seed(accepted), declined); + expect(saved.telemetry).toEqual(declined); + expect(live.telemetry).toEqual(declined); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46504f81156..b9d4526a709 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -764,7 +764,7 @@ "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", - "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", + "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", "telemetry-live-save.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows", "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", From 5b88d16aea117181899551b9e5affb968e4ef1d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:34:08 +0900 Subject: [PATCH 18/29] fix(telemetry): keep a running opted-out proxy's days unsent, fail closed on bad state, and stop leaking the scheduler into server start From the round-2 privacy and code reviews of #8300: - Two proxies can share one home. An opted-out proxy now records its pid in envOptOutOwners; a tick without the opt-out ends the period only once no recorded owner is alive and otherwise extends ineligibleThroughDay to today, and a clean stop always covers its stop day. Failing-first: the second proxy sent 2026-11-03 while the first still ran opted out. - A telemetry-state.json that exists but cannot be read stops sending (state-unreadable) instead of reading as empty and reopening opt-out days. - readTelemetryInstallId re-checks the file before trusting its memo, so an id deleted by another process is never reused or rewritten. - State code moved to src/telemetry/state.ts; background-lifecycle imports only it, so the scheduler, aggregation and sender load only when telemetry can run. - GET /api/telemetry reads consent from disk like the scheduler, and reuses a day's preview aggregate for 10 minutes because the dashboard panel polls it. - Removed the migration of a state key no release ever wrote; AGENTS_INSTALL names tty_required and DO_NOT_TRACK; plan 010/000 wording. --- AGENTS_INSTALL.md | 7 +- .../261107_telemetry_dashboard/000_plan.md | 2 +- .../010_client_core.md | 2 +- src/server/background-lifecycle.ts | 8 +- src/telemetry/install-id.ts | 20 +- src/telemetry/routes.ts | 29 +- src/telemetry/scheduler.ts | 243 +++------------- src/telemetry/state.ts | 275 ++++++++++++++++++ structure/telemetry.md | 19 +- tests/server/server-telemetry-routes.test.ts | 10 + tests/usage/usage-telemetry-client.test.ts | 66 ++++- 11 files changed, 443 insertions(+), 238 deletions(-) create mode 100644 src/telemetry/state.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index fbc64bdb3dd..bbe5f606d95 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -58,9 +58,10 @@ Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: `PUT /api/telemetry` and `ocx telemetry enable` refuse agent-driven callers with -`agent_consent_required`, and `ocx config set`/`unset telemetry` refuse every -caller. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always -permitted. +`agent_consent_required` (and `ocx telemetry enable` refuses a non-interactive +terminal with `tty_required`), and `ocx config set`/`unset telemetry` refuse every +caller. Opting out (`ocx telemetry disable`, `OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) +is always permitted. ## Where things are installed diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 36750627e48..688441c38da 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -100,6 +100,6 @@ rejects `/` tokens and host identifiers there (010). ## PR review round 1 -Reviewer feedback on the open stack is recorded, with owner decisions D1–D20, in +Reviewer feedback on the open stack is recorded, with owner decisions D1–D21, in [060_review_round.md](060_review_round.md). Those decisions supersede conflicting text in 000–050, including the public privacy statement change that must land with the first layer able to send. diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md index d6d39999357..da33a0404b3 100644 --- a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -121,7 +121,7 @@ run through `validateTelemetryPayloadV1` before sending or previewing. - Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management routes also call `adoptPersistedTelemetry` and nudge the scheduler. -- It sends only completed days on or after the UTC day of `decidedAt ?? noticeShownAt` (no pre-consent +- It sends only completed days strictly after the UTC day of `decidedAt ?? noticeShownAt` (060 D1; no pre-consent backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. - `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index d52636579d1..549ad5e406f 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -34,7 +34,8 @@ import { } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; import { isTelemetryEnvOptOut } from "../telemetry/consent"; -import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; +// The state module alone: the scheduler and its aggregation stay behind the dynamic import below. +import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -142,7 +143,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { }); } else { telemetryEnvOptOutObserved = true; - try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } + // Recorded under this pid, so a proxy sharing the home cannot end the period while this one runs. + try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { @@ -167,7 +169,7 @@ function stopProcessLoops(): void { stopTelemetry?.(); if (telemetryEnvOptOutObserved) { telemetryEnvOptOutObserved = false; - try { endTelemetryEnvOptOut(); } catch { /* best-effort; the marker stays and the next tick ends it */ } + try { endTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort; the next tick ends it once this pid has exited */ } } const loops = processLoops; processLoops = null; diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index 2c2a7dd1e0e..977b68cc8c7 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -59,13 +59,18 @@ function parseTelemetryId(text: string): TelemetryInstallIdentity | null { /** Read-only: returns the persisted telemetry install identity, or null when absent/invalid. */ export function readTelemetryInstallId(configDir = getConfigDir()): TelemetryInstallIdentity | null { const path = telemetryIdPath(configDir); - if (memo?.path === path) return memo.identity; try { const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) { + if (memo?.path === path) memo = null; + return null; + } } catch { + // Another process (`ocx telemetry disable`, an env opt-out) may have deleted it: forget the memo. + if (memo?.path === path) memo = null; return null; } + if (memo?.path === path) return memo.identity; let identity: TelemetryInstallIdentity | null; try { identity = parseTelemetryId(readFileSync(path, "utf8")); @@ -209,17 +214,6 @@ export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { } catch (error) { if (errorCode(error) !== "ENOENT") deleted = false; } - const statePath = join(configDir, "telemetry-state.json"); - try { - const stat = lstatSync(statePath); - if (!stat.isSymbolicLink() && stat.isFile() && stat.size <= 64 * 1024) { - const raw = JSON.parse(readFileSync(statePath, "utf8")) as Record; - if (raw && typeof raw === "object" && !Array.isArray(raw) && "firstReportSentForInstallId" in raw) { - delete raw.firstReportSentForInstallId; - atomicWriteFileNoFollow(statePath, `${JSON.stringify(raw, null, 2)}\n`); - } - } - } catch { /* best-effort */ } return deleted; } diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 13af9ffb841..1ba32b455e0 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -1,11 +1,12 @@ import { mutatePersistedConfig } from "../config"; +import { readConfigDiagnostics } from "../config/diagnostics"; import { adoptPersistedTelemetry } from "../config/live-reconcile"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { jsonResponse } from "../server/auth-cors"; import { hasHumanDashboardSession } from "../server/management/consent-session"; import type { ManagementContext } from "../server/management/context"; import type { OcxConfig, OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; -import { aggregateTelemetryForDay } from "./aggregate"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; import type { TelemetryInstallChannel, TelemetryPayloadV1 } from "./contract"; import { @@ -33,9 +34,28 @@ export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { preview: TelemetryPayloadV1; } +/** How long the dashboard's preview may reuse a completed day's aggregate (the panel polls). */ +export const TELEMETRY_PREVIEW_CACHE_MS = 10 * 60 * 1000; +let previewAggregateCache: { configDir: string; day: string; at: number; aggregate: DailyTelemetryAggregate } | null = null; + +export function resetTelemetryPreviewCacheForTests(): void { + previewAggregateCache = null; +} + +async function previewAggregate(day: string, configDir: string, now: number, cache: boolean): Promise { + const hit = previewAggregateCache; + if (cache && hit && hit.configDir === configDir && hit.day === day && now - hit.at >= 0 && now - hit.at < TELEMETRY_PREVIEW_CACHE_MS) { + return hit.aggregate; + } + // A completed day's aggregate scans usage.jsonl from the start; the dashboard polls this route. + const aggregate = await aggregateTelemetryForDay(day); + if (cache) previewAggregateCache = { configDir, day, at: now, aggregate }; + return aggregate; +} + export async function buildTelemetryStatusSnapshot( config: Pick | null | undefined, - options: { now?: number; env?: Record; configDir?: string } = {}, + options: { now?: number; env?: Record; configDir?: string; cachePreview?: boolean } = {}, ): Promise { const now = options.now ?? Date.now(); const env = options.env ?? process.env; @@ -46,7 +66,7 @@ export async function buildTelemetryStatusSnapshot( const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); const previewEligible = nextEligibleDay !== null; const day = nextEligibleDay ?? latestCompletedUtcDay(now); - const aggregate = await aggregateTelemetryForDay(day); + const aggregate = await previewAggregate(day, configDir, now, options.cachePreview === true); const identity = readTelemetryInstallId(configDir); const installDayHash = identity ? computeInstallDayHash(identity.installId, day) @@ -85,7 +105,8 @@ function consentRefusedResponse(ctx: ManagementContext): Response { } export async function handleGetTelemetryRoute(ctx: ManagementContext): Promise { - const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + // Consent is read from disk, like the scheduler: a CLI decision while the proxy runs must show here. + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config, { cachePreview: true }); return jsonResponse(snapshot, 200, ctx.req, ctx.config); } diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index ba914dd50d4..c56029ffed9 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -1,226 +1,51 @@ -import { lstatSync, mkdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; -import { recordOwnedConfigPath } from "../lib/config-ownership"; import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; -import type { OcxConfig, OcxTelemetryConfig } from "../types"; +import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { isTelemetryEnvOptOut, resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; -import { parseCalendarUtcDayMs } from "./contract"; import { computeInstallDayHash, - deleteTelemetryInstallId, ensureTelemetryInstallId, markTelemetryFirstReportSent, } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; +import { + endTelemetryEnvOptOut, + formatUtcDay, + readTelemetryState, + readTelemetryStateFile, + recordTelemetryEnvOptOut, + utcMidnightMs, + withPendingTelemetryDecision, + writeTelemetryState, + type TelemetryState, +} from "./state"; + +export { + TELEMETRY_STATE_FILE, + clearPendingTelemetryDecision, + endTelemetryEnvOptOut, + formatUtcDay, + readTelemetryState, + readTelemetryStateFile, + recordPendingTelemetryDecision, + recordTelemetryEnvOptOut, + telemetryStatePath, + utcMidnightMs, + withPendingTelemetryDecision, + writeTelemetryState, + type TelemetryEnvOptOutOwner, + type TelemetryState, +} from "./state"; -export const TELEMETRY_STATE_FILE = "telemetry-state.json"; export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; export const TELEMETRY_STARTUP_BASE_DELAY_MS = 30_000; export const TELEMETRY_STARTUP_JITTER_MS = 60_000; export const TELEMETRY_INTERVAL_JITTER_MS = 15 * 60 * 1000; const DAY_MS = 86_400_000; const MAX_BACKLOG_DAYS = 7; -const STATE_RETENTION_DAYS = 30; - -export interface TelemetryState { - version: 1; - sentDays: Record; - lastSentDay: string | null; - lastSentAt: string | null; - ineligibleThroughDay: string | null; - retryAfterUntil: string | null; - /** - * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the - * next tick that runs without the opt-out. While it is set, every day up to the current one is - * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks - * that could keep `ineligibleThroughDay` current (D2). - */ - envOptOutActiveSince?: string | null; - /** - * A decision made before `config.json` exists (the Windows installer opt-out, `ocx telemetry - * disable` or the dashboard on a fresh install). Writing the config would turn the install into a - * configured one and make `ocx init` skip setup, so the decision waits here and applies until a - * decision is written into the config. - */ - pendingDecision?: OcxTelemetryConfig | null; - /** Set once any report was accepted, so an id minted after an opt-out is not counted as a new install. */ - everReported?: boolean; -} - -export function telemetryStatePath(configDir = getConfigDir()): string { - return join(configDir, TELEMETRY_STATE_FILE); -} - -function emptyState(): TelemetryState { - return { - version: 1, - sentDays: {}, - lastSentDay: null, - lastSentAt: null, - ineligibleThroughDay: null, - retryAfterUntil: null, - envOptOutActiveSince: null, - pendingDecision: null, - everReported: false, - }; -} - -function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const value = raw as Record; - if (value.decision !== "accepted" && value.decision !== "declined") return null; - const stamp = (key: string): string | null => - typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; - const decidedAt = stamp("decidedAt"); - if (!decidedAt) return null; - return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; -} - -export function readTelemetryState(configDir = getConfigDir()): TelemetryState { - const path = telemetryStatePath(configDir); - try { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return emptyState(); - const raw = JSON.parse(readFileSync(path, "utf8")) as Record; - if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { - return emptyState(); - } - const sentDays: Record = {}; - for (const [day, at] of Object.entries(raw.sentDays as Record)) { - if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { - sentDays[day] = at; - } - } - const ineligibleThroughDay = - typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null - ? raw.ineligibleThroughDay - : null; - const retryAfterUntil = - typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) - ? raw.retryAfterUntil - : null; - const envOptOutActiveSince = - typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) - ? raw.envOptOutActiveSince - : null; - return { - version: 1, - sentDays, - lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, - lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, - ineligibleThroughDay, - retryAfterUntil, - envOptOutActiveSince, - pendingDecision: parsePendingDecision(raw.pendingDecision), - everReported: raw.everReported === true, - }; - } catch { - return emptyState(); - } -} - -export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const path = telemetryStatePath(configDir); - try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } - const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); - const prunedSentDays: Record = {}; - for (const [day, at] of Object.entries(state.sentDays ?? {})) { - if (day >= minKeepDay) prunedSentDays[day] = at; - } - const payload: TelemetryState = { - version: 1, - sentDays: prunedSentDays, - lastSentDay: state.lastSentDay ?? null, - lastSentAt: state.lastSentAt ?? null, - ineligibleThroughDay: state.ineligibleThroughDay ?? null, - retryAfterUntil: state.retryAfterUntil ?? null, - envOptOutActiveSince: state.envOptOutActiveSince ?? null, - pendingDecision: state.pendingDecision ?? null, - everReported: state.everReported === true, - }; - atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); -} - -/** Hold a decision made before `config.json` exists. */ -export function recordPendingTelemetryDecision( - decision: OcxTelemetryConfig, - configDir = getConfigDir(), - now = Date.now(), -): void { - const state = readTelemetryState(configDir); - state.pendingDecision = structuredClone(decision); - writeTelemetryState(state, configDir, now); -} - -/** Drop the pending decision once a decision is written into `config.json`. */ -export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.pendingDecision) return; - state.pendingDecision = null; - writeTelemetryState(state, configDir, now); -} - -/** - * The telemetry block consent is resolved from: the config's own decision when it has one, - * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. - */ -export function withPendingTelemetryDecision>( - config: T | null | undefined, - configDir = getConfigDir(), -): Pick | null | undefined { - if (config?.telemetry?.decision) return config; - const pending = readTelemetryState(configDir).pendingDecision; - if (!pending) return config; - return { ...(config ?? {}), telemetry: structuredClone(pending) }; -} - -/** - * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in - * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an - * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. - */ -export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { - const todayDay = formatUtcDay(utcMidnightMs(now)); - const state = readTelemetryState(configDir); - if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { - state.ineligibleThroughDay = todayDay; - } - state.envOptOutActiveSince ??= new Date(now).toISOString(); - writeTelemetryState(state, configDir, now); - deleteTelemetryInstallId(configDir); -} - -/** - * End an observed env opt-out: every day up to today stays ineligible and the marker is cleared, so - * only days strictly after today can be sent. Called by a clean shutdown of an opted-out proxy and - * by the first tick that runs without the opt-out. - */ -export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.envOptOutActiveSince) return; - const todayDay = formatUtcDay(utcMidnightMs(now)); - if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { - state.ineligibleThroughDay = todayDay; - } - state.envOptOutActiveSince = null; - writeTelemetryState(state, configDir, now); - deleteTelemetryInstallId(configDir); -} - -export function utcMidnightMs(epochMs: number): number { - const d = new Date(epochMs); - return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); -} - -export function formatUtcDay(epochMs: number): string { - return new Date(epochMs).toISOString().slice(0, 10); -} export function latestCompletedUtcDay(now = Date.now()): string { return formatUtcDay(utcMidnightMs(now) - DAY_MS); @@ -242,7 +67,7 @@ export function resolveNextEligibleTelemetryDay( if (!status.effective) { return { day: null, reason: status.reason }; } - if (state.envOptOutActiveSince) { + if (state.envOptOutActiveSince || (state.envOptOutOwners?.length ?? 0) > 0) { return { day: null, reason: "env-opt-out-pending" }; } const consentTimestamp = status.decidedAt ?? status.noticeShownAt; @@ -343,7 +168,13 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, reason: status.reason }; } - if (readTelemetryState(configDir).envOptOutActiveSince) { + const stateFile = readTelemetryStateFile(configDir); + if (stateFile.unreadable) { + // A state file that exists but cannot be read may have held opt-out days; never send over it. + return { sent: false, reason: "state-unreadable" }; + } + if (stateFile.state.envOptOutActiveSince || (stateFile.state.envOptOutOwners?.length ?? 0) > 0) { + // Ends the period only once no opted-out proxy is still running; otherwise extends it to today. endTelemetryEnvOptOut(configDir, now); } const state = readTelemetryState(configDir); diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts new file mode 100644 index 00000000000..e945bcc4a40 --- /dev/null +++ b/src/telemetry/state.ts @@ -0,0 +1,275 @@ +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import type { OcxConfig, OcxTelemetryConfig } from "../types"; +import { parseCalendarUtcDayMs } from "./contract"; +import { deleteTelemetryInstallId } from "./install-id"; + +/** + * `telemetry-state.json`: sent days, opt-out bookkeeping and the pending decision. Kept apart from + * the scheduler so the server's background lifecycle can record an env opt-out without loading the + * scheduler, aggregation, payload and sender modules. + */ + +export const TELEMETRY_STATE_FILE = "telemetry-state.json"; +const DAY_MS = 86_400_000; +const STATE_RETENTION_DAYS = 30; + +/** A proxy that started under an env opt-out and has not stopped cleanly yet. */ +export interface TelemetryEnvOptOutOwner { + pid: number; + since: string; +} +export interface TelemetryState { + version: 1; + sentDays: Record; + lastSentDay: string | null; + lastSentAt: string | null; + ineligibleThroughDay: string | null; + retryAfterUntil: string | null; + /** + * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the + * next tick that runs without the opt-out. While it is set, every day up to the current one is + * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks + * that could keep `ineligibleThroughDay` current (D2). + */ + envOptOutActiveSince?: string | null; + /** + * Proxies running under an env opt-out. A tick without the opt-out ends the period only once all + * of them have exited, so a second proxy sharing the home cannot reopen days the first still covers. + */ + envOptOutOwners?: TelemetryEnvOptOutOwner[]; + /** + * A decision made before `config.json` exists (the Windows installer opt-out, `ocx telemetry + * disable` or the dashboard on a fresh install). Writing the config would turn the install into a + * configured one and make `ocx init` skip setup, so the decision waits here and applies until a + * decision is written into the config. + */ + pendingDecision?: OcxTelemetryConfig | null; + /** Set once any report was accepted, so an id minted after an opt-out is not counted as a new install. */ + everReported?: boolean; +} + +export function telemetryStatePath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_STATE_FILE); +} + +function emptyState(): TelemetryState { + return { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, + envOptOutActiveSince: null, + envOptOutOwners: [], + pendingDecision: null, + everReported: false, + }; +} + +function parseOwners(raw: unknown): TelemetryEnvOptOutOwner[] { + if (!Array.isArray(raw)) return []; + const owners: TelemetryEnvOptOutOwner[] = []; + for (const entry of raw.slice(0, 64)) { + if (!entry || typeof entry !== "object") continue; + const { pid, since } = entry as Record; + if (typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 && typeof since === "string" && !Number.isNaN(Date.parse(since))) { + owners.push({ pid, since }); + } + } + return owners; +} + +function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const value = raw as Record; + if (value.decision !== "accepted" && value.decision !== "declined") return null; + const stamp = (key: string): string | null => + typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; + const decidedAt = stamp("decidedAt"); + if (!decidedAt) return null; + return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; +} + +/** + * Read the state, noting whether a file exists but cannot be trusted (a symlink, oversized or + * malformed). Such a file must not be read as empty: that would forget opt-out days. + */ +export function readTelemetryStateFile(configDir = getConfigDir()): { state: TelemetryState; unreadable: boolean } { + const path = telemetryStatePath(configDir); + let stat; + try { + stat = lstatSync(path); + } catch { + return { state: emptyState(), unreadable: false }; + } + try { + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return { state: emptyState(), unreadable: true }; + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { + return { state: emptyState(), unreadable: true }; + } + const sentDays: Record = {}; + for (const [day, at] of Object.entries(raw.sentDays as Record)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { + sentDays[day] = at; + } + } + const ineligibleThroughDay = + typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null + ? raw.ineligibleThroughDay + : null; + const retryAfterUntil = + typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) + ? raw.retryAfterUntil + : null; + const envOptOutActiveSince = + typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) + ? raw.envOptOutActiveSince + : null; + return { unreadable: false, state: { + version: 1, + sentDays, + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + ineligibleThroughDay, + retryAfterUntil, + envOptOutActiveSince, + envOptOutOwners: parseOwners(raw.envOptOutOwners), + pendingDecision: parsePendingDecision(raw.pendingDecision), + everReported: raw.everReported === true, + } }; + } catch { + return { state: emptyState(), unreadable: true }; + } +} + +export function readTelemetryState(configDir = getConfigDir()): TelemetryState { + return readTelemetryStateFile(configDir).state; +} + +export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryStatePath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); + const prunedSentDays: Record = {}; + for (const [day, at] of Object.entries(state.sentDays ?? {})) { + if (day >= minKeepDay) prunedSentDays[day] = at; + } + const payload: TelemetryState = { + version: 1, + sentDays: prunedSentDays, + lastSentDay: state.lastSentDay ?? null, + lastSentAt: state.lastSentAt ?? null, + ineligibleThroughDay: state.ineligibleThroughDay ?? null, + retryAfterUntil: state.retryAfterUntil ?? null, + envOptOutActiveSince: state.envOptOutActiveSince ?? null, + envOptOutOwners: state.envOptOutOwners ?? [], + pendingDecision: state.pendingDecision ?? null, + everReported: state.everReported === true, + }; + atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); +} + +/** Hold a decision made before `config.json` exists. */ +export function recordPendingTelemetryDecision( + decision: OcxTelemetryConfig, + configDir = getConfigDir(), + now = Date.now(), +): void { + const state = readTelemetryState(configDir); + state.pendingDecision = structuredClone(decision); + writeTelemetryState(state, configDir, now); +} + +/** Drop the pending decision once a decision is written into `config.json`. */ +export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.pendingDecision) return; + state.pendingDecision = null; + writeTelemetryState(state, configDir, now); +} + +/** + * The telemetry block consent is resolved from: the config's own decision when it has one, + * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. + */ +export function withPendingTelemetryDecision>( + config: T | null | undefined, + configDir = getConfigDir(), +): Pick | null | undefined { + if (config?.telemetry?.decision) return config; + const pending = readTelemetryState(configDir).pendingDecision; + if (!pending) return config; + return { ...(config ?? {}), telemetry: structuredClone(pending) }; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === "EPERM"; + } +} + +function extendIneligibleThroughToday(state: TelemetryState, now: number): void { + const todayDay = formatUtcDay(utcMidnightMs(now)); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } +} + +/** + * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in + * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an + * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. A proxy that runs under the opt-out + * passes its pid as `owner`; a one-off observation (a CLI command, a tick) passes none. + */ +export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): void { + const state = readTelemetryState(configDir); + extendIneligibleThroughToday(state, now); + if (owner === undefined) { + state.envOptOutActiveSince ??= new Date(now).toISOString(); + } else { + const owners = (state.envOptOutOwners ?? []).filter(entry => entry.pid !== owner); + state.envOptOutOwners = [...owners, { pid: owner, since: new Date(now).toISOString() }]; + } + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + +/** + * Close an env opt-out period so only days strictly after today can be sent. A clean stop of an + * opted-out proxy passes its pid and always covers its stop day. A tick passes none: it ends the + * period only when no recorded opted-out proxy is still running, and otherwise extends + * `ineligibleThroughDay` to today. Returns true while an opted-out proxy is still running. + */ +export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): boolean { + const state = readTelemetryState(configDir); + const recorded = state.envOptOutOwners ?? []; + const others = owner === undefined ? recorded : recorded.filter(entry => entry.pid !== owner); + const running = others.filter(entry => isProcessAlive(entry.pid)); + if (owner === undefined && !state.envOptOutActiveSince && recorded.length === 0) return false; + extendIneligibleThroughToday(state, now); + state.envOptOutOwners = running; + if (running.length === 0) state.envOptOutActiveSince = null; + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); + return running.length > 0; +} + + +export function utcMidnightMs(epochMs: number): number { + const d = new Date(epochMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} diff --git a/structure/telemetry.md b/structure/telemetry.md index 4f1029a6aaf..e44ee3af428 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -32,7 +32,9 @@ Only a human can transition an undecided install to active sending: from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) requires `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. `GET /api/telemetry` previews the oldest eligible unsent day (or the latest completed day with - `previewEligible: false` when no day is eligible). + `previewEligible: false` when no day is eligible). It reads consent from `config.json` on disk, + like the scheduler, and reuses a day's aggregate for `TELEMETRY_PREVIEW_CACHE_MS` because the + dashboard polls it. - `ocx telemetry enable` in `src/cli/telemetry.ts` requires an interactive human terminal (`isatty` stdin and stdout, not `OCX_SERVICE`, not agent-driven), prints the disclosure block, and asks for confirmation before recording `accepted`; otherwise it exits non-zero with @@ -69,11 +71,16 @@ with a 10-second `signalWithTimeout` and parses `Retry-After` on HTTP 429/503 re 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 reads the env once and runs no ticks, so the marker stands for the whole -period it ran: while it is set no day is eligible, and `endTelemetryEnvOptOut` closes the period -(ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick -that runs without the opt-out. Each tick sends at most one completed UTC +`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) stops sending +(`state-unreadable`) rather than being read as empty. 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 sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after `ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index e0a180e0989..986d751bed0 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -256,4 +256,14 @@ describe("/api/telemetry management routes", () => { const status = await call("GET", "/api/telemetry"); expect(status.body).toMatchObject({ effective: false, decision: "declined" }); }); + + test("GET /api/telemetry reports a decision written to disk while the server's config is stale", async () => { + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...config, + telemetry: { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }, + }, null, 2) + "\n", "utf8"); + expect(config.telemetry).toBeUndefined(); + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 0ce6fa8b831..1e3db899269 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -40,6 +40,7 @@ import { computeTelemetryIntervalDelayMs, computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, + endTelemetryEnvOptOut, readTelemetryState, recordTelemetryEnvOptOut, runTelemetryTick, @@ -743,7 +744,11 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => test("never sends its opt-out days when it did not stop cleanly before a normal proxy ticks", async () => { const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); try { - // Monday through Thursday ran opted out. A normal proxy takes over on Friday. + // Monday through Thursday ran opted out, then the proxy crashed: its recorded pid is gone. + const crashed = readTelemetryState(home); + expect(crashed.envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + writeTelemetryState({ ...crashed, envOptOutOwners: [{ pid: 2_000_000_000, since: crashed.envOptOutOwners![0]!.since }] }, home); + // A normal proxy takes over on Friday. setSystemTime(new Date("2026-11-06T12:00:00.000Z")); expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); expect((await tickAt("2026-11-07T12:00:00.000Z")).sent).toBe(false); @@ -809,6 +814,65 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).everReported).toBe(true); }); + test("D2: a proxy without the opt-out never ends the period of an opted-out proxy that is still running", async () => { + const sentDays: string[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const tickAt = (iso: string) => runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + + // Process B (this test process, so it is alive) starts opted out on 2026-11-02 and keeps running. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-02T09:00:00.000Z"), process.pid); + // Process A, sharing the home without the opt-out, ticks on the same and later days. + expect((await tickAt("2026-11-02T12:00:00.000Z")).sent).toBe(false); + expect((await tickAt("2026-11-04T12:00:00.000Z")).sent).toBe(false); + expect(sentDays).toEqual([]); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-04"); + + // B stops cleanly on 2026-11-05: every day through its stop day stays unsent. + endTelemetryEnvOptOut(home, Date.parse("2026-11-05T08:00:00.000Z"), process.pid); + expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentDays).toEqual(["2026-11-06"]); + }); + + test("D2: an opted-out proxy that died is closed by the next tick", async () => { + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + // A pid far above any real one stands for a process that has exited. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-02T09:00:00.000Z"), 2_000_000_000); + const result = await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-04T12:00:00.000Z"), fetch: (async () => new Response("{}", { status: 202 })) as unknown as typeof fetch }); + expect(result.sent).toBe(false); + expect(readTelemetryState(home)).toMatchObject({ ineligibleThroughDay: "2026-11-04", envOptOutActiveSince: null }); + expect(readTelemetryState(home).envOptOutOwners ?? []).toEqual([]); + }); + + test("an unreadable telemetry-state.json blocks sending instead of forgetting opt-out days", async () => { + let fetchCalls = 0; + const mockFetch = (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: mockFetch })).toEqual({ sent: false, reason: "state-unreadable" }); + expect(fetchCalls).toBe(0); + }); + + test("a telemetry-id.json deleted by another process is not served from memory", () => { + const { identity } = ensureTelemetryInstallId(home); + expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); + unlinkSync(telemetryIdPath(home)); + expect(readTelemetryInstallId(home)).toBeNull(); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From 7aee5186250d2bd5572e67ebb8a8081d8282bd8b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:09:10 +0900 Subject: [PATCH 19/29] fix(config,telemetry): carry validated on-disk consent through every whole-config save, and keep an unreadable state file's protection From the round-3 reviews of #8300 (all P3): - saveConfig now carries the on-disk telemetry block like the live save does, so a decline written by another process between a load and a save is never rolled back. - Both saves carry the block only when it validates; a malformed hand edit is dropped (as a load drops it) instead of being adopted into the live config, which made unrelated route validations fail. Shared helper: src/config/telemetry-carry.ts. - A writer that replaces an unreadable telemetry-state.json starts from 'nothing through today may be sent', and the status preview reports nothing eligible while every tick refuses to send. Failing-first tests in tests/config/telemetry-live-save.test.ts and tests/usage/usage-telemetry-client.test.ts. --- src/config.ts | 8 +++- src/config/live-reconcile.ts | 13 ++---- src/config/telemetry-carry.ts | 23 ++++++++++ src/telemetry/routes.ts | 9 +++- src/telemetry/state.ts | 16 +++++-- structure/telemetry.md | 5 +++ tests/config/telemetry-live-save.test.ts | 51 +++++++++++++++++++++- tests/usage/usage-telemetry-client.test.ts | 10 +++++ 8 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 src/config/telemetry-carry.ts diff --git a/src/config.ts b/src/config.ts index 609326e2587..3742fd4e383 100644 --- a/src/config.ts +++ b/src/config.ts @@ -161,6 +161,8 @@ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unloc import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; import { getDefaultConfig } from "./config/proxy-env"; import { configSchema } from "./config/schema/config-schema"; +import { adoptPersistedTelemetry } from "./config/live-reconcile"; +import { carryPersistedTelemetry } from "./config/telemetry-carry"; import { sanitizeOpenCodeOverridesForLoad } from "./config/opencode-wire-upgrade"; import { projectRemovedOpenCodeFreeSnapshot } from "./config/removed-opencode-free-backup"; import { @@ -384,12 +386,16 @@ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); withConfigMutationLockSync(() => { + const onDisk = readRawConfigJson(); const withProvenance = projectCustomModelCatalogMigration( - readRawConfigJson(), + onDisk, projectConfigRebaseProvenance(config), ); + // Telemetry consent is owned by the file, as in the live save (see carryPersistedTelemetry). + const telemetry = carryPersistedTelemetry(withProvenance, onDisk); if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite(); adoptCustomModelCatalogMigration(config, withProvenance); + if (telemetry.carried) adoptPersistedTelemetry(config, telemetry.value); if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); clearPendingConfigDeletions(config); diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 2b5f34a73eb..77bc917fc09 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -1,6 +1,7 @@ import { observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions"; import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { configReasoningPinsConfigError } from "./provider-validation"; +import { carryPersistedTelemetry } from "./telemetry-carry"; import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration"; import { refreshPreservedProviderOwner } from "../usage/user-cost-overlays"; import { refreshConfigDerivedRegistries } from "./derived-registries"; @@ -636,15 +637,9 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { ); if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; - // Telemetry consent is owned by the file: the CLI and the consent route write it there while a - // proxy may hold an older snapshot. A settings save must carry the decision on disk, never drop - // or roll it back. - if (onDisk !== undefined) { - const diskTelemetry = (onDisk as Partial).telemetry; - if (diskTelemetry === undefined) delete projectedConfig.telemetry; - else projectedConfig.telemetry = structuredClone(diskTelemetry); - adoptPersistedTelemetry(config, diskTelemetry); - } + // Telemetry consent is owned by the file (see carryPersistedTelemetry). + const telemetry = carryPersistedTelemetry(projectedConfig, onDisk as Record | undefined); + if (telemetry.carried) adoptPersistedTelemetry(config, telemetry.value); const persistedBinding = (bindingBaseline || diskDeletedKeys.has("hostname")) && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline ?? { port: config.port, hostname: config.hostname }) : bindingBaseline; diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts new file mode 100644 index 00000000000..ba613037c97 --- /dev/null +++ b/src/config/telemetry-carry.ts @@ -0,0 +1,23 @@ +import type { OcxConfig } from "../types"; +import { telemetryConfigSchema } from "./schema/leaf-validators"; + +/** + * Telemetry consent is owned by `config.json` on disk: `ocx telemetry`, the start prompt and the + * consent route write it there, often while another process holds an older snapshot. A whole-config + * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load + * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a + * file on disk the snapshot's block is kept. + * + * Returns whether the block was taken from disk and the value now in `target`. + */ +export function carryPersistedTelemetry( + target: OcxConfig, + onDisk: Record | undefined, +): { carried: boolean; value: OcxConfig["telemetry"] } { + if (onDisk === undefined) return { carried: false, value: target.telemetry }; + const parsed = onDisk.telemetry === undefined ? undefined : telemetryConfigSchema.safeParse(onDisk.telemetry); + const value = parsed?.success ? (structuredClone(parsed.data) as OcxConfig["telemetry"]) : undefined; + if (value === undefined) delete target.telemetry; + else target.telemetry = value; + return { carried: true, value }; +} diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 1ba32b455e0..2577af42387 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -21,6 +21,7 @@ import { latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState, + readTelemetryStateFile, recordPendingTelemetryDecision, resolveNextEligibleTelemetryDay, withPendingTelemetryDecision, @@ -62,8 +63,12 @@ export async function buildTelemetryStatusSnapshot( const configDir = options.configDir ?? getConfigDir(); const status = resolveTelemetryStatus(withPendingTelemetryDecision(config, configDir), env); const installChannel = resolveInstallChannel({ env }); - const state = readTelemetryState(configDir); - const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); + const stateFile = readTelemetryStateFile(configDir); + const state = stateFile.state; + // An unreadable state file blocks every send (state-unreadable), so nothing is eligible to preview. + const { day: nextEligibleDay } = stateFile.unreadable + ? { day: null } + : resolveNextEligibleTelemetryDay(status, state, now); const previewEligible = nextEligibleDay !== null; const day = nextEligibleDay ?? latestCompletedUtcDay(now); const aggregate = await previewAggregate(day, configDir, now, options.cachePreview === true); diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index e945bcc4a40..03dac3fb322 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -152,6 +152,16 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { return readTelemetryStateFile(configDir).state; } +/** + * Read the state for a rewrite. A file that exists but cannot be read may have held opt-out days + * or sent days, so the rewrite starts from "nothing through today may be sent". + */ +function readTelemetryStateForWrite(configDir: string, now: number): TelemetryState { + const { state, unreadable } = readTelemetryStateFile(configDir); + if (unreadable) extendIneligibleThroughToday(state, now); + return state; +} + export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { mkdirSync(configDir, { recursive: true, mode: 0o700 }); const path = telemetryStatePath(configDir); @@ -182,7 +192,7 @@ export function recordPendingTelemetryDecision( configDir = getConfigDir(), now = Date.now(), ): void { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); state.pendingDecision = structuredClone(decision); writeTelemetryState(state, configDir, now); } @@ -232,7 +242,7 @@ function extendIneligibleThroughToday(state: TelemetryState, now: number): void * passes its pid as `owner`; a one-off observation (a CLI command, a tick) passes none. */ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): void { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); extendIneligibleThroughToday(state, now); if (owner === undefined) { state.envOptOutActiveSince ??= new Date(now).toISOString(); @@ -251,7 +261,7 @@ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date. * `ineligibleThroughDay` to today. Returns true while an opted-out proxy is still running. */ export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): boolean { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); const recorded = state.envOptOutOwners ?? []; const others = owner === undefined ? recorded : recorded.filter(entry => entry.pid !== owner); const running = others.filter(entry => isProcessAlive(entry.pid)); diff --git a/structure/telemetry.md b/structure/telemetry.md index e44ee3af428..e38ab86028d 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -49,6 +49,11 @@ Only a human can transition an undecided install to active sending: `telemetry-state.json` instead of creating the config, so `ocx init` still runs setup. `withPendingTelemetryDecision` applies it wherever consent is resolved (scheduler, status route, CLI prompt) until a decision is written into the config, which clears it. +- Telemetry consent in `config.json` is owned by the file: `carryPersistedTelemetry` + (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and + `saveConfigPreservingClaudeCode`) 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. ## Identity, aggregation, and scheduling diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index c41aaccec16..330e01b7e6e 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -59,3 +59,50 @@ test("a live save never rolls a newer decline on disk back to the accept it load expect(saved.telemetry).toEqual(declined); expect(live.telemetry).toEqual(declined); }); + +test("a live save drops a malformed telemetry block instead of adopting it", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed()); + const live = loadConfig(); + armClaudeCodeBaseline(live); + // A hand edit leaves an invalid block on disk; a load would drop it, and so must a save. + const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + raw.telemetry = { decision: "nope", extra: 1 }; + writeFileSync(getConfigPath(), JSON.stringify(raw, null, 2) + "\n", "utf8"); + live.logLevel = "debug"; + saveConfigPreservingClaudeCode(live); + expect(live.telemetry).toBeUndefined(); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(validateConfigCandidate(live).ok).toBe(true); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); + +test("a whole-config save from a stale snapshot keeps a newer decline on disk", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed(accepted)); + const stale = loadConfig(); + // Another process (`ocx telemetry disable`) declines between this load and save. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(declined); + return { changed: true, value: null }; + }).status).toBe("committed"); + stale.logLevel = "debug"; + saveConfig(stale); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + expect(stale.telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 1e3db899269..017648cbadf 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -36,12 +36,14 @@ import { telemetryIdPath, } from "../../src/telemetry/install-id"; import { buildTelemetryPayloadV1, normalizeTelemetryVersion, resolveInstallChannel } from "../../src/telemetry/payload"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; import { computeTelemetryIntervalDelayMs, computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, endTelemetryEnvOptOut, readTelemetryState, + recordPendingTelemetryDecision, recordTelemetryEnvOptOut, runTelemetryTick, startTelemetryScheduler, @@ -864,6 +866,14 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); expect(await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: mockFetch })).toEqual({ sent: false, reason: "state-unreadable" }); expect(fetchCalls).toBe(0); + // The status shows nothing eligible while every tick refuses to send. + expect((await buildTelemetryStatusSnapshot(config, { configDir: home, env: {}, now: NOW })).previewEligible).toBe(false); + // A writer that replaces the unreadable file keeps every day through today unsent. + recordTelemetryEnvOptOut(home, NOW); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + recordPendingTelemetryDecision({ decision: "declined", decidedAt: "2026-11-07T10:00:00.000Z", noticeShownAt: "2026-11-07T10:00:00.000Z" }, home, NOW); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); test("a telemetry-id.json deleted by another process is not served from memory", () => { From 12d45a3f7ed503019459f5e40b52da20cf72c58a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:48:47 +0900 Subject: [PATCH 20/29] fix(telemetry): never overwrite an unreadable state file after a send, and never let a stale save accept over an unread config From the round-4 reviews of #8300 (all P3; the first three reproduced): - The pre-send recheck reads strictly and refuses when telemetry-state.json has become unreadable; the bookkeeping writes after a send or a Retry-After leave such a file alone. Before, they rewrote it from an empty state, and a later tick sent days covered by an env opt-out. - A whole-config save over a config.json that exists but cannot be read keeps only a snapshot decline; any other block is dropped, so a stale accept can no longer replace an unread decline. - A proxy running under an env opt-out records itself again every 6 hours, so a state file rebuilt after corruption (which forgets envOptOutOwners) learns it again. - The status snapshot reports stateUnreadable, and ocx telemetry status says sending is paused. Failing-first tests in tests/usage/usage-telemetry-client.test.ts and tests/config/telemetry-live-save.test.ts. --- src/cli/telemetry.ts | 1 + src/config/telemetry-carry.ts | 22 +++++- src/server/background-lifecycle.ts | 22 ++++++ src/telemetry/routes.ts | 3 + src/telemetry/scheduler.ts | 44 ++++++++--- structure/telemetry.md | 10 ++- tests/config/telemetry-live-save.test.ts | 32 ++++++++ tests/usage/usage-telemetry-client.test.ts | 90 +++++++++++++++++++++- 8 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 78579f7abfd..65269dccbbb 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -165,6 +165,7 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, + ...(snapshot.stateUnreadable ? ["Sending paused: telemetry-state.json cannot be read, so nothing is sent."] : []), `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, snapshot.previewEligible ? `Preview (${snapshot.preview.day}, next eligible send):` diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts index ba613037c97..44a749368cc 100644 --- a/src/config/telemetry-carry.ts +++ b/src/config/telemetry-carry.ts @@ -1,4 +1,6 @@ +import { existsSync } from "node:fs"; import type { OcxConfig } from "../types"; +import { getConfigPath } from "./paths"; import { telemetryConfigSchema } from "./schema/leaf-validators"; /** @@ -6,7 +8,9 @@ import { telemetryConfigSchema } from "./schema/leaf-validators"; * consent route write it there, often while another process holds an older snapshot. A whole-config * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a - * file on disk the snapshot's block is kept. + * file on disk the snapshot's block is kept. A file that exists but cannot be read may hold a + * decline the snapshot never saw, so only a decline survives from the snapshot; anything else is + * dropped and the install waits for the notice again. * * Returns whether the block was taken from disk and the value now in `target`. */ @@ -14,10 +18,24 @@ export function carryPersistedTelemetry( target: OcxConfig, onDisk: Record | undefined, ): { carried: boolean; value: OcxConfig["telemetry"] } { - if (onDisk === undefined) return { carried: false, value: target.telemetry }; + if (onDisk === undefined) { + if (!configFileExists()) return { carried: false, value: target.telemetry }; + if (target.telemetry?.decision === "declined") return { carried: true, value: target.telemetry }; + delete target.telemetry; + return { carried: true, value: undefined }; + } const parsed = onDisk.telemetry === undefined ? undefined : telemetryConfigSchema.safeParse(onDisk.telemetry); const value = parsed?.success ? (structuredClone(parsed.data) as OcxConfig["telemetry"]) : undefined; if (value === undefined) delete target.telemetry; else target.telemetry = value; return { carried: true, value }; } + +function configFileExists(): boolean { + try { + return existsSync(getConfigPath()); + } catch { + // Cannot tell: treat it as present, so an unread decision is not overwritten. + return true; + } +} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 549ad5e406f..f273fe957d8 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -67,6 +67,22 @@ let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; +/** + * How often a proxy running under an env opt-out records itself again, so a telemetry-state.json + * rebuilt after corruption (which forgets every owner) learns that this proxy is still opted out. + */ +const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; +let telemetryEnvOptOutRefreshMsOverride: number | null = null; +let telemetryEnvOptOutRefresh: ReturnType | null = null; + +export function setTelemetryEnvOptOutRefreshMsForTests(ms: number | null): void { + telemetryEnvOptOutRefreshMsOverride = ms; +} + +function clearTelemetryEnvOptOutRefresh(): void { + if (telemetryEnvOptOutRefresh) clearInterval(telemetryEnvOptOutRefresh); + telemetryEnvOptOutRefresh = null; +} function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); @@ -145,12 +161,17 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { telemetryEnvOptOutObserved = true; // Recorded under this pid, so a proxy sharing the home cannot end the period while this one runs. try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } + telemetryEnvOptOutRefresh = setInterval(() => { + try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } + }, telemetryEnvOptOutRefreshMsOverride ?? TELEMETRY_ENV_OPT_OUT_REFRESH_MS); + telemetryEnvOptOutRefresh.unref?.(); } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { telemetryLoopGeneration += 1; stopTelemetryLoop?.(); stopTelemetryLoop = null; + clearTelemetryEnvOptOutRefresh(); memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); @@ -167,6 +188,7 @@ function stopProcessLoops(): void { const stopTelemetry = stopTelemetryLoop; stopTelemetryLoop = null; stopTelemetry?.(); + clearTelemetryEnvOptOutRefresh(); if (telemetryEnvOptOutObserved) { telemetryEnvOptOutObserved = false; try { endTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort; the next tick ends it once this pid has exited */ } diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 2577af42387..0007cfb3ff3 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -33,6 +33,8 @@ export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { lastSentAt: string | null; previewEligible: boolean; preview: TelemetryPayloadV1; + /** Present when telemetry-state.json exists but cannot be read: every send is refused until it can. */ + stateUnreadable?: true; } /** How long the dashboard's preview may reuse a completed day's aggregate (the panel polls). */ @@ -91,6 +93,7 @@ export async function buildTelemetryStatusSnapshot( lastSentAt: state.lastSentAt, previewEligible, preview, + ...(stateFile.unreadable ? { stateUnreadable: true as const } : {}), }; } diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index c56029ffed9..30582aaa548 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -177,7 +177,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom // Ends the period only once no opted-out proxy is still running; otherwise extends it to today. endTelemetryEnvOptOut(configDir, now); } - const state = readTelemetryState(configDir); + const current = readTelemetryStateFile(configDir); + if (current.unreadable) return { sent: false, reason: "state-unreadable" }; + const state = current.state; if (state.retryAfterUntil) { const retryUntilMs = Date.parse(state.retryAfterUntil); if (!Number.isNaN(retryUntilMs) && now < retryUntilMs) { @@ -226,7 +228,12 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom if (!preSendStatus.effective) { return { sent: false, day: targetDay, reason: preSendStatus.reason }; } - const preSendState = readTelemetryState(configDir); + // The file may have become unreadable while the day was aggregated; never send over it. + const preSendFile = readTelemetryStateFile(configDir); + if (preSendFile.unreadable) { + return { sent: false, day: targetDay, reason: "state-unreadable" }; + } + const preSendState = preSendFile.state; const recheckedEligibility = resolveNextEligibleTelemetryDay(preSendStatus, preSendState, now); if (recheckedEligibility.day !== targetDay) { return { sent: false, day: targetDay, reason: recheckedEligibility.reason }; @@ -253,22 +260,23 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, day: targetDay, reason: "aborted", sendResult }; } if (typeof sendResult.retryAfterMs === "number" && sendResult.retryAfterMs > 0) { - const freshState = readTelemetryState(configDir); - freshState.retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); - writeTelemetryState(freshState, configDir, now); + const retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); + updateReadableTelemetryState(configDir, now, state => { + state.retryAfterUntil = retryAfterUntil; + }); return { sent: false, day: targetDay, reason: "retry-after-backoff", sendResult }; } return { sent: false, day: targetDay, reason: "send-failed", sendResult }; } markTelemetryFirstReportSent(configDir); - const freshState = readTelemetryState(configDir); const nowIso = new Date(now).toISOString(); - freshState.sentDays[targetDay] = nowIso; - freshState.everReported = true; - freshState.lastSentDay = targetDay; - freshState.lastSentAt = nowIso; - freshState.retryAfterUntil = null; - writeTelemetryState(freshState, configDir, now); + updateReadableTelemetryState(configDir, now, state => { + state.sentDays[targetDay] = nowIso; + state.everReported = true; + state.lastSentDay = targetDay; + state.lastSentAt = nowIso; + state.retryAfterUntil = null; + }); return { sent: true, day: targetDay, reason: "sent", sendResult }; } finally { parentSignal?.removeEventListener("abort", onParentAbort); @@ -276,6 +284,18 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } } +/** + * Bookkeeping after a send. A file that became unreadable during the send is left as it is: rewriting + * it from an empty state would drop the opt-out days it held, and every later tick refuses to send + * until it can be read again. + */ +function updateReadableTelemetryState(configDir: string, now: number, update: (state: TelemetryState) => void): void { + const file = readTelemetryStateFile(configDir); + if (file.unreadable) return; + update(file.state); + writeTelemetryState(file.state, configDir, now); +} + let startupTimer: ReturnType | null = null; let intervalTimer: ReturnType | null = null; let nudgeTimer: ReturnType | null = null; diff --git a/structure/telemetry.md b/structure/telemetry.md index e38ab86028d..1e5d5066a6c 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -53,7 +53,9 @@ Only a human can transition an undecided install to active sending: (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and `saveConfigPreservingClaudeCode`) 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. + malformed hand edit is dropped as a load would drop it. A `config.json` that exists but cannot be + read may hold a decline the snapshot never saw, so only a snapshot decline survives a save over + it; any other block is dropped and the install waits for the notice again. ## Identity, aggregation, and scheduling @@ -83,7 +85,11 @@ day is eligible. `endTelemetryEnvOptOut` closes the period (ineligible through t 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) stops sending -(`state-unreadable`) rather than being read as empty. The state code lives in +(`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 rebuild it as ineligible through today, and the status snapshot reports +`stateUnreadable`. A rebuild forgets `envOptOutOwners`, so an opted-out proxy records itself again +every 6 hours. 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 sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 330e01b7e6e..33d812dd3c2 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -106,3 +106,35 @@ test("a whole-config save from a stale snapshot keeps a newer decline on disk", removeTreeWithRetry(home); } }); + +test("a save over an unreadable config.json never turns an unread decline back into an accept", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed(accepted)); + const stale = loadConfig(); + // Another process declines, then a hand edit breaks the file: the decline can no longer be read. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(declined); + return { changed: true, value: null }; + }).status).toBe("committed"); + writeFileSync(getConfigPath(), "{ broken", "utf8"); + stale.logLevel = "debug"; + saveConfig(stale); + // The stale accept is not written over a decision that could not be read; the install waits for the notice. + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(stale.telemetry).toBeUndefined(); + + // A snapshot that holds a decline keeps it. + const declinedSnapshot = loadConfig(); + declinedSnapshot.telemetry = structuredClone(declined); + writeFileSync(getConfigPath(), "{ broken", "utf8"); + saveConfig(declinedSnapshot); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 017648cbadf..b7afb062ba0 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -43,6 +43,7 @@ import { nudgeTelemetryScheduler, endTelemetryEnvOptOut, readTelemetryState, + readTelemetryStateFile, recordPendingTelemetryDecision, recordTelemetryEnvOptOut, runTelemetryTick, @@ -56,7 +57,7 @@ import { sendTelemetryPayload, setTelemetrySenderOverridesForTests, } from "../../src/telemetry/sender"; -import { acquireServerBackgroundLifecycle } from "../../src/server/background-lifecycle"; +import { acquireServerBackgroundLifecycle, setTelemetryEnvOptOutRefreshMsForTests } from "../../src/server/background-lifecycle"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -876,6 +877,93 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); + test("a state file that becomes unreadable during a send keeps its protection, so opt-out days are never sent", async () => { + const statePath = telemetryStatePath(home); + const sentDays: string[] = []; + let corruptOnSend = false; + let reply = () => new Response("{}", { status: 202 }); + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + if (corruptOnSend) writeFileSync(statePath, "{ not json", "utf8"); + return reply(); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const tickAt = (iso: string) => runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + + // An env opt-out covers every day through 2026-11-05. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-03T09:00:00.000Z")); + expect((await tickAt("2026-11-05T12:00:00.000Z")).sent).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); + + // A 429 arrives while the file is being corrupted: the backoff write must not replace it. + corruptOnSend = true; + reply = () => new Response("{}", { status: 429, headers: { "retry-after": "60" } }); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: false, reason: "retry-after-backoff" }); + expect(readTelemetryStateFile(home).unreadable).toBe(true); + + // A send that succeeds while the file is corrupted must not replace it either. + writeFileSync(statePath, JSON.stringify({ version: 1, sentDays: {}, ineligibleThroughDay: "2026-11-05" }), "utf8"); + reply = () => new Response("{}", { status: 202 }); + expect(await tickAt("2026-11-07T13:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(readTelemetryStateFile(home).unreadable).toBe(true); + // Every later tick refuses, so no day through 2026-11-05 is ever sent. + corruptOnSend = false; + expect(await tickAt("2026-11-09T12:00:00.000Z")).toEqual({ sent: false, reason: "state-unreadable" }); + expect(sentDays.every(day => day > "2026-11-05")).toBe(true); + // The status says why nothing is sent. + const status = await buildTelemetryStatusSnapshot(config, { configDir: home, env: {}, now: Date.parse("2026-11-09T12:00:00.000Z") }); + expect(status.stateUnreadable).toBe(true); + expect(status.previewEligible).toBe(false); + }); + + test("a state file that becomes unreadable before the pre-send check blocks the send", async () => { + let fetchCalls = 0; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const result = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-07T12:00:00.000Z"), + aggregateDay: async (day, opts) => { + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + return aggregateTelemetryForDay(day, opts); + }, + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: false, reason: "state-unreadable" }); + expect(fetchCalls).toBe(0); + }); + + test("D2: an opted-out proxy records itself again, so a state file rebuilt after corruption keeps it", async () => { + const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const prevTelemetry = process.env.OCX_TELEMETRY; + setTelemetryEnvOptOutRefreshMsForTests(25); + process.env.OCX_TELEMETRY = "0"; + let lease: Awaited> | undefined; + try { + lease = acquireServerBackgroundLifecycle(() => {}, proxyConfig); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + // The file is corrupted and rebuilt by a one-off observation that does not know the owner. + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + recordTelemetryEnvOptOut(home); + expect(readTelemetryState(home).envOptOutOwners ?? []).toEqual([]); + await new Promise(resolve => setTimeout(resolve, 120)); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + } finally { + await lease?.release(); + setTelemetryEnvOptOutRefreshMsForTests(null); + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + } + }); + test("a telemetry-id.json deleted by another process is not served from memory", () => { const { identity } = ensureTelemetryInstallId(home); expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); From 0a0ea7446218444ce1e4b87351a42a351ff2965a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:50:24 +0900 Subject: [PATCH 21/29] docs(privacy): name the per-day install hash in the privacy statement The payload carries installDayHash (sha256 of the install id and the day), which the dashboard popup lists; the README privacy line (and its seven copies) and the code-signing privacy section now name it too. readme/i18n-manifest.json records the resynced README hash. --- README.md | 2 +- .../src/content/docs/reference/code-signing.md | 18 +++++++++--------- .../content/docs/tr/reference/code-signing.md | 2 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++++++------- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0dbab6f40f5..145b51eb1d5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 61a75e9e001..6bb4feb7224 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -30,15 +30,15 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy -OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, -outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person -installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and -has not declined; it can be turned off in that notice and anytime with -`ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide -aggregates are public; otherwise data goes only where the user configures. In practice, -OpenCodex forwards requests to the providers you configure. To look for new versions it also -reads release metadata from GitHub and the npm registry, and those update checks carry none of -your prompts, credentials or usage data. +OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, +version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to +`telemetry.opencodex.me` only after the person installing or operating it has seen the notice +(dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that +notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy +details; project-wide aggregates are public; otherwise data goes only where the user configures. +In practice, OpenCodex forwards requests to the providers you configure. To look for new +versions it also reads release metadata from GitHub and the npm registry, and those update +checks carry none of your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 1c1f53667f0..97b10cb7e91 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -33,7 +33,7 @@ imzalanmasını yasaklar. OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra -`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum karması, sürüm, platform, sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy diff --git a/readme/README.fr.md b/readme/README.fr.md index ab4d40ccf78..6a3e6238560 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index bce4e0b3d0b..0b418532863 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index ff86a86a490..34be23a82e7 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 5c06c7b974e..04e28cad530 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index b9c34551830..c7714db0ad1 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 8647bf6f080..457a0795645 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 4a837326dfc..791299f09d4 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 3ace0af263d..c99ef3b9b27 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" } } } From 37d09e9090730642467c0f6f9c3bc9d3d1f06523 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:23:35 +0900 Subject: [PATCH 22/29] fix(config,telemetry): a save that creates config.json never introduces a telemetry decision From the final privacy review of #8300: - [P2] Without config.json, a whole-config save wrote the caller's in-memory telemetry block. A dashboard accept adopted into a config-less proxy, then "ocx telemetry disable" (held as the pending decision), then any settings save created config.json with the stale accept, and the config's decision outranks the pending one, so sending resumed. Without a readable config.json only a snapshot decline survives now; the held decision or the notice applies. - [P3] A recorded decision rebuilds an unreadable telemetry-state.json, which otherwise paused sending for good; the status line says how. Any rebuild keeps every day through the UTC day six hours from now unsent (TELEMETRY_ENV_OPT_OUT_REFRESH_MS), the window in which a forgotten opted-out proxy records itself again. - [P3] The CLI notice names the per-day install hash, release channel and first-report flag, and the privacy statement (README and its seven copies, code-signing en/tr) lists every field sent. Failing-first tests in tests/config/telemetry-live-save.test.ts and tests/usage/usage-telemetry-client.test.ts; the live-save tests now create a config holding a decision the way a person's decision reaches it. --- README.md | 2 +- .../content/docs/reference/code-signing.md | 15 +++--- .../content/docs/tr/reference/code-signing.md | 23 +++++---- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++--- src/cli/telemetry.ts | 6 ++- src/config/telemetry-carry.ts | 19 ++----- src/server/background-lifecycle.ts | 7 +-- src/telemetry/state.ts | 20 ++++++-- structure/telemetry.md | 13 +++-- tests/config/telemetry-live-save.test.ts | 50 +++++++++++++++++-- tests/usage/usage-telemetry-client.test.ts | 28 +++++++++++ 18 files changed, 142 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 145b51eb1d5..89fe809b41d 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 6bb4feb7224..5de0a2d2fb9 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -31,13 +31,14 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, -version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to -`telemetry.opencodex.me` only after the person installing or operating it has seen the notice -(dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that -notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy -details; project-wide aggregates are public; otherwise data goes only where the user configures. -In practice, OpenCodex forwards requests to the providers you configure. To look for new -versions it also reads release metadata from GitHub and the npm registry, and those update +version, release channel, platform, install channel, provider kind, coarse model family, active +minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or +paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the +notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in +that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or +Proxy details; project-wide aggregates are public; otherwise data goes only where the user +configures. In practice, OpenCodex forwards requests to the providers you configure. To look for +new versions it also reads release metadata from GitHub and the npm registry, and those update checks carry none of your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 97b10cb7e91..304305ab1d5 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -31,17 +31,18 @@ imzalanmasını yasaklar. ### Gizlilik -OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli -açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra -`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum karması, sürüm, platform, -sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya -yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz -zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy -details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun -dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada -OpenCodex, istekleri yapılandırdığınız sağlayıcılara iletir. Yeni sürümler için -GitHub ve npm kayıt deposundan yayın meta verilerini de okur; bu güncelleme -kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi taşımaz. +OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli açılır +penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum +karması, sürüm, sürüm kanalı, platform, kurulum kanalı, sağlayıcı türü, kaba model ailesi, etkin +dakikalar ve istek, jeton ve hata sayımları; istemler, çıktılar, anahtarlar, hesaplar, ana +bilgisayar adları veya dosya yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz +zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy details üzerinden +kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun dışında veriler yalnızca +kullanıcının yapılandırdığı yerlere gider. Uygulamada OpenCodex, istekleri yapılandırdığınız +sağlayıcılara iletir. Yeni sürümler için GitHub ve npm kayıt deposundan yayın meta verilerini de +okur; bu güncelleme kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi +taşımaz. ## İndirmeyi doğrulama diff --git a/readme/README.fr.md b/readme/README.fr.md index 6a3e6238560..50d81f0e0b9 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index 0b418532863..c007b9bf8d7 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 34be23a82e7..b9042de68e6 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 04e28cad530..63768e4a02f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index c7714db0ad1..1d7567f80c0 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 457a0795645..787dfecdf9e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 791299f09d4..e2d194a64fb 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index c99ef3b9b27..d8a22a78e1f 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" } } } diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 65269dccbbb..8cbc0be3d65 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -37,7 +37,7 @@ const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ " Anonymous daily usage telemetry:", - " • Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + " • Sent daily to telemetry.opencodex.me: a daily-changing install hash, version, release channel, OS/arch, install channel, first-report flag, provider kind, model family, active minutes, request/token/error counts.", " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", " • See the exact payload: ocx telemetry status --json", @@ -165,7 +165,9 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, - ...(snapshot.stateUnreadable ? ["Sending paused: telemetry-state.json cannot be read, so nothing is sent."] : []), + ...(snapshot.stateUnreadable + ? ["Sending paused: telemetry-state.json cannot be read. Choosing again (ocx telemetry enable or disable) rebuilds it."] + : []), `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, snapshot.previewEligible ? `Preview (${snapshot.preview.day}, next eligible send):` diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts index 44a749368cc..e70737c0fdd 100644 --- a/src/config/telemetry-carry.ts +++ b/src/config/telemetry-carry.ts @@ -1,6 +1,4 @@ -import { existsSync } from "node:fs"; import type { OcxConfig } from "../types"; -import { getConfigPath } from "./paths"; import { telemetryConfigSchema } from "./schema/leaf-validators"; /** @@ -8,9 +6,10 @@ import { telemetryConfigSchema } from "./schema/leaf-validators"; * consent route write it there, often while another process holds an older snapshot. A whole-config * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a - * file on disk the snapshot's block is kept. A file that exists but cannot be read may hold a - * decline the snapshot never saw, so only a decline survives from the snapshot; anything else is - * dropped and the install waits for the notice again. + * readable file, a whole-config save never introduces a decision: before `config.json` exists a + * decision is held in telemetry-state.json (and another process may have replaced it there), and a + * file that cannot be read may hold a decline the snapshot never saw. Only a snapshot decline + * survives; anything else is dropped, and the held decision or the notice applies. * * Returns whether the block was taken from disk and the value now in `target`. */ @@ -19,7 +18,6 @@ export function carryPersistedTelemetry( onDisk: Record | undefined, ): { carried: boolean; value: OcxConfig["telemetry"] } { if (onDisk === undefined) { - if (!configFileExists()) return { carried: false, value: target.telemetry }; if (target.telemetry?.decision === "declined") return { carried: true, value: target.telemetry }; delete target.telemetry; return { carried: true, value: undefined }; @@ -30,12 +28,3 @@ export function carryPersistedTelemetry( else target.telemetry = value; return { carried: true, value }; } - -function configFileExists(): boolean { - try { - return existsSync(getConfigPath()); - } catch { - // Cannot tell: treat it as present, so an unread decision is not overwritten. - return true; - } -} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index f273fe957d8..72d267bb150 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -35,7 +35,7 @@ import { import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; import { isTelemetryEnvOptOut } from "../telemetry/consent"; // The state module alone: the scheduler and its aggregation stay behind the dynamic import below. -import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; +import { TELEMETRY_ENV_OPT_OUT_REFRESH_MS, endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -67,11 +67,6 @@ let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; -/** - * How often a proxy running under an env opt-out records itself again, so a telemetry-state.json - * rebuilt after corruption (which forgets every owner) learns that this proxy is still opted out. - */ -const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; let telemetryEnvOptOutRefreshMsOverride: number | null = null; let telemetryEnvOptOutRefresh: ReturnType | null = null; diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 03dac3fb322..8b971b25a82 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -16,6 +16,11 @@ import { deleteTelemetryInstallId } from "./install-id"; export const TELEMETRY_STATE_FILE = "telemetry-state.json"; const DAY_MS = 86_400_000; const STATE_RETENTION_DAYS = 30; +/** + * How often a proxy running under an env opt-out records itself again, so a state file rebuilt after + * corruption (which forgets every owner) learns that the proxy is still opted out. + */ +export const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; /** A proxy that started under an env opt-out and has not stopped cleanly yet. */ export interface TelemetryEnvOptOutOwner { @@ -154,11 +159,12 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { /** * Read the state for a rewrite. A file that exists but cannot be read may have held opt-out days - * or sent days, so the rewrite starts from "nothing through today may be sent". + * or sent days and opted-out proxies that are still running, so the rewrite starts from "nothing + * may be sent through the UTC day those proxies have recorded themselves again by". */ function readTelemetryStateForWrite(configDir: string, now: number): TelemetryState { const { state, unreadable } = readTelemetryStateFile(configDir); - if (unreadable) extendIneligibleThroughToday(state, now); + if (unreadable) extendIneligibleThroughToday(state, now + TELEMETRY_ENV_OPT_OUT_REFRESH_MS); return state; } @@ -197,10 +203,14 @@ export function recordPendingTelemetryDecision( writeTelemetryState(state, configDir, now); } -/** Drop the pending decision once a decision is written into `config.json`. */ +/** + * Drop the pending decision once a decision is written into `config.json`. A person's decision also + * rebuilds an unreadable state file, which otherwise pauses sending for good. + */ export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.pendingDecision) return; + const { state: current, unreadable } = readTelemetryStateFile(configDir); + if (!unreadable && !current.pendingDecision) return; + const state = unreadable ? readTelemetryStateForWrite(configDir, now) : current; state.pendingDecision = null; writeTelemetryState(state, configDir, now); } diff --git a/structure/telemetry.md b/structure/telemetry.md index 1e5d5066a6c..1ce71aa72e5 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -53,9 +53,11 @@ Only a human can transition an undecided install to active sending: (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and `saveConfigPreservingClaudeCode`) 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. A `config.json` that exists but cannot be - read may hold a decline the snapshot never saw, so only a snapshot decline survives a save over - it; any other block is dropped and the install waits for the notice again. + malformed hand edit is dropped as a load would drop it. Without a readable `config.json` a save + never introduces a decision: before the file exists the decision is held in + `telemetry-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. ## Identity, aggregation, and scheduling @@ -87,9 +89,10 @@ which ends it only once no recorded owner pid is still alive and otherwise exten `telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed) 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 rebuild it as ineligible through today, and the status snapshot reports +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. The state code lives in +every 6 hours (`TELEMETRY_ENV_OPT_OUT_REFRESH_MS`), within that window. 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 sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 33d812dd3c2..903eafde817 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -3,6 +3,8 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { recordPendingTelemetryDecision, withPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -22,12 +24,26 @@ const seed = (telemetry?: OcxTelemetryConfig): OcxConfig => ({ const declined: OcxTelemetryConfig = { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }; const accepted: OcxTelemetryConfig = { decision: "accepted", decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }; +/** + * A whole-config save never introduces a telemetry decision, so a config holding one is created the + * way a person's decision reaches it: the file first, then the decision through mutatePersistedConfig. + */ +function createConfig(config: OcxConfig): void { + const { telemetry, ...rest } = config; + saveConfig(rest as OcxConfig); + if (!telemetry) return; + expect(mutatePersistedConfig(persisted => { + persisted.telemetry = structuredClone(telemetry); + return { changed: true, value: null }; + }).status).toBe("committed"); +} + function liveSaveAfterDiskDecision(initial: OcxConfig, onDisk: OcxTelemetryConfig): { live: OcxConfig; saved: OcxConfig } { const oldHome = process.env.OPENCODEX_HOME; const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(initial); + createConfig(initial); const live = loadConfig(); armClaudeCodeBaseline(live); // A CLI process records a decision while the proxy keeps its older in-memory config. @@ -89,7 +105,7 @@ test("a whole-config save from a stale snapshot keeps a newer decline on disk", const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(seed(accepted)); + createConfig(seed(accepted)); const stale = loadConfig(); // Another process (`ocx telemetry disable`) declines between this load and save. expect(mutatePersistedConfig(config => { @@ -112,7 +128,7 @@ test("a save over an unreadable config.json never turns an unread decline back i const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(seed(accepted)); + createConfig(seed(accepted)); const stale = loadConfig(); // Another process declines, then a hand edit breaks the file: the decline can no longer be read. expect(mutatePersistedConfig(config => { @@ -138,3 +154,31 @@ test("a save over an unreadable config.json never turns an unread decline back i removeTreeWithRetry(home); } }); + +test("a save that creates config.json never turns a pending decline into an accept", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + // A proxy runs without config.json and adopted a dashboard accept (held as the pending decision) ... + const live = { ...seed(), telemetry: structuredClone(accepted) } as OcxConfig; + recordPendingTelemetryDecision(accepted, home); + // ... then "ocx telemetry disable" replaces the pending decision with a decline. + recordPendingTelemetryDecision(declined, home); + // A settings save from that proxy creates config.json. + saveConfig(live); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(live.telemetry).toBeUndefined(); + expect(resolveTelemetryStatus(withPendingTelemetryDecision(loadConfig(), home), {})).toMatchObject({ decision: "declined", effective: false }); + + // A snapshot that holds a decline still creates the file with it. + removeTreeWithRetry(getConfigPath()); + const declinedSnapshot = { ...seed(), telemetry: structuredClone(declined) } as OcxConfig; + saveConfig(declinedSnapshot); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index b7afb062ba0..95e57598b4d 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -58,6 +58,7 @@ import { setTelemetrySenderOverridesForTests, } from "../../src/telemetry/sender"; import { acquireServerBackgroundLifecycle, setTelemetryEnvOptOutRefreshMsForTests } from "../../src/server/background-lifecycle"; +import { clearPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -941,6 +942,33 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(fetchCalls).toBe(0); }); + test("a person's decision rebuilds an unreadable state file, keeping every day through the next owner refresh unsent", async () => { + const statePath = telemetryStatePath(home); + writeFileSync(statePath, "{ broken", "utf8"); + // Recorded late in the day: an opted-out proxy forgotten by the rebuild records itself again + // within 6 hours, so the rebuild covers the next UTC day too. + clearPendingTelemetryDecision(home, Date.parse("2026-11-07T21:00:00.000Z")); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-08"); + // Sending resumes for later days instead of staying paused forever. + const sentDays: string[] = []; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const result = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-10T12:00:00.000Z"), + fetch: (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: true, day: "2026-11-09" }); + expect(sentDays).toEqual(["2026-11-09"]); + }); + test("D2: an opted-out proxy records itself again, so a state file rebuilt after corruption keeps it", async () => { const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; const prevTelemetry = process.env.OCX_TELEMETRY; From d10d52849b2e5fc26433c417793a8ae36b185358 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:28:40 +0900 Subject: [PATCH 23/29] ci: approve the skill-surface generator change in the release evidence policy Layer 2 adds the telemetry command root to scripts/generate-ocx-skill-surface.ts, a file dev pins in .github/scripts/release-evidence-policy.json since #8345. Following the procedure in release-evidence.cjs, a new ci v1 entry copies the latest one with only that file's blob updated (git hash-object --no-filters: 750ea21e -> 155e8666); older entries stay for historical revisions. node .github/scripts/release-evidence.cjs policy-check passes, and the gates step's release tests pass 68/68. --- .github/scripts/release-evidence-policy.json | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/scripts/release-evidence-policy.json b/.github/scripts/release-evidence-policy.json index 88c10c6ccbf..333ca4c278b 100644 --- a/.github/scripts/release-evidence-policy.json +++ b/.github/scripts/release-evidence-policy.json @@ -134,5 +134,37 @@ "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "562e63d6bd140944d8c6ba808010913463c75826", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "155e866656ba2d71e8fd48adda857451caf16322", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } } ] From eec104730a17a8feb7f85bf2774a9ebd1b1626d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:33:05 +0900 Subject: [PATCH 24/29] fix(telemetry): read consent only from the telemetry block, and fail closed on untrustworthy state - [P2] resolveTelemetryStatus treated a whole config without a telemetry key as the telemetry block, so "ocx config set noticeShownAt " (allowed for agents; only telemetry.* is refused) turned sending on for an undecided install without anyone seeing the notice. Reproduced end to end with the CLI under CI=1: status went awaiting-notice -> default-on. Consent is now read only from config.telemetry, and the parameter type no longer accepts a bare block. - A telemetry-state.json lstat error other than ENOENT, or an opt-out field that is present but invalid (ineligibleThroughDay, envOptOutActiveSince, envOptOutOwners), marks the file unreadable instead of reading it as empty. - The rebuild of an unreadable state file during a decision is best effort, so a decision that is already saved never ends in an error (a state path that is a directory threw EISDIR). - The sender refuses redirects, so a report never goes to another host. Failing-first tests in tests/usage/usage-telemetry-client.test.ts (4 failed before the fix). --- src/telemetry/consent.ts | 19 ++--- src/telemetry/sender.ts | 2 + src/telemetry/state.ts | 56 ++++++++++---- structure/telemetry.md | 4 +- tests/usage/usage-telemetry-client.test.ts | 86 ++++++++++++++++++++-- 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts index 5461028ba51..bb5eaec8a59 100644 --- a/src/telemetry/consent.ts +++ b/src/telemetry/consent.ts @@ -22,17 +22,18 @@ export interface TelemetryConsentStatus { const OCX_TELEMETRY_OFF_VALUES = new Set(["0", "false", "off", "no"]); const DO_NOT_TRACK_ON_VALUES = new Set(["1", "true", "yes"]); +/** + * Consent lives only in the config's `telemetry` block. A whole config is never read as a block, so + * a top-level key such as `noticeShownAt` (which `ocx config set` can write) is not consent. + */ function extractTelemetryConfig( - input: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + input: { telemetry?: OcxTelemetryConfig | null } | null | undefined, ): OcxTelemetryConfig | undefined { if (!input || typeof input !== "object") return undefined; - if ("telemetry" in input) { - const nested = (input as { telemetry?: unknown }).telemetry; - return nested && typeof nested === "object" && !Array.isArray(nested) - ? (nested as OcxTelemetryConfig) - : undefined; - } - return input as OcxTelemetryConfig; + const nested = (input as { telemetry?: unknown }).telemetry; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as OcxTelemetryConfig) + : undefined; } function validIsoString(value: unknown): string | null { @@ -55,7 +56,7 @@ export function isTelemetryEnvOptOut(env: Record = p } export function resolveTelemetryStatus( - config: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + config: { telemetry?: OcxTelemetryConfig | null } | null | undefined, env: Record = process.env, ): TelemetryConsentStatus { const telemetry = extractTelemetryConfig(config); diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts index cc42909a08b..b7da1743182 100644 --- a/src/telemetry/sender.ts +++ b/src/telemetry/sender.ts @@ -67,6 +67,8 @@ export async function sendTelemetryPayload( "content-type": "application/json", }, body: JSON.stringify(validated.payload), + // The collector URL is a constant; a redirect would send the report to another host. + redirect: "error", signal: deadline.signal, }); if (!response.ok) { diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 8b971b25a82..d78a573acc4 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -89,6 +89,14 @@ function parseOwners(raw: unknown): TelemetryEnvOptOutOwner[] { return owners; } +function ownersAreValid(raw: unknown): boolean { + return Array.isArray(raw) && raw.length <= 64 && raw.every(entry => { + if (!entry || typeof entry !== "object") return false; + const { pid, since } = entry as Record; + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 && typeof since === "string" && !Number.isNaN(Date.parse(since)); + }); +} + function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const value = raw as Record; @@ -102,15 +110,17 @@ function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { /** * Read the state, noting whether a file exists but cannot be trusted (a symlink, oversized or - * malformed). Such a file must not be read as empty: that would forget opt-out days. + * malformed, or with an opt-out field that is present but invalid). Such a file must not be read as + * empty: that would forget opt-out days. Only a file that does not exist reads as empty. */ export function readTelemetryStateFile(configDir = getConfigDir()): { state: TelemetryState; unreadable: boolean } { const path = telemetryStatePath(configDir); let stat; try { stat = lstatSync(path); - } catch { - return { state: emptyState(), unreadable: false }; + } catch (error) { + const missing = (error as NodeJS.ErrnoException)?.code === "ENOENT"; + return { state: emptyState(), unreadable: !missing }; } try { if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return { state: emptyState(), unreadable: true }; @@ -124,18 +134,25 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel sentDays[day] = at; } } - const ineligibleThroughDay = - typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null - ? raw.ineligibleThroughDay - : null; + // Opt-out fields may be absent or null; present but invalid means the file cannot be trusted. + const present = (value: unknown) => value !== undefined && value !== null; + if (present(raw.ineligibleThroughDay) + && !(typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null)) { + return { state: emptyState(), unreadable: true }; + } + if (present(raw.envOptOutActiveSince) + && !(typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)))) { + return { state: emptyState(), unreadable: true }; + } + if (present(raw.envOptOutOwners) && !ownersAreValid(raw.envOptOutOwners)) { + return { state: emptyState(), unreadable: true }; + } + const ineligibleThroughDay = typeof raw.ineligibleThroughDay === "string" ? raw.ineligibleThroughDay : null; const retryAfterUntil = typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) ? raw.retryAfterUntil : null; - const envOptOutActiveSince = - typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) - ? raw.envOptOutActiveSince - : null; + const envOptOutActiveSince = typeof raw.envOptOutActiveSince === "string" ? raw.envOptOutActiveSince : null; return { unreadable: false, state: { version: 1, sentDays, @@ -209,10 +226,19 @@ export function recordPendingTelemetryDecision( */ export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { const { state: current, unreadable } = readTelemetryStateFile(configDir); - if (!unreadable && !current.pendingDecision) return; - const state = unreadable ? readTelemetryStateForWrite(configDir, now) : current; - state.pendingDecision = null; - writeTelemetryState(state, configDir, now); + if (unreadable) { + // Best effort: the decision is already saved. If the rebuild fails, the file stays unreadable, + // which keeps sending blocked. + try { + const rebuilt = readTelemetryStateForWrite(configDir, now); + rebuilt.pendingDecision = null; + writeTelemetryState(rebuilt, configDir, now); + } catch { /* keep the unreadable file */ } + return; + } + if (!current.pendingDecision) return; + current.pendingDecision = null; + writeTelemetryState(current, configDir, now); } /** diff --git a/structure/telemetry.md b/structure/telemetry.md index 1ce71aa72e5..66a7e8f9632 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -24,7 +24,9 @@ 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`). +(`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. Only a human can transition an undecided install to active sending: diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 95e57598b4d..128c4cc95a4 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -180,13 +180,13 @@ describe("consent status matrix and config schema wiring", () => { envOverride: null, }); - expect(resolveTelemetryStatus({ noticeShownAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { noticeShownAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ effective: true, reason: "default-on", decision: null, }); - expect(resolveTelemetryStatus({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ effective: true, reason: "accepted", decision: "accepted", @@ -194,7 +194,7 @@ describe("consent status matrix and config schema wiring", () => { expect( resolveTelemetryStatus( - { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" }, + { telemetry: { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" } }, {}, ), ).toMatchObject({ @@ -205,7 +205,7 @@ describe("consent status matrix and config schema wiring", () => { for (const val of ["0", "false", "off", "no", " FALSE "]) { expect( - resolveTelemetryStatus({ decision: "accepted" }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), + resolveTelemetryStatus({ telemetry: { decision: "accepted" } }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), ).toMatchObject({ effective: false, reason: "env-ocx-telemetry", @@ -214,7 +214,7 @@ describe("consent status matrix and config schema wiring", () => { } for (const val of ["1", "true", "yes", " TRUE "]) { - expect(resolveTelemetryStatus({ decision: "accepted" }, { DO_NOT_TRACK: val })).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { decision: "accepted" } }, { DO_NOT_TRACK: val })).toMatchObject({ effective: false, reason: "env-do-not-track", envOverride: "DO_NOT_TRACK", @@ -222,6 +222,30 @@ describe("consent status matrix and config schema wiring", () => { } }); + test("consent is read only from the telemetry block, never from top-level config keys", async () => { + // `ocx config set noticeShownAt …` writes a top-level key; it must not count as the notice. + const topLevel = { noticeShownAt: "2026-01-01T00:00:00.000Z", decision: "accepted", decidedAt: "2026-01-01T00:00:00.000Z" }; + expect(resolveTelemetryStatus(topLevel as never, {})).toMatchObject({ effective: false, reason: "awaiting-notice", decision: null }); + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-toplevel-")); + try { + let fetchCalls = 0; + const result = await runTelemetryTick({ + configDir: home, + readConfig: () => topLevel as never, + env: {}, + now: Date.parse("2026-11-07T12:00:00.000Z"), + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: false, reason: "awaiting-notice" }); + expect(fetchCalls).toBe(0); + } finally { + removeTreeWithRetry(home); + } + }); + test("config diagnostics, load-degrade, and live-reconcile handle telemetry config", () => { expect(telemetryConfigError({ telemetry: { decision: "accepted" } })).toBeNull(); expect(telemetryConfigError({ telemetry: { endpoint: "https://evil.example" } })).toContain("schema_invalid: telemetry"); @@ -992,6 +1016,38 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("a state file that cannot be inspected, or holds malformed opt-out fields, blocks sending", () => { + const statePath = telemetryStatePath(home); + const base = { version: 1, sentDays: {} }; + for (const bad of [ + { ...base, ineligibleThroughDay: "not-a-day" }, + { ...base, envOptOutActiveSince: "not-a-time" }, + { ...base, envOptOutOwners: "not-a-list" }, + { ...base, envOptOutOwners: [{ pid: "1", since: "2026-11-01T00:00:00.000Z" }] }, + ]) { + writeFileSync(statePath, JSON.stringify(bad), "utf8"); + expect(readTelemetryStateFile(home).unreadable, JSON.stringify(bad)).toBe(true); + } + // Absent or null fields are legitimate. + writeFileSync(statePath, JSON.stringify({ ...base, ineligibleThroughDay: null, envOptOutActiveSince: null, envOptOutOwners: [] }), "utf8"); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + // An lstat failure other than "not found" is not read as a missing file. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + chmodSync(home, 0o000); + try { + expect(readTelemetryStateFile(home).unreadable).toBe(true); + } finally { + chmodSync(home, 0o700); + } + } + }); + + test("a decision over a state path that cannot be rewritten still completes", () => { + const statePath = telemetryStatePath(home); + mkdirSync(statePath); + expect(() => clearPendingTelemetryDecision(home, NOW)).not.toThrow(); + }); + test("a telemetry-id.json deleted by another process is not served from memory", () => { const { identity } = ensureTelemetryInstallId(home); expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); @@ -1221,6 +1277,24 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("sendTelemetryPayload refuses redirects, so a report never goes to another host", async () => { + const payload = buildTelemetryPayloadV1({ + aggregate: aggregateTelemetryEntriesForDay("2026-11-06", []), + installDayHash: "c".repeat(64), + newInstall: false, + now: NOW, + }); + let redirect: RequestRedirect | undefined; + await sendTelemetryPayload(payload, { + fetch: (async (_url: string | URL | Request, init?: RequestInit) => { + redirect = init?.redirect; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + now: NOW, + }); + expect(redirect).toBe("error"); + }); + test("sendTelemetryPayload enforces its timeout and does not retry", async () => { const agg = aggregateTelemetryEntriesForDay("2026-11-06", []); const payload = buildTelemetryPayloadV1({ From 25633e9c7abeeea37edc082005ea365e99533c8c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:45:50 +0900 Subject: [PATCH 25/29] fix(telemetry): keep a held decision out of a newly created config.json A dashboard accept on a fresh install is held in telemetry-state.json, but the route also copied it into the running config. After a later `ocx telemetry disable`, initializePersistedConfigIfMissing (reached by PUT /api/subagent-models and account selection) wrote that stale accept into the new config.json, which outranks the held decline. Creating config.json now runs carryPersistedTelemetry with no file, so only an in-memory decline survives, and the route no longer adopts a decision it only held as pending. Recording an opted-out owner also drops dead pids and stays within the 64 entries a reader accepts. --- src/config.ts | 6 ++++ src/telemetry/routes.ts | 4 ++- src/telemetry/state.ts | 6 +++- structure/telemetry.md | 4 ++- tests/config/telemetry-live-save.test.ts | 27 +++++++++++++++++- tests/server/server-telemetry-routes.test.ts | 30 +++++++++++++++++++- tests/usage/usage-telemetry-client.test.ts | 9 ++++++ 7 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3742fd4e383..03d40608984 100644 --- a/src/config.ts +++ b/src/config.ts @@ -350,12 +350,17 @@ export function initializePersistedConfigIfMissing( const before = observeInitialConfigState(); if (before !== "missing") return before; let published = false; + let telemetry: ReturnType | undefined; try { const persisted = withConfigMutationLockSync((): OcxConfig | "exists" | "invalid" => { const current = observeInitialConfigState(); if (current !== "missing") return current; const projected = projectCustomModelCatalogMigration(undefined, projectConfigRebaseProvenance(config)); normalizeAccountAutoPauseRevisions(undefined, projected); + // Creating config.json never introduces a telemetry decision: one made before the file existed + // is held in telemetry-state.json, where another process may have replaced it (see + // carryPersistedTelemetry). Only an in-memory decline survives. + telemetry = carryPersistedTelemetry(projected, undefined); if (!validateConfigCandidate(projected).ok) throw new Error("Initial configuration is invalid."); if (!publishInitialConfigNoReplace(getConfigPath(), JSON.stringify(projected, null, 2) + "\n", io)) { return observeInitialConfigState() === "exists" ? "exists" : "invalid"; @@ -368,6 +373,7 @@ export function initializePersistedConfigIfMissing( }); if (typeof persisted === "string") return persisted; adoptCustomModelCatalogMigration(config, persisted); + if (telemetry?.carried) adoptPersistedTelemetry(config, telemetry.value); if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); clearPendingConfigDeletions(config); diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 0007cfb3ff3..7f64747d33c 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -171,7 +171,9 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise entry.pid !== owner); + // Drop owners that have exited (their days through today are covered above) and stay within the + // 64 entries a reader accepts. + const owners = (state.envOptOutOwners ?? []) + .filter(entry => entry.pid !== owner && isProcessAlive(entry.pid)) + .slice(-63); state.envOptOutOwners = [...owners, { pid: owner, since: new Date(now).toISOString() }]; } writeTelemetryState(state, configDir, now); diff --git a/structure/telemetry.md b/structure/telemetry.md index 66a7e8f9632..87c3b61cc1a 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -59,7 +59,9 @@ Only a human can transition an undecided install to active sending: never introduces a decision: before the file exists the decision is held in `telemetry-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. + dropped, and the held decision or the notice applies. The same rule covers + `initializePersistedConfigIfMissing`, which creates `config.json` from the running config, and the + consent route never copies a decision it only held as pending into the running config. ## Identity, aggregation, and scheduling diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 903eafde817..4896d87f126 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; +import { armClaudeCodeBaseline, getConfigPath, initializePersistedConfigIfMissing, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { recordPendingTelemetryDecision, withPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; @@ -182,3 +182,28 @@ test("a save that creates config.json never turns a pending decline into an acce removeTreeWithRetry(home); } }); + +test("creating config.json from an in-memory config never introduces a telemetry decision", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + // A running proxy holds an accept in memory while the held decision on disk is a decline. + recordPendingTelemetryDecision(declined, home); + const live = { ...seed(), telemetry: structuredClone(accepted) } as OcxConfig; + expect(initializePersistedConfigIfMissing(live)).toBe("created"); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(live.telemetry).toBeUndefined(); + expect(resolveTelemetryStatus(withPendingTelemetryDecision(loadConfig(), home), {})).toMatchObject({ decision: "declined", effective: false }); + + // An in-memory decline is kept. + removeTreeWithRetry(getConfigPath()); + const declinedLive = { ...seed(), telemetry: structuredClone(declined) } as OcxConfig; + expect(initializePersistedConfigIfMissing(declinedLive)).toBe("created"); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 986d751bed0..3ba727f4866 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -7,7 +7,7 @@ import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; -import { latestCompletedUtcDay, readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { latestCompletedUtcDay, readTelemetryState, recordPendingTelemetryDecision, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -257,6 +257,34 @@ describe("/api/telemetry management routes", () => { expect(status.body).toMatchObject({ effective: false, decision: "declined" }); }); + test("a decline held before config.json exists survives a management request that creates config.json", async () => { + // Fresh install: the dashboard popup accepts before any config.json exists ... + unlinkSync(join(home, "config.json")); + config = { + port: 10100, + logLevel: "info", + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example.test" } }, + } as unknown as OcxConfig; + const accepted = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(accepted.status).toBe(200); + // ... the held decision is not copied into the running proxy's config ... + expect(config.telemetry).toBeUndefined(); + // ... then "ocx telemetry disable" replaces the held decision with a decline. + recordPendingTelemetryDecision({ decision: "declined", decidedAt: "2026-11-08T12:00:00.000Z", noticeShownAt: "2026-11-08T12:00:00.000Z" }, home); + // An admin-token request that creates config.json from the in-memory config. + const created = await call("PUT", "/api/subagent-models", { body: { force: null } }); + expect(created.status).toBe(200); + expect(existsSync(join(home, "config.json"))).toBe(true); + expect((JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); + test("GET /api/telemetry reports a decision written to disk while the server's config is stale", async () => { writeFileSync(join(home, "config.json"), JSON.stringify({ ...config, diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 128c4cc95a4..5d11d8f2c3e 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1042,6 +1042,15 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("recording an opted-out owner drops dead pids and keeps the list within the readable limit", () => { + const since = "2026-11-01T00:00:00.000Z"; + const dead = Array.from({ length: 64 }, (_, i) => ({ pid: 2_000_000_000 + i, since })); + writeTelemetryState({ ...readTelemetryState(home), envOptOutOwners: dead }, home); + recordTelemetryEnvOptOut(home, NOW, process.pid); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + }); + test("a decision over a state path that cannot be rewritten still completes", () => { const statePath = telemetryStatePath(home); mkdirSync(statePath); From f5a8ae01da287ebb679a7f398a5745f5d746164b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:48:39 +0900 Subject: [PATCH 26/29] fix(telemetry): fail closed on a malformed held decision A pendingDecision that is present but invalid was silently dropped, so a damaged held decline could let a notice-only config send under default-on consent. A held decline with a damaged timestamp now still applies (dated by noticeShownAt or the file time), and any other malformed held decision makes telemetry-state.json unreadable, which blocks sending until a decision rebuilds it. --- src/telemetry/state.ts | 19 ++++++++++++++----- structure/telemetry.md | 6 ++++-- tests/usage/usage-telemetry-client.test.ts | 8 +++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 66ecb1f9120..9baee7140ba 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -97,14 +97,21 @@ function ownersAreValid(raw: unknown): boolean { }); } -function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; +/** A held decision that is present but cannot be trusted returns "invalid", which blocks sending. */ +function parsePendingDecision(raw: unknown, fileTime: Date): OcxTelemetryConfig | null | "invalid" { + if (raw === undefined || raw === null) return null; + if (typeof raw !== "object" || Array.isArray(raw)) return "invalid"; const value = raw as Record; - if (value.decision !== "accepted" && value.decision !== "declined") return null; + if (value.decision !== "accepted" && value.decision !== "declined") return "invalid"; const stamp = (key: string): string | null => typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; const decidedAt = stamp("decidedAt"); - if (!decidedAt) return null; + if (!decidedAt) { + // A decline needs no date to apply; dropping it could let a notice-only config send. + if (value.decision !== "declined") return "invalid"; + const at = stamp("noticeShownAt") ?? fileTime.toISOString(); + return { decision: "declined", decidedAt: at, noticeShownAt: at }; + } return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; } @@ -147,6 +154,8 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel if (present(raw.envOptOutOwners) && !ownersAreValid(raw.envOptOutOwners)) { return { state: emptyState(), unreadable: true }; } + const pendingDecision = parsePendingDecision(raw.pendingDecision, stat.mtime); + if (pendingDecision === "invalid") return { state: emptyState(), unreadable: true }; const ineligibleThroughDay = typeof raw.ineligibleThroughDay === "string" ? raw.ineligibleThroughDay : null; const retryAfterUntil = typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) @@ -162,7 +171,7 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel retryAfterUntil, envOptOutActiveSince, envOptOutOwners: parseOwners(raw.envOptOutOwners), - pendingDecision: parsePendingDecision(raw.pendingDecision), + pendingDecision, everReported: raw.everReported === true, } }; } catch { diff --git a/structure/telemetry.md b/structure/telemetry.md index 87c3b61cc1a..cb2762b1c27 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -90,13 +90,15 @@ once and runs no ticks, so the marker or owner stands for the whole period: whil 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) stops sending +`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. The state code lives in +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 sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 5d11d8f2c3e..85fc988d47c 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1016,7 +1016,7 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); - test("a state file that cannot be inspected, or holds malformed opt-out fields, blocks sending", () => { + test("a state file that cannot be inspected, or holds malformed opt-out fields or a malformed held decision, blocks sending", () => { const statePath = telemetryStatePath(home); const base = { version: 1, sentDays: {} }; for (const bad of [ @@ -1024,6 +1024,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => { ...base, envOptOutActiveSince: "not-a-time" }, { ...base, envOptOutOwners: "not-a-list" }, { ...base, envOptOutOwners: [{ pid: "1", since: "2026-11-01T00:00:00.000Z" }] }, + { ...base, pendingDecision: "declined" }, + { ...base, pendingDecision: { decision: "no", decidedAt: "2026-11-01T00:00:00.000Z" } }, + { ...base, pendingDecision: { decision: "accepted", decidedAt: "not-a-time" } }, ]) { writeFileSync(statePath, JSON.stringify(bad), "utf8"); expect(readTelemetryStateFile(home).unreadable, JSON.stringify(bad)).toBe(true); @@ -1031,6 +1034,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => // Absent or null fields are legitimate. writeFileSync(statePath, JSON.stringify({ ...base, ineligibleThroughDay: null, envOptOutActiveSince: null, envOptOutOwners: [] }), "utf8"); expect(readTelemetryStateFile(home).unreadable).toBe(false); + // A held decline with a damaged timestamp still applies. + writeFileSync(statePath, JSON.stringify({ ...base, pendingDecision: { decision: "declined", decidedAt: "not-a-time" } }), "utf8"); + expect(readTelemetryStateFile(home)).toMatchObject({ unreadable: false, state: { pendingDecision: { decision: "declined" } } }); // An lstat failure other than "not found" is not read as a missing file. if (process.platform !== "win32" && process.getuid?.() !== 0) { chmodSync(home, 0o000); From e367ca6a4c3525c7721585bda89499d26cf4528e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 00:26:27 +0900 Subject: [PATCH 27/29] docs(structure): map the telemetry consent, carry and lifecycle files to telemetry.md --- structure/INDEX.md | 3 +++ structure/manifest.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/structure/INDEX.md b/structure/INDEX.md index 4fc13713fa7..61a5af4fcfb 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -336,6 +336,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/config/schema/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/config/schema/config-schema.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/config/schema/leaf-validators.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`pricing.md`](pricing.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | +| `src/config/telemetry-carry.ts` | [`telemetry.md`](telemetry.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | | `src/grok/` | [`runtime.md`](runtime.md) | @@ -427,6 +428,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/audio-dictation.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/audio-live.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | +| `src/server/background-lifecycle.ts` | [`telemetry.md`](telemetry.md) | | `src/server/capture-admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/server/claude-messages.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md)
[`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/context-history.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | @@ -447,6 +449,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | +| `src/server/management/consent-session.ts` | [`telemetry.md`](telemetry.md) | | `src/server/management/link-enrollment.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 8bc7cb5cb4e..f0f32d948c4 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1438,7 +1438,10 @@ "src/telemetry/", "src/types/telemetry.ts", "src/server/management/telemetry-routes.ts", - "src/cli/telemetry.ts" + "src/cli/telemetry.ts", + "src/config/telemetry-carry.ts", + "src/server/management/consent-session.ts", + "src/server/background-lifecycle.ts" ] } ], From e27855ead29eacab813b5c74ebfac83892bb738f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:30:31 +0900 Subject: [PATCH 28/29] ci: retrigger on current dev From d2b4a5df2440970f70d9c0f31de2a1782f3a3ef5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:41:19 +0900 Subject: [PATCH 29/29] docs(skill): regenerate the surface counts after merging dev --- skills/ocx/references/01_management_surface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index a381c0615f7..960a85b1068 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1553,6 +1553,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 373 -- of those, state-changing: 230 +- declared capabilities: 376 +- of those, state-changing: 232 - head-resolved invocations: 2