diff --git a/.github/scripts/release-evidence-policy.json b/.github/scripts/release-evidence-policy.json index e82ef837c5d..3239ed69e6e 100644 --- a/.github/scripts/release-evidence-policy.json +++ b/.github/scripts/release-evidence-policy.json @@ -206,5 +206,37 @@ ".github/workflows/service-lifecycle.yml": "87249bf4d19ce47a6eafa59703fd8e52453cc924", ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d" } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "dc5496ad7e6a146a94fbe8dfeaa3b73461feafbc", + ".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" + } } ] diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 0bab328853d..3f8cd3e3afc 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,17 @@ 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` and `ocx telemetry enable` refuse agent-driven callers with +`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 An agent asked to install, inspect, repair, or remove opencodex needs to know what is written diff --git a/README.md b/README.md index 263079fb129..3b1cd17750e 100644 --- a/README.md +++ b/README.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 (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/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/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/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index a85a1b381cf..5de0a2d2fb9 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -30,11 +30,16 @@ 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 (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. 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..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,10 +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 +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 fcb05695ccf..9d7b7d526b6 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.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 (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 ba4b91408b5..e528a6a985a 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.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 (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 b631de84ad8..fbd6d2b1768 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -479,7 +479,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 (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 c1ac691fe02..2db01d2971c 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -508,7 +508,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 (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 216c2d0f570..28f074d6b40 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -504,7 +504,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 (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 13d4c9317a1..71bc7f80e73 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -477,7 +477,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 (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 7843c589bf6..3acac389a62 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -476,7 +476,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 (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 cb937aa02f5..f10a3cbb2c7 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": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" } } } 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/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 625abd760dd..3618339e4d8 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -11,5 +11,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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ebf714ed3e3..1b976a1d2a2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -780,7 +780,7 @@ "config-save-boundary.test.ts": "config", "config-thinking-display.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/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..97b2c18f044 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 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. +- 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` + +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. | + +- 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` + +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..2be0edcea69 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 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.", "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"], + 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: ["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"], + 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..9ff69f6b0df 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -258,6 +258,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 @@ -331,14 +334,25 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { - readCliConfigForMutation(); - const imported = validate(loadInput(path)); + const current = readCliConfigForMutation(); + const rawInput = loadInput(path); + if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + delete (rawInput as Record).telemetry; + console.warn("Note: ignored telemetry block during config import; use `ocx telemetry` to manage telemetry consent."); + } + const imported = validate(rawInput); withConfigMutationLockSync(() => { const client = readClientConnectionState(); if (client.kind === "connected" && (imported.runtimeRole !== "client" || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } + 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/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..592d6c766fd 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] 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.", + ], + }, ]; 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..8cbc0be3d65 --- /dev/null +++ b/src/cli/telemetry.ts @@ -0,0 +1,255 @@ +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"; +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"; +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 const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ + " Anonymous daily usage telemetry:", + " • 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", + " • 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 { + 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()); + 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; +} + +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(); + const mutate = () => + 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 }; + }); + 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. 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); + } +} + +/** + * 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(withPendingTelemetryDecision(diagnostics.config), process.env); + if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { + return; + } + const isTty = isInteractiveTty(); + const dir = getConfigDir(); + if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { + const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); + let record: string | null = null; + try { + 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; + 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); + atomicWriteFileNoFollow(deferralPath, `${new Date().toISOString()} ${version}\n`); + } catch { /* best-effort */ } + return; + } + printTelemetryDisclosure(); + 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"}`, + ...(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):` + : `Preview (${snapshot.preview.day}, sample only — not eligible to send):`, + 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") { + 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")) { + 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); + 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."]); + 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/config.ts b/src/config.ts index 609326e2587..03d40608984 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 { @@ -348,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"; @@ -366,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); @@ -384,12 +392,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/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..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"; @@ -118,6 +119,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. @@ -623,6 +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 (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/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 3838c5a949e..4c741ce47e3 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, @@ -122,6 +123,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 eefc16d8ee4..e893371c980 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1194,3 +1194,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/config/telemetry-carry.ts b/src/config/telemetry-carry.ts new file mode 100644 index 00000000000..e70737c0fdd --- /dev/null +++ b/src/config/telemetry-carry.ts @@ -0,0 +1,30 @@ +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 + * 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`. + */ +export function carryPersistedTelemetry( + target: OcxConfig, + onDisk: Record | undefined, +): { carried: boolean; value: OcxConfig["telemetry"] } { + if (onDisk === undefined) { + 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 }; +} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8f51e2c9fcf..72d267bb150 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,6 +33,9 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; 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 { TELEMETRY_ENV_OPT_OUT_REFRESH_MS, endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -60,6 +63,21 @@ export type ServerBackgroundLifecycleLease = { const owners: LeaseOwner[] = []; 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; +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); @@ -124,8 +142,31 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next poll tick retries. }); + const generation = ++telemetryLoopGeneration; + if (isTelemetryEnvOptOut(process.env) === null) { + void import("../telemetry/scheduler") + .then(scheduler => { + if (generation !== telemetryLoopGeneration) return; + stopTelemetryLoop = scheduler.startTelemetryScheduler(); + }) + .catch(() => { + // Optional background loop. + }); + } else { + 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(); @@ -138,6 +179,15 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { } function stopProcessLoops(): void { + telemetryLoopGeneration += 1; + 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 */ } + } const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/server/management-api.ts b/src/server/management-api.ts index dc206ce4e2e..b397ab53934 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, @@ -395,6 +401,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..5f1f20f7a11 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -451,6 +451,9 @@ 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." } }, // --- 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..834a17af9e9 --- /dev/null +++ b/src/server/management/telemetry-routes.ts @@ -0,0 +1,16 @@ +import type { ManagementContext } from "./context"; +import { + handleGetTelemetryRoute, + 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); + } + return null; +} diff --git a/src/telemetry/aggregate.ts b/src/telemetry/aggregate.ts new file mode 100644 index 00000000000..e3fd5cff469 --- /dev/null +++ b/src/telemetry/aggregate.ts @@ -0,0 +1,348 @@ +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 { usageDisplayTotalTokens } from "../usage/totals"; +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|[po][0-9a-f]{6}|k[0-9a-f]{32})$/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); + const displayTotal = nonNegInt(usageDisplayTotalTokens(usage)); + const accounted = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens; + if (displayTotal > accounted) { + inputTokens += displayTotal - accounted; + } + return { + inputTokens, + cacheReadTokens, + cacheWriteTokens, + outputTokens, + reasoningTokens, + }; +} + +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}`); + } + 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; + }; + + const onEntry = (entry: PersistedUsageEntry): void => { + if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { + return; + } + 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); + } + }; + + 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; + } + models = kept; + } + + 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 accumulator = createDailyTelemetryAccumulator(day); + await scanUsageLedgerCooperatively({ + signal: options.signal, + onEntry: accumulator.onEntry, + }); + return accumulator.finalize(); +} diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts new file mode 100644 index 00000000000..bb5eaec8a59 --- /dev/null +++ b/src/telemetry/consent.ts @@ -0,0 +1,128 @@ +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"]); + +/** + * 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: { telemetry?: OcxTelemetryConfig | null } | null | undefined, +): OcxTelemetryConfig | undefined { + if (!input || typeof input !== "object") return undefined; + 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 { + 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: { telemetry?: OcxTelemetryConfig | null } | 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..20d23d1dcc5 --- /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{8}(\.\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..977b68cc8c7 --- /dev/null +++ b/src/telemetry/install-id.ts @@ -0,0 +1,226 @@ +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 { atomicWriteFileNoFollow } from "../config/atomic-write"; +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; + 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; +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, + firstReportSent: raw.firstReportSent === true, + }; + } 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); + try { + const stat = lstatSync(path); + 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")); + } 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(), + firstReportSent: false, + }; + 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 }; +} + +/** 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()) { + unlinkSync(path); + deleted = true; + } + } catch (error) { + if (errorCode(error) !== "ENOENT") deleted = false; + } + return deleted; +} + +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/routes.ts b/src/telemetry/routes.ts new file mode 100644 index 00000000000..7f64747d33c --- /dev/null +++ b/src/telemetry/routes.ts @@ -0,0 +1,183 @@ +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, type DailyTelemetryAggregate } from "./aggregate"; +import { resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; +import type { TelemetryInstallChannel, TelemetryPayloadV1 } from "./contract"; +import { + TELEMETRY_PREVIEW_HASH_PLACEHOLDER, + computeInstallDayHash, + deleteTelemetryInstallId, + readTelemetryInstallId, +} from "./install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel } from "./payload"; +import { + clearPendingTelemetryDecision, + latestCompletedUtcDay, + nudgeTelemetryScheduler, + readTelemetryState, + readTelemetryStateFile, + recordPendingTelemetryDecision, + resolveNextEligibleTelemetryDay, + withPendingTelemetryDecision, +} from "./scheduler"; + +export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { + installChannel: TelemetryInstallChannel; + lastSentDay: string | null; + 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). */ +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; cachePreview?: boolean } = {}, +): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const status = resolveTelemetryStatus(withPendingTelemetryDecision(config, configDir), env); + const installChannel = resolveInstallChannel({ env }); + 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); + const identity = readTelemetryInstallId(configDir); + const installDayHash = identity + ? computeInstallDayHash(identity.installId, day) + : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; + const newInstall = state.everReported !== true && identity?.firstReportSent !== true; + const preview = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + installChannel, + now, + }); + return { + ...status, + installChannel, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + previewEligible, + preview, + ...(stateFile.unreadable ? { stateUnreadable: true as const } : {}), + }; +} + +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 { + // 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); +} + +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" && outcome.reason === "missing") { + // A fresh install's first-run popup can answer before any config.json exists; hold the + // decision so creating a config here does not make `ocx init` skip setup. + committed = { decision, decidedAt: nowIso, noticeShownAt: nowIso }; + recordPendingTelemetryDecision(committed); + } else if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } else { + clearPendingTelemetryDecision(); + } + // A decision held before config.json exists stays out of the running config: a later write that + // creates config.json from it must not carry a decision another process may since have replaced. + adoptPersistedTelemetry(ctx.config, outcome.status === "unavailable" ? undefined : committed); + if (decision === "declined") { + deleteTelemetryInstallId(); + } + 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 new file mode 100644 index 00000000000..30582aaa548 --- /dev/null +++ b/src/telemetry/scheduler.ts @@ -0,0 +1,386 @@ +import { readConfigDiagnostics } from "../config/diagnostics"; +import { getConfigDir } from "../config/paths"; +import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; +import type { OcxConfig } from "../types"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; +import { + computeInstallDayHash, + 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_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; + +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). + * - 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( + status: TelemetryConsentStatus, + state: TelemetryState, + now = Date.now(), +): { day: string | null; reason: string } { + if (!status.effective) { + return { day: null, reason: status.reason }; + } + if (state.envOptOutActiveSince || (state.envOptOutOwners?.length ?? 0) > 0) { + return { day: null, reason: "env-opt-out-pending" }; + } + 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, options?: { signal?: AbortSignal }) => Promise; +} + +export interface TelemetryTickResult { + sent: boolean; + day?: string; + reason: string; + 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`. + * - 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 configDir = options.configDir ?? getConfigDir(); + const readCurrentEnv = (): Record => options.env ?? process.env; + const readRawConfig = (): 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; + }; + // 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) { + recordTelemetryEnvOptOut(configDir, now); + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); + return { sent: false, reason: status.reason }; + } + + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); + if (!status.effective) { + return { sent: false, reason: status.reason }; + } + + 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 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) { + return { sent: false, reason: "retry-after-backoff" }; + } + } + + const { day: targetDay, reason: eligibilityReason } = resolveNextEligibleTelemetryDay(status, state, now); + if (!targetDay) { + return { sent: false, reason: eligibilityReason }; + } + + 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 }; + } + // 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 }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + // 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, + 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 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 nowIso = new Date(now).toISOString(); + 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); + activeTickControllers.delete(tickController); + } +} + +/** + * 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; +let detachShutdownHook: (() => void) | null = null; +let activeTick: Promise | null = null; +let running = false; + +function triggerBackgroundTick(): void { + if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } + stopTelemetryScheduler(); + return; + } + if (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) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } + return () => {}; + } + if (running) return stopTelemetryScheduler; + running = true; + detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); + const startupDelay = options.startupDelayMs ?? computeTelemetryStartupDelayMs(); + const intervalMs = options.intervalMs ?? computeTelemetryIntervalDelayMs(); + startupTimer = setTimeout(() => { + startupTimer = null; + triggerBackgroundTick(); + }, startupDelay); + startupTimer.unref?.(); + intervalTimer = setInterval(() => { + triggerBackgroundTick(); + }, intervalMs); + intervalTimer.unref?.(); + return stopTelemetryScheduler; +} + +export function stopTelemetryScheduler(): void { + running = false; + abortActiveTicks("Telemetry scheduler stopped"); + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } + if (detachShutdownHook) { + const detach = detachShutdownHook; + detachShutdownHook = null; + detach(); + } +} + +export function nudgeTelemetryScheduler(): void { + abortActiveTicks("Telemetry consent changed"); + if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } + stopTelemetryScheduler(); + return; + } + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } + nudgeTimer = setTimeout(() => { + nudgeTimer = null; + triggerBackgroundTick(); + }, 10); + nudgeTimer.unref?.(); +} diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts new file mode 100644 index 00000000000..b7da1743182 --- /dev/null +++ b/src/telemetry/sender.ts @@ -0,0 +1,97 @@ +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 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; + fetch?: typeof fetch; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +} + +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; + +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), + // The collector URL is a constant; a redirect would send the report to another host. + redirect: "error", + 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 }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + deadline.cleanup(); + } +} diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts new file mode 100644 index 00000000000..9baee7140ba --- /dev/null +++ b/src/telemetry/state.ts @@ -0,0 +1,334 @@ +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; +/** + * 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 { + 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 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)); + }); +} + +/** 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 "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) { + // 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 }; +} + +/** + * Read the state, noting whether a file exists but cannot be trusted (a symlink, oversized or + * 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 (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 }; + 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; + } + } + // 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 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)) + ? raw.retryAfterUntil + : null; + const envOptOutActiveSince = typeof raw.envOptOutActiveSince === "string" ? 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, + everReported: raw.everReported === true, + } }; + } catch { + return { state: emptyState(), unreadable: true }; + } +} + +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 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 + TELEMETRY_ENV_OPT_OUT_REFRESH_MS); + return 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 = readTelemetryStateForWrite(configDir, now); + state.pendingDecision = structuredClone(decision); + writeTelemetryState(state, configDir, now); +} + +/** + * 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: current, unreadable } = readTelemetryStateFile(configDir); + 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); +} + +/** + * 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 = readTelemetryStateForWrite(configDir, now); + extendIneligibleThroughToday(state, now); + if (owner === undefined) { + state.envOptOutActiveSince ??= new Date(now).toISOString(); + } else { + // 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); + 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 = 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)); + 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/src/types.ts b/src/types.ts index 1c7d579f3a7..9498a7921c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,3 +148,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/structure/INDEX.md b/structure/INDEX.md index ace8d12f872..c9f7a7525f8 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 @@ -258,6 +259,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) | @@ -335,6 +337,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 +430,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) | @@ -448,11 +452,13 @@ 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) | | `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) | @@ -512,6 +518,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) | @@ -519,6 +526,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 2f4a6f16fbb..92daa00a6eb 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1431,6 +1431,21 @@ "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", + "src/config/telemetry-carry.ts", + "src/server/management/consent-session.ts", + "src/server/background-lifecycle.ts" + ] } ], "contracts": { diff --git a/structure/telemetry.md b/structure/telemetry.md new file mode 100644 index 00000000000..cb2762b1c27 --- /dev/null +++ b/structure/telemetry.md @@ -0,0 +1,106 @@ +# 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, 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 + +`src/types/telemetry.ts` defines `OcxTelemetryConfig` (`decision`, `decidedAt`, `noticeShownAt`), +validated in `src/config/schema/leaf-validators.ts`, `src/config/schema/config-schema.ts`, +`src/config/diagnostics.ts`, `src/config/load-degrade.ts`, and adopted into live server state +via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +`src/telemetry/consent.ts` (`resolveTelemetryStatus`) evaluates precedence in order: +`OCX_TELEMETRY` (`0`, `false`, `off`, `no`), `DO_NOT_TRACK` (`1`, `true`, `yes`), +`decision === "declined"`, `decision === "accepted"`, undecided with `noticeShownAt` +(`default-on`), and undecided without notice (`awaiting-notice`). It reads only the config's +`telemetry` block: a whole config is never treated as a block, so a top-level `noticeShownAt` or +`decision` (which `ocx config set` can write) is not consent. + +Only a human can transition an undecided install to active sending: + +- `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`) + 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). 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 + `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`. +- 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. +- 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. 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. 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 + +`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 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, 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` and parses `Retry-After` on HTTP 429/503 responses. +`src/telemetry/scheduler.ts` runs an unref'd background timer with startup and interval jitter +started from `src/server/background-lifecycle.ts`. Whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` +opt-out is observed (at startup in `src/server/background-lifecycle.ts` or on a tick), +`recordTelemetryEnvOptOut` persists `ineligibleThroughDay` (today's UTC day) and the +`envOptOutActiveSince` marker in `telemetry-state.json` and deletes `telemetry-id.json`; a proxy +started under the opt-out records its pid in `envOptOutOwners` instead. Such a proxy reads the env +once and runs no ticks, so the marker or owner stands for the whole period: while either is set no +day is eligible. `endTelemetryEnvOptOut` closes the period (ineligible through the closing day) on +that proxy's clean stop, which always covers its stop day, or on the first tick without the opt-out, +which ends it only once no recorded owner pid is still alive and otherwise extends it to today. A +`telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed, or with an +opt-out field or held decision that is present but invalid) stops sending +(`state-unreadable`) rather than being read as empty: the tick checks it before choosing a day and +again just before sending, the bookkeeping writes after a send or a `Retry-After` leave such a file +alone, other writers (including every recorded decision, so sending does not stay paused) rebuild +it as ineligible through the UTC day six hours from now, and the status snapshot reports +`stateUnreadable`. A rebuild forgets `envOptOutOwners`, so an opted-out proxy records itself again +every 6 hours (`TELEMETRY_ENV_OPT_OUT_REFRESH_MS`), within that window. A held decline whose +timestamp is damaged still applies. The state code lives in +`src/telemetry/state.ts`, which `background-lifecycle.ts` imports statically; the scheduler, its +aggregation and sender stay behind the dynamic import. Each tick 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/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..ee3f36415de --- /dev/null +++ b/tests/cli/cli-telemetry.test.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +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"; +import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; +import { + TELEMETRY_CLI_DISCLOSURE_LINES, + TELEMETRY_DEFERRAL_FILE, + handleTelemetryCommand, + maybeShowTelemetryPrompt, + setTelemetryPromptDepsForTests, +} from "../../src/cli/telemetry"; +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, + 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 = [ + "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); + setStarPromptDepsForTests(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 { + // 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({ + 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: + 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); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } + } finally { + delete process.env.OCX_SERVICE; + log.mockRestore(); + err.mockRestore(); + } + }); + + 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(() => {}); + try { + expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); + expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); + + const importAccepted = join(home, "imported-accepted.json"); + writeFileSync( + importAccepted, + 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", + ); + // 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"; + 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"); + + // 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"); + + 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, + 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(); + 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 { + // 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(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: + log.mockClear(); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // 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"); + expect(typeof telemetry?.noticeShownAt).toBe("string"); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).telemetry.decision).toBe("accepted"); + } finally { + log.mockRestore(); + } + }); + + 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 (or a privacy-minded user) declines before any config exists. + unlinkSync(join(home, "config.json")); + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + // 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 { + 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(() => {}); + 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()"); + 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/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts new file mode 100644 index 00000000000..4896d87f126 --- /dev/null +++ b/tests/config/telemetry-live-save.test.ts @@ -0,0 +1,209 @@ +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, 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"; +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" }; + +/** + * 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 { + createConfig(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); +}); + +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 { + createConfig(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); + } +}); + +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 { + 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 => { + 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); + } +}); + +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); + } +}); + +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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6240bf32f63..a38aefe9d1f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -175,6 +175,7 @@ "model-costs-prompt-length-pricing.test.ts": "server", "cli-models-prompt-length-pricing.test.ts": "cli", "usage-prompt-length-pricing.test.ts": "usage", + "telemetry-live-save.test.ts": "config", "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", "codex-use-remaining-quota.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", 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()) { diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts new file mode 100644 index 00000000000..3ba727f4866 --- /dev/null +++ b/tests/server/server-telemetry-routes.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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"; +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, recordPendingTelemetryDecision, runTelemetryTick } from "../../src/telemetry/scheduler"; +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, + previewEligible: false, + }); + 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("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"], + ] 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"); + } + expect(config.telemetry).toBeUndefined(); + + 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" }, + }); + 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 () => { + 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 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", + 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); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } + }); + + 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" }); + }); + + 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, + 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 new file mode 100644 index 00000000000..85fc988d47c --- /dev/null +++ b/tests/usage/usage-telemetry-client.test.ts @@ -0,0 +1,1354 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; +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"; +import { adoptPersistedTelemetry, armClaudeCodeBaseline } from "../../src/config/live-reconcile"; +import { malformedTelemetryWarning } from "../../src/config/load-degrade"; +import { + aggregateTelemetryEntriesForDay, + aggregateTelemetryForDay, + classifyModelFamily, + classifyProviderKind, + createDailyTelemetryAccumulator, + 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, normalizeTelemetryVersion, resolveInstallChannel } from "../../src/telemetry/payload"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; +import { + computeTelemetryIntervalDelayMs, + computeTelemetryStartupDelayMs, + nudgeTelemetryScheduler, + endTelemetryEnvOptOut, + readTelemetryState, + readTelemetryStateFile, + recordPendingTelemetryDecision, + recordTelemetryEnvOptOut, + runTelemetryTick, + startTelemetryScheduler, + stopTelemetryScheduler, + telemetryStatePath, + writeTelemetryState, +} from "../../src/telemetry/scheduler"; +import { + parseRetryAfterMs, + sendTelemetryPayload, + 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"; +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"); + +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"); + 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 }); + 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); + 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({ telemetry: { noticeShownAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ + effective: true, + reason: "default-on", + decision: null, + }); + + expect(resolveTelemetryStatus({ telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ + effective: true, + reason: "accepted", + decision: "accepted", + }); + + expect( + resolveTelemetryStatus( + { telemetry: { 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({ telemetry: { 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({ telemetry: { decision: "accepted" } }, { DO_NOT_TRACK: val })).toMatchObject({ + effective: false, + reason: "env-do-not-track", + envOverride: "DO_NOT_TRACK", + }); + } + }); + + 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"); + 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); + + // 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", () => { + 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"); + 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("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) => { + 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. + // 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-04T15:30:00.000Z", + noticeShownAt: "2026-11-04T15: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"); + + // 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); + } + } + + // 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. 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, + env: {}, + now: Date.parse("2026-11-06T12:00:00.000Z"), + 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); + + const day7Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + 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-07"); + 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"); + }); + + 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, 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); + 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("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("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("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); + // 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 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("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; + 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 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 [ + { ...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" }] }, + { ...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); + } + // 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); + try { + expect(readTelemetryStateFile(home).unreadable).toBe(true); + } finally { + chmodSync(home, 0o700); + } + } + }); + + 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); + 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); + 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 () => { + 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", () => { + 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 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({ + 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(); + }); +});