From b9ca17478ce3032bdbcfa7387b0c365d63b9e2cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 22:52:29 +0900 Subject: [PATCH 01/63] docs(devlog): plan opt-in telemetry and the opencodex.me usage dashboard Roadmap unit for a six-layer stack: client telemetry core, GUI consent popup, Cloudflare Worker collector, telemetry guide, and the public /dashboard page. Docs only. --- .../261107_telemetry_dashboard/000_plan.md | 93 +++++++++ .../010_client_core.md | 179 ++++++++++++++++++ .../020_gui_consent.md | 55 ++++++ .../030_collector.md | 80 ++++++++ .../040_docs_privacy.md | 31 +++ .../050_dashboard.md | 43 +++++ 6 files changed, 481 insertions(+) create mode 100644 devlog/_plan/261107_telemetry_dashboard/000_plan.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/010_client_core.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/030_collector.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/050_dashboard.md diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md new file mode 100644 index 00000000000..e694f22d51a --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -0,0 +1,93 @@ +# Opt-in telemetry and the opencodex.me usage dashboard + +## Objective and scope + +Add anonymous, opt-in product telemetry to the opencodex proxy and a public, project-wide +analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform +pings, active time, request and token totals, and error counts; a collector stores daily +aggregates; the dashboard renders installs, active time, usage, errors and downloads. + +Delivery is a manual chain of stacked pull requests, one per work phase below, each based on the +previous layer and the bottom on `dev`. No deploy of the collector, no DNS change, no merge and no +release are part of this unit; those are maintainer actions after review. + +The visual and structural reference is [junhoyeo/tokscale](https://github.com/junhoyeo/tokscale) +(MIT): its contribution heatmap, KPI header, client/model breakdown tables, and the +validated aggregate submit API. Patterns are adapted, not vendored; credit lives in the dashboard +footer and in the PR descriptions. + +## Owner decisions (2026-11-07) + +| Id | Decision | +| --- | --- | +| hosting | Collector is a Cloudflare Worker with D1 at `telemetry.opencodex.me`. The repository ships its code, schema and tests; deployment is a maintainer step. | +| consent | Superseded by default-on. | +| update-popup | An install that updates and has no recorded decision sees the same consent popup once after the update. | +| default-on | Telemetry defaults to **on** (opt-out). The GUI first-run/post-update popup (checkbox pre-checked) and the first interactive `ocx start` (Y/n, default yes) let the user choose. `ocx init` and `ocx service install` ask the same question. An agent-driven start prints a notice and never answers for the user or records the notice. No payload is sent until a person has seen the disclosure. Advanced settings and `ocx telemetry disable` turn it off later. | +| scope | The dashboard is public and project-wide (aggregates only). No per-user profiles or leaderboards. | + +## Privacy contract (applies to every layer) + +- Effective state: env opt-out (`OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) → off; decision `declined` → off; `accepted` → on; undecided → on only after `noticeShownAt` is recorded, which only a person can cause (GUI popup seen, interactive TTY prompt answered). An agent-driven start prints a notice but records nothing, so an agent can never switch sending on. +- Identity is a random install id (UUID v4) generated on first send, never derived from + hardware, accounts, hostnames or paths. Disabling telemetry deletes it; re-enabling mints a new one. +- Payloads carry only closed-vocabulary values and counters: version, OS, arch, install channel, + provider *kind* (closed vendor roster), coarse model family, closed + error codes, and integer counts. Never request or response bodies, prompts, model output, API + keys, OAuth tokens, account ids or e-mails, custom base URLs, hostnames, file paths, or free-form + error text. +- The exact JSON the next send would contain is visible before consent ("What is sent") and via + the management API and `ocx telemetry status`. +- The collector stores no IP address and no raw install id: reports carry a per-day install hash, + rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per + install and day counts. +- No backfill: days before the disclosure are never sent; at most one day is sent per scheduler tick. +- The collector URL is a constant; it is not configurable from `config.json`. +- Telemetry code stays off the core request path: `src/router.ts`, + `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. + Aggregates are computed from the existing usage log on a timer, never inline in a request. + +## Work phases (stack order) + +| Layer | Doc | Thesis | +| --- | --- | --- | +| 1 | this unit | Docs-only roadmap. | +| 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | +| 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | +| 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | +| 6 | [050_dashboard.md](050_dashboard.md) | Astro page `/dashboard`: heatmap, KPIs, breakdowns, errors, downloads. | + +## Verification floor per layer + +`bun run typecheck`; focused tests for the layer plus `bun run test:changed`; +`bun run structure:check`, `bun run privacy:scan`, file-size ratchet and test-layout guards for +any layer adding files; `bun run lint:gui` and `bun run build:gui` for layer 3; docs-site build +for layers 5 and 6; `bun run skill:surface:check` when a CLI capability is added. Each layer gets an +independent review before its PR is opened. + + +## Audit disposition (round 1) + +Independent audit (argon, read-only): FAIL with 9 blockers and 6 majors; all accepted and folded. +Agent-driven starts no longer record the notice; the notice and decision routes need a human session; +`ocx telemetry enable` and `ocx config set telemetry` refuse agent-driven use; no persisted endpoint; +no pre-consent backfill and one day per tick; an import-free shared contract module with closed rosters +that the collector imports; strict version regex; `OcxTelemetryConfig` in `src/types/telemetry.ts` +and no edits to `src/cli/index.ts` or `scripts/test-layout/layout.json` (ratchet headroom 0–4 lines); +`telemetry` added to the skill-surface roots; session-only route exemptions; the docs guide now lands +before the dashboard; collector typecheck test; a tick-driven scheduler that re-reads config and stops in +`stopProcessLoops`, with `OCX_TELEMETRY=0` in the test preload; explicit install-channel mapping; GUI +dialog sequencing via `onSettled`, notice only on dismiss, `readOnly`; unified `env` and kebab-case +causes; disjoint token buckets; `TelemetryStatsV1` with `dailyByProvider` and a 365-day heatmap; dedup +on `(day, install_day_hash)`; prompts in `ocx init` and `ocx service install`; sidebar, configuration +reference and both header menus. Goalplan note: work phase wp4 delivers 040 (docs) and wp5 delivers 050 +(dashboard). + + +## Audit disposition (round 2) + +Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. Two minors folded: the +collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in +`src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` +rejects `/` tokens and host identifiers there (010). diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md new file mode 100644 index 00000000000..62a3cb13885 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -0,0 +1,179 @@ +# 010 — Client telemetry core + +Stack layer 2. Everything the proxy needs to decide, build, preview and send a daily report, the +management API and CLI that later layers use, the shared wire contract, and the structure doc that +claims the new `src/` area. Anchors were read on `c61e957900`. + +## Shared wire contract (`src/telemetry/contract.ts`) + +An **import-free** module (same rule as `src/usage/telemetry-contract.ts`) holding the closed rosters +and the payload validator: `TELEMETRY_SCHEMA_VERSION = 1`, `PROVIDER_KINDS`, `MODEL_FAMILIES`, +`INSTALL_CHANNELS`, `RELEASE_CHANNELS`, `OS_VALUES`, `ARCH_VALUES`, the outcome classes and the failure +causes (re-declared and pinned equal to `REQUEST_OUTCOME_CLASSES` / `REQUEST_FAILURE_CAUSES` by a test), +and `validateTelemetryPayloadV1(input, now)`. The collector (030) imports this file directly, so the +client cannot emit a key or value the collector rejects and the two cannot drift. + +- `PROVIDER_KINDS` (vendor-level, closed): openai, chatgpt, anthropic, claude-subscription, google, + vertex, xai, deepseek, moonshot, qwen, zai, minimax, mistral, openrouter, groq, cerebras, together, + fireworks, cursor, kiro, copilot, devin, ollama, lmstudio, local-other, other. The client maps a + provider's registry preset to a kind; custom/unknown providers become `other`. User-chosen provider + keys, labels and base URLs are never read into the payload. +- `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, + claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, + llama, other. +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d+)?$` (no build metadata). + +## State model + +Type `OcxTelemetryConfig` lives in a new `src/types/telemetry.ts`; `src/types/config.ts` (1996 lines, +ratchet threshold 2000) gains exactly one import line and one field line, or an existing block moves +out first if the count says otherwise. Schema in `src/config/schema/config-schema.ts` as +`telemetryConfigSchema.optional().catch(undefined)`, strict live-write validation in +`src/config/diagnostics.ts`, load-degrade warning in `src/config/load-degrade.ts`, baseline adoption +`adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +```ts +type OcxTelemetryConfig = { + decision?: "accepted" | "declined"; // absent = undecided + decidedAt?: string; // ISO + noticeShownAt?: string; // a human saw the disclosure (GUI or interactive TTY) +}; +``` + +There is **no persisted endpoint**: the collector URL is the constant +`https://telemetry.opencodex.me/v1/events` in `sender.ts` with a test-only injection seam. + +`resolveTelemetryStatus(config, env)` (`src/telemetry/consent.ts`): + +| Condition (first match) | `effective` | `reason` | +| --- | --- | --- | +| `OCX_TELEMETRY` ∈ {0,false,off,no} | false | `env-ocx-telemetry` | +| `DO_NOT_TRACK` ∈ {1,true,yes} | false | `env-do-not-track` | +| `decision === "declined"` | false | `declined` | +| `decision === "accepted"` | true | `accepted` | +| undecided and `noticeShownAt` set | true | `default-on` | +| undecided, no notice | false | `awaiting-notice` | + +## Who may change it (agent boundary) + +Telemetry is on by default, so the only thing that can switch sending on for an undecided install is +a human seeing the disclosure. Following `AGENTS_INSTALL.md` and `src/cli/star-prompt.ts`: + +- An agent-driven start (`isAgentDriven()`, non-TTY, or `OCX_SERVICE=1`) prints a one-line notice at + most once per version (`.telemetry-deferred` marker in the config dir) and writes **neither** + `noticeShownAt` nor `decision`. The install stays `awaiting-notice` until a human sees the GUI popup + or an interactive prompt. +- `PUT /api/telemetry` and `POST /api/telemetry/notice` both require `hasHumanDashboardSession(ctx)`. +- `ocx telemetry enable` refuses with `agent_consent_required` (non-zero exit) when `isAgentDriven()`; + `ocx telemetry disable` is always allowed (opting out is never a consent risk). +- `src/cli/config-command.ts` `set`/`unset` refuse the `telemetry` key, and `import` drops a + `telemetry` block when agent-driven, pointing to `ocx telemetry`. +- `AGENTS_INSTALL.md` gains a short "Telemetry" entry in its user-consent list. + +## Identity + +`src/telemetry/install-id.ts` keeps `/telemetry-id.json` (`0o600`, exclusive no-follow +create, pattern of `src/quota-pool/identity.ts`), created on the first send and deleted when the +decision becomes `declined`. The raw id never leaves the machine; each report carries +`installDayHash = sha256(installId + ":" + day)` and `newInstall: true` only on the very first report +of a freshly minted id (the collector counts new installs per day, version and channel from it). + +## Aggregation (`src/telemetry/aggregate.ts`) + +Scans `usage.jsonl` with `scanUsageLedgerCooperatively` (`src/usage/ledger-scanner.ts`) for one +completed UTC day: + +- `activeMinutes`: distinct `floor(timestamp / 60000)` values (≤ 1440). +- `requests`: counts per outcome class via `classifyRequestOutcome` (`src/usage/request-outcome.ts`). +- `models`: per (`providerKind`, `modelFamily`) requests and **disjoint** token buckets — + `inputTokens` = uncached input (`normalizeCostTokens`, `src/usage/cost.ts`), `cacheReadTokens`, + `cacheWriteTokens`, `outputTokens` = output minus reasoning, `reasoningTokens`. The five sum to the + display total (`usageDisplayTotalTokens`); a test pins that identity. +- `errors`: counts keyed by failure cause (kebab-case, e.g. `rate-limit`). + +## Payload v1 + +```json +{ "schemaVersion": 1, "batchId": "", "day": "2026-11-06", "installDayHash": "<64 hex>", + "newInstall": false, + "env": { "version": "2.40.1", "releaseChannel": "latest", "os": "darwin", "arch": "arm64", + "installChannel": "npm-global" }, + "activeMinutes": 312, + "requests": { "completed": 840, "failed": 12, "incomplete": 3, "aborted": 5 }, + "models": [ { "providerKind": "anthropic", "modelFamily": "claude-opus", "requests": 400, + "inputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, + "outputTokens": 0, "reasoningTokens": 0 } ], + "errors": { "rate-limit": 7 } } +``` + +`installChannel` mapping in `src/telemetry/payload.ts`: `OCX_DESKTOP_SUPERVISED === "1"` → desktop; +container markers (`/.dockerenv`, `/run/.containerenv`) → docker; `detectInstallOwnership().installer` +npm → npm-global, pnpm → pnpm-global, bun → bun-global, mise → mise, standalone → standalone, +source → source; otherwise unknown. Every field is named explicitly in the builder and the result is +run through `validateTelemetryPayloadV1` before sending or previewing. + +## Scheduling and sending + +- `src/server/background-lifecycle.ts` `startProcessLoops` starts a dormant `.unref()` scheduler via + `void import("../telemetry/scheduler")…` unless an env opt-out is set; `stopProcessLoops` stops it + (handle kept in background-lifecycle), and `registerOptionalShutdownHook("telemetry", stop)` covers + drain. `src/server/index.ts` and `src/cli/index.ts` (1999 lines) are not touched. +- Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates + `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management + routes also call `adoptPersistedTelemetry` and nudge the scheduler. +- It sends only completed days on or after the UTC day of `decidedAt ?? noticeShownAt` (no pre-consent + backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s + `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. +- `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. + +## Management API + +Thin `src/server/management/telemetry-routes.ts` (seen by `management-route-registry.test.ts`) +delegating to `src/telemetry/routes.ts`, lazily namespaced from `src/server/management-api.ts` and +declared in `route-registry.ts`: + +- `GET /api/telemetry` → effective/reason/decision/env override, install channel, last sent day, + `preview` (the exact payload for the latest completed day; hash shown as a placeholder when no id + exists). +- `PUT /api/telemetry` `{ decision }` — human session only; `exempt: { reason: "session-only" }`. +- `POST /api/telemetry/notice` — human session only, idempotent; `exempt: { reason: "session-only" }`. + +## CLI + +`src/cli/telemetry.ts`: `ocx telemetry status [--json]` (route `GET /api/telemetry`, falls back to +local status when the proxy is down), `enable` and `disable` (local config mutation, `routes: []`, +`mutates: true`). Capability objects stay plain string/array/object literals (the lexical guard in `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers). Registered in `capabilities-observe-system.ts`, `registry.ts`, `dispatch.ts`, +`help.ts`; `telemetry` is added to the `observe-system` roots in +`scripts/generate-ocx-skill-surface.ts` and `tests/ci-workflows/skill-ocx-generated.test.ts`, then +`bun run skill:surface`. + +Interactive prompt `Share anonymous usage statistics? [Y/n]` (default yes; Enter = yes; records +`decision` and `noticeShownAt`) shown once when undecided, from: `maybeShowStarPrompt()`'s module on +interactive `ocx start` (no new line in `src/cli/index.ts`), `ocx service install` next to its star +prompt (`src/service/cli.ts`), and the end of `ocx init` (`src/cli/init.ts`). + +## Ownership + +`structure/telemetry.md` registered in `structure/manifest.json` claiming `src/telemetry/`, +`src/types/telemetry.ts`, `src/server/management/telemetry-routes.ts` and `src/cli/telemetry.ts`, +naming only paths that exist in this layer; `bun run structure:index`. The four 600-line structure +docs are not extended. + +## Verification + +- `tests/usage/usage-telemetry-client.test.ts`: status matrix, env precedence, id file mode and + deletion, aggregate bucketing and the disjoint-token identity on a fixture ledger, payload validator + rejects extra keys/unknown roster values/semver metadata, roster parity with the usage contract, no + pre-consent days, one day per tick, sender timeout, nothing sent when not effective. +- `tests/usage/usage-telemetry-core-boundary.test.ts`: `router.ts`, `lifecycle.ts`, + `responses/core.ts`, `management-api.ts` have no load-time path to `src/telemetry/`. +- `tests/server/server-telemetry-routes.test.ts`: GET preview; PUT and notice refused with admin token + and remembered-admin; accepted with a human session; notice idempotent. +- `tests/cli/cli-telemetry.test.ts`: status/enable/disable, enable refused when agent-driven, config + set refusal, agent-driven start writes no notice, prompt default yes. +- New tests registered in a domain shard (`layout-usage.json` etc.; `layout.json` is at 1999 lines) + and `tests/fixtures/test-layout-expected.json`. +- `bun run typecheck`, focused files, `bun run test:changed`, `bun run structure:check`, + `bun run skill:surface:check`, `bun run privacy:scan`, file-size and layout guards, + `tests/cli/cli-capabilities.test.ts`, `tests/server/management-route-registry.test.ts`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md new file mode 100644 index 00000000000..96a0a3bc3b7 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md @@ -0,0 +1,55 @@ +# 020 — GUI consent popup, post-update popup and Advanced toggle + +Stack layer 3. Depends on 010 (`GET /api/telemetry`, `PUT /api/telemetry`, `POST /api/telemetry/notice`). + +## Behaviour + +- `TelemetryConsentCard` is a native `` mounted beside + `` in `gui/src/App.tsx`. It is enabled only when + `targetsSettled && !targets.connected && sharedSessionReady && starOnboardingSettled`. + `DesktopStarOnboarding` gains an `onSettled` callback (fired when it closes or decides it will not + show), so the two dialogs never open together. +- It opens when the status reports an undecided install with no env override and the proxy says + `reason === "awaiting-notice"` or `"default-on"` without a decision. That covers first run and + installs that update into this version (they have no decision yet): the post-update popup. +- The checkbox is **pre-checked**. Continue sends `PUT /api/telemetry { decision: checked ? "accepted" : + "declined" }` (this also records the notice server-side). Escape/backdrop without a decision sends + `POST /api/telemetry/notice`: the user saw the disclosure and the default (on) applies. Opening the + dialog alone records nothing, so an unread or automated page load never enables sending. +- The card lists what is sent and what is never sent and has a "What is sent" disclosure with the + exact preview JSON from the status endpoint. +- `TelemetrySettingsPanel` renders in Proxy details (`SettingsSection id="proxy-telemetry"` in + `dashboard-overview-panels.tsx`, id appended to `PROXY_SECTION_IDS`) with `Switch` from `ui.tsx`, + `readOnly` honoured for Remote Link children, and the preview dialog. With `OCX_TELEMETRY=0` or + `DO_NOT_TRACK=1` the switch is disabled and a `Notice tone="warn"` names the variable. + `AdvancedHub.tsx` gets `telemetry`/`privacy` search words on the proxy entry. +- The panel and card fetch on their own with `createBoundedFetch(15_000)`, outside + `useDashboardData` (dashboard-contracts poll-wave rule). + +## Files + +| File | Change | +| --- | --- | +| `gui/src/telemetry-api.ts` | DTO types and fetch helpers (non-component, oxlint `only-export-components`). | +| `gui/src/components/telemetry-consent-card.tsx` | First-run / post-update dialog. | +| `gui/src/components/TelemetrySettingsPanel.tsx` | Toggle row and preview dialog. | +| `gui/src/components/desktop-star-onboarding.tsx` | `onSettled` prop. | +| `gui/src/App.tsx` | Mount the card, track `starOnboardingSettled`. | +| `gui/src/pages/dashboard-section-ids.ts`, `dashboard-overview-panels.tsx`, `AdvancedHub.tsx` | Section, panel, search words. | +| `gui/src/i18n/v2-pages/proxy-copy.ts` | All `proxyV2.telemetry*` rows in the 11-column table (en, ko, de, fr, zh, zh-TW, ru, ja, tr, vi, pt). | + +No CSS is added to `gui/src/styles.css` (ratchet cap); reuse `.modal-overlay`/`.modal-card` and v2 +utilities. + +## Verification + +- `gui/tests/telemetry-consent-card.test.tsx`: waits for star onboarding; shows only when undecided + and no env override; checkbox pre-checked; nothing recorded on open; Continue sends accepted / + declined; Escape sends notice only; hidden once decided. +- `gui/tests/telemetry-settings-panel.test.tsx`: switch mirrors effective state, toggles via PUT, + disabled with env notice and in read-only mode, preview dialog shows the JSON. +- `gui/tests/desktop-star-onboarding.test.tsx` extended for `onSettled`. +- `cd gui && bun test tests && bun run lint && bun run lint:i18n && bun run build`; root + `bun run typecheck`. +- Screenshot of the card and panel for the PR description (uploaded via `pr-assets`, never committed). + diff --git a/devlog/_plan/261107_telemetry_dashboard/030_collector.md b/devlog/_plan/261107_telemetry_dashboard/030_collector.md new file mode 100644 index 00000000000..a8a02208b02 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/030_collector.md @@ -0,0 +1,80 @@ +# 030 — Telemetry collector (Cloudflare Worker + D1) + +Stack layer 4. Imports the wire contract `src/telemetry/contract.ts` from 010 directly. + +## Layout (dependency-free) + +| File | Role | +| --- | --- | +| `services/telemetry-collector/wrangler.toml` | Worker name, route `telemetry.opencodex.me/*`, D1 binding `DB` with placeholder id `00000000-0000-0000-0000-000000000000`, cron trigger for pruning. Real ids are set by a maintainer at deploy time. | +| `services/telemetry-collector/schema.sql` | D1 tables (below). | +| `services/telemetry-collector/tsconfig.json` | Extends root settings with `rootDir: "../.."`, `noEmit: true`, `declaration: false` (as in `scripts/test-layout/tsconfig.verify.json`) so importing `../../src/telemetry/contract.ts` does not hit TS6059. | +| `services/telemetry-collector/src/types.ts` | Minimal local `D1Database`/`D1PreparedStatement`/`ExecutionContext` interfaces, `CollectorEnv`, `TelemetryStatsV1`. | +| `services/telemetry-collector/src/ingest.ts` | `POST /v1/events`. | +| `services/telemetry-collector/src/stats.ts` | `GET /v1/stats?range=7d|30d|90d|365d`. | +| `services/telemetry-collector/src/prune.ts` | Scheduled pruning. | +| `services/telemetry-collector/src/index.ts` | `fetch`/`scheduled` handlers, CORS, 404/405. | +| `services/telemetry-collector/README.md` | Deploy: `bunx wrangler d1 create`, `d1 execute --file schema.sql`, secrets (`RATE_LIMIT_SALT`), `wrangler deploy`. | + +No root `package.json`/`bun.lock` change. The root typecheck only covers `src/`; this layer adds a +test that typechecks the collector project with the repository's own TypeScript toolchain (command +chosen and proven in B, since `node_modules/.bin/tsc` is absent and `bun check` is the root checker). + +## Storage + +- `daily_installs(day, install_day_hash, version, release_channel, os, arch, install_channel, + new_install, active_minutes, PRIMARY KEY(day, install_day_hash))` — pruned after 400 days. +- `daily_totals(day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, + input, cache_read, cache_write, output, reasoning)`. +- `daily_models(day, provider_kind, model_family, requests, input, cache_read, cache_write, output, + reasoning)`. +- `daily_errors(day, cause, count)`. +- `rate_limits(bucket_hour, ip_hash, hits)` — pruned after 2 h. + +**Dedup:** ingest first `INSERT OR IGNORE` into `daily_installs`; only when that inserted a new +`(day, install_day_hash)` row are the rollups incremented (one D1 `batch`). A re-sent day — new +`batchId` or not — changes nothing, so lost client state cannot inflate totals. Raw install ids and IPs +are never stored; `ip_hash = sha256(RATE_LIMIT_SALT + ip + hour)`. + +## Validation and limits + +`validateTelemetryPayloadV1` from the contract (closed keys at every level, closed rosters for +provider kind, model family, channels, os, arch, failure causes; calendar-checked `day` in +[today − 7, today + 1]; strict version regex; bounded integers; ≤ 64 model rows). Body ≤ 16 KiB +(413), JSON only (400), 30 reports/hour per IP hash (429 with `Retry-After`). + +## Stats API (`TelemetryStatsV1`, consumed by 050) + +```ts +type TelemetryStatsV1 = { + schemaVersion: 1; generatedAt: string; range: "7d" | "30d" | "90d" | "365d"; + totals: { activeInstalls: number; newInstalls: number; activeMinutes: number; requests: number; + tokens: { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning: number } }; + daily: Array<{ day: string; activeInstalls: number; newInstalls: number; activeMinutes: number; + requests: number; failed: number; tokens: number }>; + dailyByProvider: Array<{ day: string; providerKind: string; requests: number; tokens: number }>; + heatmap: Array<{ day: string; activeInstalls: number }>; // always the last 365 days + providers: Array<{ providerKind: string; requests: number; tokens: number }>; + models: Array<{ modelFamily: string; requests: number; tokens: number }>; + versions: Array<{ version: string; installs: number; newInstalls: number }>; + platforms: Array<{ os: string; arch: string; installs: number }>; + channels: Array<{ installChannel: string; installs: number; newInstalls: number }>; + outcomes: { completed: number; failed: number; incomplete: number; aborted: number }; + errors: Array<{ cause: string; count: number }>; +}; +``` + +Served with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and +`http://localhost:4321`. + +## Verification + +- `tests/usage/usage-telemetry-collector-worker.test.ts`: `bun:sqlite` in-memory D1 shim loading + `schema.sql` via `repoPath`; ingest accepts a payload built by the 010 builder, rejects unknown keys + and roster values, dedups re-sent days, rate-limits with 429, sets CORS, returns every stats field + for each range with a full 365-day heatmap, never stores a raw id or IP; pruning. +- `tests/usage/usage-telemetry-collector-typecheck.test.ts`: the collector project typechecks. +- Registered in `scripts/test-layout/layout-usage.json` and `tests/fixtures/test-layout-expected.json`; + add `ci:full` if the import graph misses them. +- `bun run typecheck`, `bun run privacy:scan` (placeholder ids only), file-size and layout guards. + diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md new file mode 100644 index 00000000000..b50b0589b7e --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -0,0 +1,31 @@ +# 040 — Telemetry guide and configuration reference + +Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the +docs build rejects links to missing pages. + +## Public docs + +- `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never + collected, default-on with "nothing is sent until a person has seen the notice", agent-driven starts + never enable it, how to turn it off (GUI popup, Proxy details toggle, `ocx telemetry disable`, + `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1`), how to see the exact payload (`ocx telemetry status --json`, + "What is sent"), storage and retention (daily aggregates, install hash rotates daily, no IPs), and + the collector source location. No link to `/dashboard` yet (050 adds it). +- `docs-site/astro.config.mjs`: add `guides/telemetry` to the Guides sidebar. +- `docs-site/src/content/docs/reference/configuration/server.md`: `telemetry` config block and the + two environment variables. +- `guides/web-dashboard.md`: one line pointing to the Proxy details telemetry toggle. +- Translated locales follow the docs-site locale rule (translated or absent with English fallback); + nothing may contradict the English page. + +## Maintainer docs + +`structure/telemetry.md` (from 010) gains the collector location (`services/telemetry-collector/`, +now present) and the guide path. + +## Verification + +docs-site build (`cd docs-site && bun install --frozen-lockfile && bun run build`), +`tests/ci-workflows/docs-link-targets.test.ts` and the other `docs-*` claim guards, +`bun run structure:check`, `bun run privacy:scan`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md new file mode 100644 index 00000000000..41609671e36 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md @@ -0,0 +1,43 @@ +# 050 — opencodex.me/dashboard + +Stack layer 6. Reads `GET https://telemetry.opencodex.me/v1/stats` (`TelemetryStatsV1`, 030) from the browser and links the telemetry guide from 040. + +## Page + +`docs-site/src/content/docs/dashboard.mdx` with `template: splash`, mounting +``. A content page (not `src/pages/`) gets Starlight's locale fallback routes, +so the header's language links (`/ko/dashboard/` ...) resolve and `internal-links.mjs` and +`tests/ci-workflows/docs-link-targets.test.ts` pass unchanged. + +## Sections (adapted from tokscale, MIT, credited in the footer) + +1. Header: title, range pills (7d / 30d / 90d / 365d), last-updated time, privacy callout linking the + telemetry guide and the opt-out variables. +2. KPI strip (tokscale `ProfileOverview`): active installs, requests, tokens, active hours — + `tabular-nums`, compact K/M/B with exact values in `title`. +3. Token mix bar (tokscale `TokenBreakdown`): input / cache read / cache write / output / reasoning — disjoint buckets from the payload contract, so the segments sum to the total. +4. Activity: a 52-week heatmap of daily active installs from the always-365-day `heatmap` series (tokscale `ProfileContributionGraph`, 5 grades) + and an ECharts stacked daily chart from `dailyByProvider` (tokens or requests by provider kind). ECharts is already a + docs-site dependency (`FrontierBoards.astro`) and re-themes on `data-theme` changes. +5. Provider-kind and model-family share tables (tokscale `ProfileModels` colour dots). +6. Platform, version and install-channel distribution, new installs per version/channel; reliability (outcome ratio and top error codes). +7. Downloads: npm weekly/total downloads (`api.npmjs.org/downloads`) and GitHub release asset download + counts (`api.github.com/repos///releases`), fetched client-side with graceful fallback. + +Loading, empty ("collector not deployed yet") and error states are explicit; no layout jumps. + +## Files + +`docs-site/src/components/TelemetryDashboard.astro` (shell + client script), +`docs-site/src/components/telemetry-dashboard/*.ts` (pure formatting / series helpers testable from +`tests/`), `docs-site/src/data/telemetry-dashboard-i18n.ts` (copy for the 8 site locales), and the localized `/dashboard/` link in both the desktop nav and the splash +mobile menu of `docs-site/src/components/Header.astro`, plus a link from `guides/telemetry.md`. + +## Verification + +- `tests/ci-workflows/docs-telemetry-dashboard.test.ts`: the route exists, helpers format and bucket a + fixture stats response correctly, empty/error states render, no hard-coded secrets. +- `cd docs-site && bun install --frozen-lockfile && bun run build`. +- Browser check of the built page against a fixture stats JSON (screenshot for the PR). + + From 0c542dea857d825ac6407fc8202a769761a29b70 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 02/63] feat(telemetry): add shared wire contract, config schema, and core telemetry engine --- src/config/diagnostics.ts | 15 + src/config/live-reconcile.ts | 13 + src/config/load-degrade.ts | 16 ++ src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 11 + src/server/background-lifecycle.ts | 27 ++ src/telemetry/aggregate.ts | 321 +++++++++++++++++++++ src/telemetry/consent.ts | 127 +++++++++ src/telemetry/contract.ts | 403 +++++++++++++++++++++++++++ src/telemetry/install-id.ts | 197 +++++++++++++ src/telemetry/payload.ts | 129 +++++++++ src/telemetry/scheduler.ts | 246 ++++++++++++++++ src/telemetry/sender.ts | 63 +++++ src/types.ts | 2 + src/types/config.ts | 2 + src/types/telemetry.ts | 15 + tests/preload.ts | 1 + 17 files changed, 1590 insertions(+) create mode 100644 src/telemetry/aggregate.ts create mode 100644 src/telemetry/consent.ts create mode 100644 src/telemetry/contract.ts create mode 100644 src/telemetry/install-id.ts create mode 100644 src/telemetry/payload.ts create mode 100644 src/telemetry/scheduler.ts create mode 100644 src/telemetry/sender.ts create mode 100644 src/types/telemetry.ts diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 5b68cffaab4..617db940f68 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -49,6 +49,7 @@ import { malformedQuotaResetNotifyWarning, malformedCatalogAutoRefreshWarning, malformedCodexPoolWarning, + malformedTelemetryWarning, malformedSpendWarning, rawConfigRecord, malformedNativeSubagentFields, @@ -81,6 +82,7 @@ import { compactionRoutingSchema, reasoningRetentionSchema, skillsConfigSchema, + telemetryConfigSchema, memoryModelsSchema, } from "./schema/leaf-validators"; @@ -175,6 +177,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (catalogRefreshWarning) warnings.push(catalogRefreshWarning); const codexPoolWarning = malformedCodexPoolWarning(rawParsed); if (codexPoolWarning) warnings.push(codexPoolWarning); + const telemetryWarning = malformedTelemetryWarning(rawParsed); + if (telemetryWarning) warnings.push(telemetryWarning); const spendWarning = malformedSpendWarning(rawParsed); if (spendWarning) warnings.push(spendWarning); const retentionWarning = degradedReasoningRetentionWarning(rawParsed, normalized); @@ -709,6 +713,16 @@ function skillsConfigError(value: unknown): string | null { return "schema_invalid: skills" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); } +export function telemetryConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return "schema_invalid: telemetry" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { @@ -773,6 +787,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? managementIngressConfigError(value) ?? metricsExportConfigError(value) ?? serviceHealthRecoveryConfigError(value) + ?? telemetryConfigError(value) ?? skillsConfigError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 3e9d14d4439..e836e31add0 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -118,6 +118,19 @@ export function adoptPersistedAdvisor(config: OcxConfig, persisted: OcxConfig["a clearConfigTopLevelDeletionIntent(config, ["advisor"]); if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["advisor"]); } + +/** Adopt the committed telemetry consent subtree without advancing unrelated pending live intent. */ +export function adoptPersistedTelemetry(config: OcxConfig, persisted: OcxConfig["telemetry"]): void { + if (persisted === undefined) delete config.telemetry; + else config.telemetry = structuredClone(persisted); + const baseline = liveConfigBaseline.get(config); + if (baseline) { + if (persisted === undefined) delete baseline.telemetry; + else baseline.telemetry = structuredClone(persisted); + } + clearConfigTopLevelDeletionIntent(config, ["telemetry"]); + if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["telemetry"]); +} /** * The live config retains the address of the socket Bun actually opened, while * this map retains the operator's desired address for the next process start. diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 355819b2ef8..ce3bae9c269 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -52,6 +52,7 @@ import { transientRetryOn5xxPolicySchema, runtimeRoleSchema, spendSchema, + telemetryConfigSchema, chatgptDesktopConfigIssue, } from "./schema/leaf-validators"; import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo"; @@ -152,6 +153,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); warnDegradedMemoryModels(rawParsed, validated); + warnDegradedTelemetry(rawParsed); // The schema replaces a malformed policy with { allProfiles: false }, which silently turns every Aside profile off (#6757). const asidePolicy = rawParsed && typeof rawParsed === "object" ? (rawParsed as Record).asideProfileSync : undefined; if (asidePolicy !== undefined && !asideProfileSyncSchema.safeParse(asidePolicy).success) { @@ -850,6 +852,20 @@ export function malformedCodexPoolWarning(rawParsed: unknown): string | null { return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; } +export function malformedTelemetryWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `telemetry${field ? `.${field}` : ""} ignored: invalid telemetry consent configuration`; +} + +export function warnDegradedTelemetry(rawParsed: unknown): void { + const warning = malformedTelemetryWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + /** * Warn once per load that the section was dropped. * diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b57c3efa537..189344bebee 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -29,6 +29,7 @@ import { spendSchema, chatgptDesktopSchema, skillsConfigSchema, + telemetryConfigSchema, configuredCodexPoolAccountIds, apiKeyEntrySchema, asideProfileSyncSchema, @@ -120,6 +121,7 @@ export const configSchema = z.object({ skills: skillsConfigSchema.optional().catch(undefined), // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts. metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), + telemetry: telemetryConfigSchema.optional().catch(undefined), // Same contract as metricsExport: a malformed hand edit leaves recovery off; live writes reject it. serviceHealthRecovery: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), // Kept raw on purpose: `.catch(undefined)` would turn a mistyped `enabled` into "inherit", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 5df3213e410..d601679717b 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1189,3 +1189,14 @@ export const spendSchema = z.object({ export const skillsConfigSchema = z.object({ catalog_refresh: z.enum(["per_session", "per_turn"]).optional(), }).strict(); + +/** + * Anonymous telemetry consent state. + * + * No endpoint is accepted here: the collector URL is a constant in src/telemetry/sender.ts. + */ +export const telemetryConfigSchema = z.object({ + decision: z.enum(["accepted", "declined"]).optional(), + decidedAt: clientTimestampSchema.optional(), + noticeShownAt: clientTimestampSchema.optional(), +}).strict(); diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8f51e2c9fcf..2661fff01d3 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -60,6 +60,15 @@ export type ServerBackgroundLifecycleLease = { const owners: LeaseOwner[] = []; let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; +let telemetryLoopGeneration = 0; +let stopTelemetryLoop: (() => void) | null = null; + +function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx === "0" || ocx === "false" || ocx === "off" || ocx === "no") return true; + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + return dnt === "1" || dnt === "true" || dnt === "yes"; +} function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); @@ -124,8 +133,22 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next poll tick retries. }); + const generation = ++telemetryLoopGeneration; + if (!isTelemetryDisabledByEnv()) { + void import("../telemetry/scheduler") + .then(scheduler => { + if (generation !== telemetryLoopGeneration) return; + stopTelemetryLoop = scheduler.startTelemetryScheduler(); + }) + .catch(() => { + // Optional background loop. + }); + } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { + telemetryLoopGeneration += 1; + stopTelemetryLoop?.(); + stopTelemetryLoop = null; memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); @@ -138,6 +161,10 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { } function stopProcessLoops(): void { + telemetryLoopGeneration += 1; + const stopTelemetry = stopTelemetryLoop; + stopTelemetryLoop = null; + stopTelemetry?.(); const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/telemetry/aggregate.ts b/src/telemetry/aggregate.ts new file mode 100644 index 00000000000..fc71f1030cb --- /dev/null +++ b/src/telemetry/aggregate.ts @@ -0,0 +1,321 @@ +import type { OcxUsage } from "../types"; +import { normalizeCostTokens } from "../usage/cost"; +import { scanUsageLedgerCooperatively } from "../usage/ledger-scanner"; +import { isKnownRequestFailureCause, type PersistedUsageEntry } from "../usage/log"; +import { classifyRequestOutcome } from "../usage/request-outcome"; +import { + TELEMETRY_MAX_MODELS, + parseCalendarUtcDayMs, + type TelemetryErrorsV1, + type TelemetryFailureCause, + type TelemetryModelFamily, + type TelemetryModelRowV1, + type TelemetryProviderKind, + type TelemetryRequestsV1, +} from "./contract"; + +export interface DisjointTelemetryTokens { + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export interface DailyTelemetryAggregate { + day: string; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const ACCOUNT_SUFFIX_RE = /-(?:main|[pko][0-9a-f]{6})$/i; + +const PRESET_TO_PROVIDER_KIND: Readonly> = Object.freeze({ + openai: "openai", + "openai-apikey": "openai", + "azure-openai": "openai", + chatgpt: "chatgpt", + "openai-multi": "chatgpt", + anthropic: "anthropic", + "anthropic-apikey": "anthropic", + "claude-subscription": "claude-subscription", + "claude-cli": "claude-subscription", + anthropic2: "claude-subscription", + google: "google", + "google-antigravity": "google", + vertex: "vertex", + "google-vertex": "vertex", + xai: "xai", + grok: "xai", + deepseek: "deepseek", + moonshot: "moonshot", + kimi: "moonshot", + "kimi-responses": "moonshot", + "kimi-code": "moonshot", + qwen: "qwen", + "qwen-cloud": "qwen", + alibaba: "qwen", + "alibaba-token-plan": "qwen", + "alibaba-token-plan-intl": "qwen", + zai: "zai", + "zhipu-bigmodel": "zai", + "zhipu-bigmodel-coding": "zai", + "zhipu-bigmodel-responses": "zai", + minimax: "minimax", + "minimax-cn": "minimax", + mistral: "mistral", + openrouter: "openrouter", + groq: "groq", + cerebras: "cerebras", + together: "together", + fireworks: "fireworks", + firepass: "fireworks", + cursor: "cursor", + kiro: "kiro", + copilot: "copilot", + "github-copilot": "copilot", + devin: "devin", + ollama: "ollama", + "ollama-cloud": "ollama", + lmstudio: "lmstudio", + "lm-studio": "lmstudio", + "local-other": "local-other", + vllm: "local-other", + litellm: "local-other", +}); + +/** + * Map a logged provider identifier to the closed vendor-level `TelemetryProviderKind` roster. + * Custom/unknown provider keys never enter the payload; they map to `"other"`. + */ +export function classifyProviderKind(provider: string): TelemetryProviderKind { + const trimmed = provider.trim().toLowerCase(); + if (!trimmed) return "other"; + const direct = PRESET_TO_PROVIDER_KIND[trimmed]; + if (direct) return direct; + const stripped = trimmed.replace(ACCOUNT_SUFFIX_RE, ""); + return PRESET_TO_PROVIDER_KIND[stripped] ?? "other"; +} + +/** + * Map a model identifier to the closed `TelemetryModelFamily` prefix roster. + */ +export function classifyModelFamily(model: string): TelemetryModelFamily { + const trimmed = model.trim().toLowerCase(); + if (!trimmed) return "other"; + const slash = trimmed.lastIndexOf("/"); + const tail = (slash >= 0 ? trimmed.slice(slash + 1) : trimmed).replace(/--[a-z0-9_-]+$/, ""); + if (!tail) return "other"; + if (tail.startsWith("gpt-6")) return "gpt-6"; + if (tail.startsWith("gpt-5")) return "gpt-5"; + if (tail.startsWith("gpt-4")) return "gpt-4"; + if (tail === "o-series" || /^o[134](?:$|[-._])/.test(tail)) return "o-series"; + if (tail.startsWith("codex")) return "codex"; + if (/^claude-(?:[\d.-]+-)?opus(?:$|[-._])/.test(tail) || tail === "claude-opus") return "claude-opus"; + if (/^claude-(?:[\d.-]+-)?sonnet(?:$|[-._])/.test(tail) || tail === "claude-sonnet") return "claude-sonnet"; + if (/^claude-(?:[\d.-]+-)?haiku(?:$|[-._])/.test(tail) || tail === "claude-haiku") return "claude-haiku"; + if (tail.startsWith("gemini-3")) return "gemini-3"; + if (tail.startsWith("gemini-2")) return "gemini-2"; + if (tail.startsWith("grok")) return "grok"; + if (tail.startsWith("deepseek")) return "deepseek"; + if (tail.startsWith("qwen") || tail.startsWith("qwq")) return "qwen"; + if (tail.startsWith("kimi") || tail.startsWith("moonshot")) return "kimi"; + if (tail.startsWith("glm") || tail.startsWith("chatglm")) return "glm"; + if (tail.startsWith("minimax") || tail.startsWith("abab")) return "minimax"; + if ( + tail.startsWith("mistral") + || tail.startsWith("mixtral") + || tail.startsWith("codestral") + || tail.startsWith("pixtral") + || tail.startsWith("ministral") + ) { + return "mistral"; + } + if (tail.startsWith("llama") || tail.startsWith("meta-llama")) return "llama"; + return "other"; +} + +function nonNegInt(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0; +} + +/** + * Decompose `OcxUsage` into five disjoint token buckets whose sum equals `usageDisplayTotalTokens(usage)`: + * - `inputTokens`: uncached input (`normalizeCostTokens`) + * - `cacheReadTokens` + * - `cacheWriteTokens` + * - `outputTokens`: output minus reasoning + * - `reasoningTokens` + */ +export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTelemetryTokens { + if (!usage) { + return { + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + } + const normalized = normalizeCostTokens(usage); + let inputTokens: number; + let cacheReadTokens: number; + let cacheWriteTokens: number; + let totalOutput: number; + if (normalized) { + inputTokens = nonNegInt(normalized.input); + cacheReadTokens = nonNegInt(normalized.cacheRead); + cacheWriteTokens = nonNegInt(normalized.cacheWrite); + totalOutput = nonNegInt(normalized.output); + } else { + const rawInput = nonNegInt(usage.inputTokens); + cacheWriteTokens = Math.min(rawInput, nonNegInt(usage.cacheCreationInputTokens)); + cacheReadTokens = Math.min( + rawInput - cacheWriteTokens, + nonNegInt(usage.cacheReadInputTokens ?? usage.cachedInputTokens), + ); + inputTokens = Math.max(0, rawInput - cacheReadTokens - cacheWriteTokens); + totalOutput = nonNegInt(usage.outputTokens); + } + const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); + const outputTokens = Math.max(0, totalOutput - reasoningTokens); + return { + inputTokens, + cacheReadTokens, + cacheWriteTokens, + outputTokens, + reasoningTokens, + }; +} + +export function aggregateTelemetryEntriesForDay( + day: string, + entries: Iterable, +): DailyTelemetryAggregate { + const dayStartMs = parseCalendarUtcDayMs(day); + if (dayStartMs === null) { + throw new Error(`Invalid UTC day: ${day}`); + } + const dayEndMs = dayStartMs + 86_400_000; + const minutes = new Set(); + const requests: TelemetryRequestsV1 = { + completed: 0, + failed: 0, + incomplete: 0, + aborted: 0, + }; + const modelMap = new Map(); + const errors: TelemetryErrorsV1 = {}; + + const addModelAttribution = (provider: string, model: string, usage: OcxUsage | undefined) => { + const providerKind = classifyProviderKind(provider); + const modelFamily = classifyModelFamily(model); + const key = `${providerKind}\0${modelFamily}`; + let row = modelMap.get(key); + if (!row) { + row = { + providerKind, + modelFamily, + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + modelMap.set(key, row); + } + row.requests += 1; + const tokens = disjointTelemetryTokens(usage); + row.inputTokens += tokens.inputTokens; + row.cacheReadTokens += tokens.cacheReadTokens; + row.cacheWriteTokens += tokens.cacheWriteTokens; + row.outputTokens += tokens.outputTokens; + row.reasoningTokens += tokens.reasoningTokens; + }; + + for (const entry of entries) { + if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { + continue; + } + minutes.add(Math.floor(entry.timestamp / 60_000)); + const outcome = classifyRequestOutcome(entry); + requests[outcome] += 1; + + const cause = entry.failureCause ?? entry.attempts?.at(-1)?.failureCause; + if (cause && isKnownRequestFailureCause(cause)) { + errors[cause as TelemetryFailureCause] = (errors[cause as TelemetryFailureCause] ?? 0) + 1; + } + + if (entry.provider === "combo" && entry.attempts && entry.attempts.length > 0) { + for (const attempt of entry.attempts) { + addModelAttribution(attempt.provider, attempt.model, attempt.usage); + } + } else { + addModelAttribution(entry.provider, entry.model, entry.usage); + } + } + + let models = [...modelMap.values()].sort((a, b) => { + if (b.requests !== a.requests) return b.requests - a.requests; + const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; + const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; + if (bTokens !== aTokens) return bTokens - aTokens; + if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); + return a.modelFamily.localeCompare(b.modelFamily); + }); + + if (models.length > TELEMETRY_MAX_MODELS) { + const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); + const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); + let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); + if (!otherRow) { + otherRow = { + providerKind: "other", + modelFamily: "other", + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + kept.push(otherRow); + } + for (const row of overflow) { + otherRow.requests += row.requests; + otherRow.inputTokens += row.inputTokens; + otherRow.cacheReadTokens += row.cacheReadTokens; + otherRow.cacheWriteTokens += row.cacheWriteTokens; + otherRow.outputTokens += row.outputTokens; + otherRow.reasoningTokens += row.reasoningTokens; + } + models = kept; + } + + return { + day, + activeMinutes: Math.min(1440, minutes.size), + requests, + models, + errors, + }; +} + +export async function aggregateTelemetryForDay( + day: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const entries: PersistedUsageEntry[] = []; + await scanUsageLedgerCooperatively({ + signal: options.signal, + onEntry: entry => { + entries.push(entry); + }, + }); + return aggregateTelemetryEntriesForDay(day, entries); +} diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts new file mode 100644 index 00000000000..5461028ba51 --- /dev/null +++ b/src/telemetry/consent.ts @@ -0,0 +1,127 @@ +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types/telemetry"; + +export type TelemetryStatusReason = + | "env-ocx-telemetry" + | "env-do-not-track" + | "declined" + | "accepted" + | "default-on" + | "awaiting-notice"; + +export type TelemetryEnvOverride = "OCX_TELEMETRY" | "DO_NOT_TRACK" | null; + +export interface TelemetryConsentStatus { + effective: boolean; + reason: TelemetryStatusReason; + decision: OcxTelemetryDecision | null; + decidedAt: string | null; + noticeShownAt: string | null; + envOverride: TelemetryEnvOverride; +} + +const OCX_TELEMETRY_OFF_VALUES = new Set(["0", "false", "off", "no"]); +const DO_NOT_TRACK_ON_VALUES = new Set(["1", "true", "yes"]); + +function extractTelemetryConfig( + input: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, +): OcxTelemetryConfig | undefined { + if (!input || typeof input !== "object") return undefined; + if ("telemetry" in input) { + const nested = (input as { telemetry?: unknown }).telemetry; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as OcxTelemetryConfig) + : undefined; + } + return input as OcxTelemetryConfig; +} + +function validIsoString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || Number.isNaN(Date.parse(trimmed))) return null; + return trimmed; +} + +export function isTelemetryEnvOptOut(env: Record = process.env): TelemetryEnvOverride { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx !== undefined && OCX_TELEMETRY_OFF_VALUES.has(ocx)) { + return "OCX_TELEMETRY"; + } + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + if (dnt !== undefined && DO_NOT_TRACK_ON_VALUES.has(dnt)) { + return "DO_NOT_TRACK"; + } + return null; +} + +export function resolveTelemetryStatus( + config: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + env: Record = process.env, +): TelemetryConsentStatus { + const telemetry = extractTelemetryConfig(config); + const decision = telemetry?.decision === "accepted" || telemetry?.decision === "declined" + ? telemetry.decision + : null; + const decidedAt = validIsoString(telemetry?.decidedAt); + const noticeShownAt = validIsoString(telemetry?.noticeShownAt); + const envOverride = isTelemetryEnvOptOut(env); + + if (envOverride === "OCX_TELEMETRY") { + return { + effective: false, + reason: "env-ocx-telemetry", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (envOverride === "DO_NOT_TRACK") { + return { + effective: false, + reason: "env-do-not-track", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (decision === "declined") { + return { + effective: false, + reason: "declined", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (decision === "accepted") { + return { + effective: true, + reason: "accepted", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (noticeShownAt !== null) { + return { + effective: true, + reason: "default-on", + decision: null, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + return { + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt, + noticeShownAt: null, + envOverride: null, + }; +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts new file mode 100644 index 00000000000..2e645963f9a --- /dev/null +++ b/src/telemetry/contract.ts @@ -0,0 +1,403 @@ +/** + * Shared wire contract for anonymous daily product telemetry (schemaVersion 1). + * + * This module is deliberately import-free so both the local proxy and the Cloudflare Worker + * collector (services/telemetry-collector) can import it directly without pulling runtime + * dependencies across project boundaries. + */ + +export const TELEMETRY_SCHEMA_VERSION = 1 as const; +export const TELEMETRY_MAX_MODELS = 64; +export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d+)?$/; + +export const PROVIDER_KINDS = Object.freeze([ + "openai", + "chatgpt", + "anthropic", + "claude-subscription", + "google", + "vertex", + "xai", + "deepseek", + "moonshot", + "qwen", + "zai", + "minimax", + "mistral", + "openrouter", + "groq", + "cerebras", + "together", + "fireworks", + "cursor", + "kiro", + "copilot", + "devin", + "ollama", + "lmstudio", + "local-other", + "other", +] as const); + +export type TelemetryProviderKind = typeof PROVIDER_KINDS[number]; +export type ProviderKind = TelemetryProviderKind; + +export const MODEL_FAMILIES = Object.freeze([ + "gpt-6", + "gpt-5", + "gpt-4", + "o-series", + "codex", + "claude-opus", + "claude-sonnet", + "claude-haiku", + "gemini-3", + "gemini-2", + "grok", + "deepseek", + "qwen", + "kimi", + "glm", + "minimax", + "mistral", + "llama", + "other", +] as const); + +export type TelemetryModelFamily = typeof MODEL_FAMILIES[number]; +export type ModelFamily = TelemetryModelFamily; + +export const INSTALL_CHANNELS = Object.freeze([ + "npm-global", + "pnpm-global", + "bun-global", + "mise", + "standalone", + "desktop", + "docker", + "source", + "unknown", +] as const); + +export type TelemetryInstallChannel = typeof INSTALL_CHANNELS[number]; +export type InstallChannel = TelemetryInstallChannel; + +export const RELEASE_CHANNELS = Object.freeze([ + "latest", + "preview", +] as const); + +export type TelemetryReleaseChannel = typeof RELEASE_CHANNELS[number]; +export type ReleaseChannel = TelemetryReleaseChannel; + +export const OS_VALUES = Object.freeze([ + "darwin", + "linux", + "win32", + "other", +] as const); + +export type TelemetryOs = typeof OS_VALUES[number]; + +export const ARCH_VALUES = Object.freeze([ + "x64", + "arm64", + "other", +] as const); + +export type TelemetryArch = typeof ARCH_VALUES[number]; + +export const REQUEST_OUTCOME_CLASSES = Object.freeze([ + "completed", + "failed", + "incomplete", + "aborted", +] as const); +export const TELEMETRY_OUTCOME_CLASSES = REQUEST_OUTCOME_CLASSES; +export type TelemetryOutcomeClass = typeof REQUEST_OUTCOME_CLASSES[number]; + +export const REQUEST_FAILURE_CAUSES = Object.freeze([ + "transport-unsent", + "transport-ambiguous", + "upstream-declined", + "rate-limit", + "quota-exhausted", + "credential-rejected", + "policy-refusal", + "parameter-rejected", + "ciphertext-refusal", + "payload-too-large", + "payload-rejected", + "upstream-fault", + "empty-output", + "client-cancelled", + "local-refusal", +] as const); +export const TELEMETRY_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES; +export type TelemetryFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; + +export interface TelemetryEnvV1 { + version: string; + releaseChannel: TelemetryReleaseChannel; + os: TelemetryOs; + arch: TelemetryArch; + installChannel: TelemetryInstallChannel; +} + +export interface TelemetryRequestsV1 { + completed: number; + failed: number; + incomplete: number; + aborted: number; +} + +export interface TelemetryModelRowV1 { + providerKind: TelemetryProviderKind; + modelFamily: TelemetryModelFamily; + requests: number; + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export type TelemetryErrorsV1 = Partial>; + +export interface TelemetryPayloadV1 { + schemaVersion: typeof TELEMETRY_SCHEMA_VERSION; + batchId: string; + day: string; + installDayHash: string; + newInstall: boolean; + env: TelemetryEnvV1; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const PROVIDER_KIND_SET: ReadonlySet = new Set(PROVIDER_KINDS); +const MODEL_FAMILY_SET: ReadonlySet = new Set(MODEL_FAMILIES); +const INSTALL_CHANNEL_SET: ReadonlySet = new Set(INSTALL_CHANNELS); +const RELEASE_CHANNEL_SET: ReadonlySet = new Set(RELEASE_CHANNELS); +const OS_VALUE_SET: ReadonlySet = new Set(OS_VALUES); +const ARCH_VALUE_SET: ReadonlySet = new Set(ARCH_VALUES); +const FAILURE_CAUSE_SET: ReadonlySet = new Set(REQUEST_FAILURE_CAUSES); + +const TOP_LEVEL_KEYS = [ + "schemaVersion", + "batchId", + "day", + "installDayHash", + "newInstall", + "env", + "activeMinutes", + "requests", + "models", + "errors", +] as const; +const ENV_KEYS = ["version", "releaseChannel", "os", "arch", "installChannel"] as const; +const REQUESTS_KEYS = ["completed", "failed", "incomplete", "aborted"] as const; +const MODEL_ROW_KEYS = [ + "providerKind", + "modelFamily", + "requests", + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", +] as const; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX64_RE = /^[0-9a-f]{64}$/; +const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DAY_MS = 86_400_000; +const MAX_REQUEST_COUNT = 10_000_000; +const MAX_TOKEN_COUNT = 1_000_000_000_000; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function hasExactKeys(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record); + if (keys.length !== expected.length) return false; + for (const key of expected) { + if (!Object.hasOwn(record, key)) return false; + } + return true; +} + +function isBoundedInt(value: unknown, max: number): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max; +} + +export function parseCalendarUtcDayMs(day: string): number | null { + const match = DAY_RE.exec(day); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const date = Number(match[3]); + const ms = Date.UTC(year, month - 1, date); + const check = new Date(ms); + if ( + check.getUTCFullYear() !== year + || check.getUTCMonth() !== month - 1 + || check.getUTCDate() !== date + ) { + return null; + } + return ms; +} + +export function validateTelemetryPayloadV1( + input: unknown, + now: number = Date.now(), +): { ok: true; payload: TelemetryPayloadV1 } | { ok: false; error: string } { + if (!isPlainRecord(input)) return { ok: false, error: "payload must be a plain object" }; + if (!hasExactKeys(input, TOP_LEVEL_KEYS)) { + return { ok: false, error: "payload has missing or unexpected top-level keys" }; + } + if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) { + return { ok: false, error: "schemaVersion must be 1" }; + } + if (typeof input.batchId !== "string" || !UUID_RE.test(input.batchId)) { + return { ok: false, error: "batchId must be a UUID" }; + } + if (typeof input.day !== "string") { + return { ok: false, error: "day must be a YYYY-MM-DD string" }; + } + const dayMs = parseCalendarUtcDayMs(input.day); + if (dayMs === null) { + return { ok: false, error: "day must be a valid calendar date in YYYY-MM-DD format" }; + } + const nowDate = new Date(now); + const todayUtcMs = Date.UTC(nowDate.getUTCFullYear(), nowDate.getUTCMonth(), nowDate.getUTCDate()); + if (dayMs < todayUtcMs - 7 * DAY_MS || dayMs > todayUtcMs + DAY_MS) { + return { ok: false, error: "day must be within [today - 7d, today + 1d]" }; + } + if (typeof input.installDayHash !== "string" || !HEX64_RE.test(input.installDayHash)) { + return { ok: false, error: "installDayHash must be 64 lowercase hex characters" }; + } + if (typeof input.newInstall !== "boolean") { + return { ok: false, error: "newInstall must be a boolean" }; + } + if (!isPlainRecord(input.env) || !hasExactKeys(input.env, ENV_KEYS)) { + return { ok: false, error: "env has missing or unexpected keys" }; + } + const env = input.env; + if (typeof env.version !== "string" || !TELEMETRY_VERSION_RE.test(env.version)) { + return { ok: false, error: "env.version must match strict semver without build metadata" }; + } + if (typeof env.releaseChannel !== "string" || !RELEASE_CHANNEL_SET.has(env.releaseChannel)) { + return { ok: false, error: "env.releaseChannel is not in RELEASE_CHANNELS" }; + } + if (typeof env.os !== "string" || !OS_VALUE_SET.has(env.os)) { + return { ok: false, error: "env.os is not in OS_VALUES" }; + } + if (typeof env.arch !== "string" || !ARCH_VALUE_SET.has(env.arch)) { + return { ok: false, error: "env.arch is not in ARCH_VALUES" }; + } + if (typeof env.installChannel !== "string" || !INSTALL_CHANNEL_SET.has(env.installChannel)) { + return { ok: false, error: "env.installChannel is not in INSTALL_CHANNELS" }; + } + if (!isBoundedInt(input.activeMinutes, 1440)) { + return { ok: false, error: "activeMinutes must be an integer between 0 and 1440" }; + } + if (!isPlainRecord(input.requests) || !hasExactKeys(input.requests, REQUESTS_KEYS)) { + return { ok: false, error: "requests has missing or unexpected keys" }; + } + for (const key of REQUESTS_KEYS) { + if (!isBoundedInt(input.requests[key], MAX_REQUEST_COUNT)) { + return { ok: false, error: `requests.${key} must be a bounded non-negative integer` }; + } + } + if (!Array.isArray(input.models) || input.models.length > TELEMETRY_MAX_MODELS) { + return { ok: false, error: `models must be an array of at most ${TELEMETRY_MAX_MODELS} rows` }; + } + const seenPairs = new Set(); + const models: TelemetryModelRowV1[] = []; + for (const row of input.models) { + if (!isPlainRecord(row) || !hasExactKeys(row, MODEL_ROW_KEYS)) { + return { ok: false, error: "models row has missing or unexpected keys" }; + } + if (typeof row.providerKind !== "string" || !PROVIDER_KIND_SET.has(row.providerKind)) { + return { ok: false, error: "models row providerKind is not in PROVIDER_KINDS" }; + } + if (typeof row.modelFamily !== "string" || !MODEL_FAMILY_SET.has(row.modelFamily)) { + return { ok: false, error: "models row modelFamily is not in MODEL_FAMILIES" }; + } + const pairKey = `${row.providerKind}\0${row.modelFamily}`; + if (seenPairs.has(pairKey)) { + return { ok: false, error: "models rows must have unique (providerKind, modelFamily) pairs" }; + } + seenPairs.add(pairKey); + if (!isBoundedInt(row.requests, MAX_REQUEST_COUNT)) { + return { ok: false, error: "models row requests must be a bounded non-negative integer" }; + } + for (const tokenField of [ + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", + ] as const) { + if (!isBoundedInt(row[tokenField], MAX_TOKEN_COUNT)) { + return { ok: false, error: `models row ${tokenField} must be a bounded non-negative integer` }; + } + } + models.push({ + providerKind: row.providerKind as TelemetryProviderKind, + modelFamily: row.modelFamily as TelemetryModelFamily, + requests: row.requests, + inputTokens: row.inputTokens as number, + cacheReadTokens: row.cacheReadTokens as number, + cacheWriteTokens: row.cacheWriteTokens as number, + outputTokens: row.outputTokens as number, + reasoningTokens: row.reasoningTokens as number, + }); + } + if (!isPlainRecord(input.errors)) { + return { ok: false, error: "errors must be a plain object" }; + } + const errors: TelemetryErrorsV1 = {}; + for (const [cause, count] of Object.entries(input.errors)) { + if (!FAILURE_CAUSE_SET.has(cause)) { + return { ok: false, error: `errors key "${cause}" is not in REQUEST_FAILURE_CAUSES` }; + } + if (!isBoundedInt(count, MAX_REQUEST_COUNT)) { + return { ok: false, error: `errors["${cause}"] must be a bounded non-negative integer` }; + } + errors[cause as TelemetryFailureCause] = count; + } + return { + ok: true, + payload: { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: input.batchId.toLowerCase(), + day: input.day, + installDayHash: input.installDayHash, + newInstall: input.newInstall, + env: { + version: env.version, + releaseChannel: env.releaseChannel as TelemetryReleaseChannel, + os: env.os as TelemetryOs, + arch: env.arch as TelemetryArch, + installChannel: env.installChannel as TelemetryInstallChannel, + }, + activeMinutes: input.activeMinutes, + requests: { + completed: input.requests.completed as number, + failed: input.requests.failed as number, + incomplete: input.requests.incomplete as number, + aborted: input.requests.aborted as number, + }, + models, + errors, + }, + }; +} diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts new file mode 100644 index 00000000000..a69e3882c37 --- /dev/null +++ b/src/telemetry/install-id.ts @@ -0,0 +1,197 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fchmodSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; + +export const TELEMETRY_ID_FILE = "telemetry-id.json"; +export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); + +export interface TelemetryInstallIdentity { + installId: string; + createdAt: string; +} + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; +const PUBLISHED_READ_ATTEMPTS = 50; +const PUBLISHED_READ_INTERVAL_MS = 10; + +let memo: { path: string; identity: TelemetryInstallIdentity } | null = null; + +export function telemetryIdPath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_ID_FILE); +} + +function parseTelemetryId(text: string): TelemetryInstallIdentity | null { + try { + const raw = JSON.parse(text) as Record; + if (raw?.version !== 1 || typeof raw.installId !== "string" || typeof raw.createdAt !== "string") { + return null; + } + if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { + return null; + } + return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + } catch { + return null; + } +} + +/** Read-only: returns the persisted telemetry install identity, or null when absent/invalid. */ +export function readTelemetryInstallId(configDir = getConfigDir()): TelemetryInstallIdentity | null { + const path = telemetryIdPath(configDir); + if (memo?.path === path) return memo.identity; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + } catch { + return null; + } + let identity: TelemetryInstallIdentity | null; + try { + identity = parseTelemetryId(readFileSync(path, "utf8")); + } catch { + return null; + } + if (identity) memo = { path, identity }; + return identity; +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +function createExclusive(path: string, content: string): void { + const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600); + try { + if (process.platform !== "win32") { + try { fchmodSync(fd, 0o600); } catch { /* best-effort */ } + } + writeSync(fd, content); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function syncParentDirectory(path: string): void { + if (process.platform === "win32") return; + try { + const fd = openSync(dirname(path), fsConstants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } + } catch { /* best-effort */ } +} + +function pause(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function adoptPublishedIdentity(configDir: string, path: string): TelemetryInstallIdentity { + for (let attempt = 0; attempt < PUBLISHED_READ_ATTEMPTS; attempt++) { + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + stat = null; + } + if (stat && (stat.isSymbolicLink() || !stat.isFile())) break; + const identity = readTelemetryInstallId(configDir); + if (identity) return identity; + if (attempt + 1 < PUBLISHED_READ_ATTEMPTS) pause(PUBLISHED_READ_INTERVAL_MS); + } + throw new Error("telemetry identity file is not a valid regular file; remove it to rotate the identity"); +} + +/** + * Create `/telemetry-id.json` (`0o600`, exclusive no-follow) on the first send. + * Returns `{ identity, freshlyCreated }` so the caller knows when a brand-new id was minted. + */ +export function ensureTelemetryInstallId( + configDir = getConfigDir(), +): { identity: TelemetryInstallIdentity; freshlyCreated: boolean } { + const existing = readTelemetryInstallId(configDir); + if (existing) return { identity: existing, freshlyCreated: false }; + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryIdPath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + let present = true; + try { + lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + present = false; + } + if (present) { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + const identity: TelemetryInstallIdentity = { + installId: randomUUID().toLowerCase(), + createdAt: new Date().toISOString(), + }; + const content = `${JSON.stringify({ version: 1, ...identity })}\n`; + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let published = false; + try { + createExclusive(temp, content); + try { + linkSync(temp, path); + published = true; + } catch (error) { + const code = errorCode(error); + if (code === "EEXIST") { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error; + } + } finally { + try { unlinkSync(temp); } catch { /* best-effort */ } + } + if (!published) { + try { + createExclusive(path, content); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + } + syncParentDirectory(path); + memo = { path, identity }; + return { identity, freshlyCreated: true }; +} + +/** Delete `/telemetry-id.json` when telemetry is declined. */ +export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { + const path = telemetryIdPath(configDir); + if (memo?.path === path) memo = null; + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + unlinkSync(path); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + return false; + } +} + +export function computeInstallDayHash(installId: string, day: string): string { + return createHash("sha256").update(`${installId}:${day}`).digest("hex"); +} + +export function resetTelemetryInstallIdMemoForTests(): void { + memo = null; +} diff --git a/src/telemetry/payload.ts b/src/telemetry/payload.ts new file mode 100644 index 00000000000..628f434f2af --- /dev/null +++ b/src/telemetry/payload.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { packageVersion } from "../lib/package-version"; +import { detectInstallOwnership } from "../update"; +import type { DailyTelemetryAggregate } from "./aggregate"; +import { + ARCH_VALUES, + OS_VALUES, + TELEMETRY_SCHEMA_VERSION, + TELEMETRY_VERSION_RE, + validateTelemetryPayloadV1, + type TelemetryArch, + type TelemetryInstallChannel, + type TelemetryOs, + type TelemetryPayloadV1, + type TelemetryReleaseChannel, +} from "./contract"; + +export interface ResolveInstallChannelOptions { + env?: Record; + exists?: (path: string) => boolean; + detectOwnership?: () => { installer?: string }; +} + +export function resolveInstallChannel(options: ResolveInstallChannelOptions = {}): TelemetryInstallChannel { + const env = options.env ?? process.env; + if (env.OCX_DESKTOP_SUPERVISED === "1") return "desktop"; + const exists = options.exists ?? existsSync; + try { + if (exists("/.dockerenv") || exists("/run/.containerenv")) return "docker"; + } catch { /* ignore fs errors */ } + try { + const installer = (options.detectOwnership ?? detectInstallOwnership)()?.installer; + switch (installer) { + case "npm": + return "npm-global"; + case "pnpm": + return "pnpm-global"; + case "bun": + return "bun-global"; + case "mise": + return "mise"; + case "standalone": + return "standalone"; + case "source": + return "source"; + default: + return "unknown"; + } + } catch { + return "unknown"; + } +} + +export function normalizeTelemetryVersion(raw = packageVersion("0.0.0")): { + version: string; + releaseChannel: TelemetryReleaseChannel; +} { + const stripped = raw.trim().replace(/^v/, "").split("+")[0] ?? "0.0.0"; + const version = TELEMETRY_VERSION_RE.test(stripped) ? stripped : "0.0.0"; + const releaseChannel: TelemetryReleaseChannel = version.includes("-preview.") ? "preview" : "latest"; + return { version, releaseChannel }; +} + +export function resolveTelemetryOs(platform: string = process.platform): TelemetryOs { + return (OS_VALUES as readonly string[]).includes(platform) && platform !== "other" + ? (platform as TelemetryOs) + : "other"; +} + +export function resolveTelemetryArch(arch: string = process.arch): TelemetryArch { + return (ARCH_VALUES as readonly string[]).includes(arch) && arch !== "other" + ? (arch as TelemetryArch) + : "other"; +} + +export interface BuildTelemetryPayloadOptions { + aggregate: DailyTelemetryAggregate; + installDayHash: string; + newInstall: boolean; + batchId?: string; + version?: string; + releaseChannel?: TelemetryReleaseChannel; + os?: TelemetryOs; + arch?: TelemetryArch; + installChannel?: TelemetryInstallChannel; + now?: number; +} + +export function buildTelemetryPayloadV1(options: BuildTelemetryPayloadOptions): TelemetryPayloadV1 { + const versionInfo = normalizeTelemetryVersion(options.version); + const candidate: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: options.batchId ?? randomUUID(), + day: options.aggregate.day, + installDayHash: options.installDayHash, + newInstall: options.newInstall, + env: { + version: options.version ?? versionInfo.version, + releaseChannel: options.releaseChannel ?? versionInfo.releaseChannel, + os: options.os ?? resolveTelemetryOs(), + arch: options.arch ?? resolveTelemetryArch(), + installChannel: options.installChannel ?? resolveInstallChannel(), + }, + activeMinutes: options.aggregate.activeMinutes, + requests: { + completed: options.aggregate.requests.completed, + failed: options.aggregate.requests.failed, + incomplete: options.aggregate.requests.incomplete, + aborted: options.aggregate.requests.aborted, + }, + models: options.aggregate.models.map(row => ({ + providerKind: row.providerKind, + modelFamily: row.modelFamily, + requests: row.requests, + inputTokens: row.inputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + outputTokens: row.outputTokens, + reasoningTokens: row.reasoningTokens, + })), + errors: { ...options.aggregate.errors }, + }; + const validated = validateTelemetryPayloadV1(candidate, options.now); + if (!validated.ok) { + throw new Error(`Invalid telemetry payload: ${validated.error}`); + } + return validated.payload; +} diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts new file mode 100644 index 00000000000..604c535d0ec --- /dev/null +++ b/src/telemetry/scheduler.ts @@ -0,0 +1,246 @@ +import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readConfigDiagnostics } from "../config/diagnostics"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; +import type { OcxConfig } from "../types"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; +import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; +import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; + +export const TELEMETRY_STATE_FILE = "telemetry-state.json"; +export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const DAY_MS = 86_400_000; +const MAX_BACKLOG_DAYS = 7; +const STATE_RETENTION_DAYS = 30; + +export interface TelemetryState { + version: 1; + sentDays: Record; + lastSentDay: string | null; + lastSentAt: string | null; + firstReportSentForInstallId: string | null; +} + +export function telemetryStatePath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_STATE_FILE); +} + +function emptyState(): TelemetryState { + return { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + firstReportSentForInstallId: null, + }; +} + +export function readTelemetryState(configDir = getConfigDir()): TelemetryState { + const path = telemetryStatePath(configDir); + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return emptyState(); + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { + return emptyState(); + } + const sentDays: Record = {}; + for (const [day, at] of Object.entries(raw.sentDays as Record)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { + sentDays[day] = at; + } + } + return { + version: 1, + sentDays, + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + firstReportSentForInstallId: + typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, + }; + } catch { + return emptyState(); + } +} + +export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryStatePath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); + const prunedSentDays: Record = {}; + for (const [day, at] of Object.entries(state.sentDays)) { + if (day >= minKeepDay) prunedSentDays[day] = at; + } + const payload: TelemetryState = { + version: 1, + sentDays: prunedSentDays, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + firstReportSentForInstallId: state.firstReportSentForInstallId, + }; + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +export function utcMidnightMs(epochMs: number): number { + const d = new Date(epochMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} + +export function latestCompletedUtcDay(now = Date.now()): string { + return formatUtcDay(utcMidnightMs(now) - DAY_MS); +} + +export interface TelemetryTickOptions extends TelemetrySendOptions, ResolveInstallChannelOptions { + configDir?: string; + config?: Pick + readConfig?: () => Pick; + aggregateDay?: (day: string) => Promise; +} + +export interface TelemetryTickResult { + sent: boolean; + day?: string; + reason: string; + sendResult?: TelemetrySendResult; +} + +/** + * Run one telemetry tick: + * - Re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`. + * - Sends only completed UTC days on or after the UTC day of `decidedAt ?? noticeShownAt` + * (no pre-consent backfill). + * - Sends at most ONE day per tick, oldest first, at most the last 7 completed days. + */ +export async function runTelemetryTick(options: TelemetryTickOptions = {}): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const config = options.config + ?? (options.readConfig ? options.readConfig() : readConfigDiagnostics({ passive: true }).config); + const status = resolveTelemetryStatus(config, env); + if (!status.effective) { + return { sent: false, reason: status.reason }; + } + const consentTimestamp = status.decidedAt ?? status.noticeShownAt; + if (!consentTimestamp) { + return { sent: false, reason: "missing-consent-timestamp" }; + } + const consentMs = Date.parse(consentTimestamp); + if (Number.isNaN(consentMs)) { + return { sent: false, reason: "invalid-consent-timestamp" }; + } + const consentDay = formatUtcDay(consentMs); + const todayMs = utcMidnightMs(now); + const state = readTelemetryState(configDir); + + let targetDay: string | null = null; + for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { + const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); + if (candidateDay < consentDay) continue; + if (state.sentDays[candidateDay]) continue; + targetDay = candidateDay; + break; + } + if (!targetDay) { + return { sent: false, reason: "up-to-date" }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + const newInstall = state.firstReportSentForInstallId !== identity.installId; + const installDayHash = computeInstallDayHash(identity.installId, targetDay); + const aggregate = await (options.aggregateDay + ? options.aggregateDay(targetDay) + : aggregateTelemetryForDay(targetDay, { signal: options.signal })); + const payload = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + now, + installChannel: resolveInstallChannel(options), + }); + const sendResult = await sendTelemetryPayload(payload, { ...options, now }); + if (!sendResult.ok) { + return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + } + const nowIso = new Date(now).toISOString(); + state.sentDays[targetDay] = nowIso; + state.lastSentDay = targetDay; + state.lastSentAt = nowIso; + state.firstReportSentForInstallId = identity.installId; + writeTelemetryState(state, configDir, now); + return { sent: true, day: targetDay, reason: "sent", sendResult }; +} + +let startupTimer: ReturnType | null = null; +let intervalTimer: ReturnType | null = null; +let detachShutdownHook: (() => void) | null = null; +let activeTick: Promise | null = null; +let running = false; + +function triggerBackgroundTick(): void { + if (!running || activeTick) return; + activeTick = runTelemetryTick() + .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) + .finally(() => { + activeTick = null; + }); +} + +export function startTelemetryScheduler(options: { startupDelayMs?: number; intervalMs?: number } = {}): () => void { + if (isTelemetryEnvOptOut(process.env) !== null) { + return () => {}; + } + if (running) return stopTelemetryScheduler; + running = true; + detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); + const startupDelay = options.startupDelayMs ?? (30_000 + Math.floor(Math.random() * 30_000)); + const intervalMs = options.intervalMs ?? TELEMETRY_TICK_INTERVAL_MS; + startupTimer = setTimeout(() => { + startupTimer = null; + triggerBackgroundTick(); + }, startupDelay); + startupTimer.unref?.(); + intervalTimer = setInterval(() => { + triggerBackgroundTick(); + }, intervalMs); + intervalTimer.unref?.(); + return stopTelemetryScheduler; +} + +export function stopTelemetryScheduler(): void { + running = false; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } + if (detachShutdownHook) { + const detach = detachShutdownHook; + detachShutdownHook = null; + detach(); + } +} + +export function nudgeTelemetryScheduler(): void { + if (!running) return; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + const timer = setTimeout(() => { + triggerBackgroundTick(); + }, 10); + timer.unref?.(); +} diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts new file mode 100644 index 00000000000..d7a4be4b4a8 --- /dev/null +++ b/src/telemetry/sender.ts @@ -0,0 +1,63 @@ +import { signalWithTimeout } from "../lib/abort"; +import { validateTelemetryPayloadV1, type TelemetryPayloadV1 } from "./contract"; + +export const TELEMETRY_COLLECTOR_ENDPOINT = "https://telemetry.opencodex.me/v1/events"; +export const TELEMETRY_SEND_TIMEOUT_MS = 10_000; + +export interface TelemetrySendOptions { + endpoint?: string; + fetch?: typeof fetch; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +} + +export interface TelemetrySendResult { + ok: boolean; + status?: number; + error?: string; +} + +let testOverrides: TelemetrySendOptions | null = null; + +export function setTelemetrySenderOverridesForTests(overrides: TelemetrySendOptions | null): void { + testOverrides = overrides; +} + +export async function sendTelemetryPayload( + payload: TelemetryPayloadV1, + options: TelemetrySendOptions = {}, +): Promise { + const now = options.now ?? testOverrides?.now ?? Date.now(); + const validated = validateTelemetryPayloadV1(payload, now); + if (!validated.ok) { + return { ok: false, error: validated.error }; + } + const endpoint = options.endpoint ?? testOverrides?.endpoint ?? TELEMETRY_COLLECTOR_ENDPOINT; + const fetchImpl = options.fetch ?? testOverrides?.fetch ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? testOverrides?.timeoutMs ?? TELEMETRY_SEND_TIMEOUT_MS; + const parentSignal = options.signal ?? testOverrides?.signal; + + const deadline = signalWithTimeout(timeoutMs, parentSignal); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(validated.payload), + signal: deadline.signal, + }); + if (!response.ok) { + return { ok: false, status: response.status, error: `collector responded ${response.status}` }; + } + return { ok: true, status: response.status }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + deadline.cleanup(); + } +} diff --git a/src/types.ts b/src/types.ts index 8da67a3db8b..9937eeb1465 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,3 +146,5 @@ export type { CodexAccountCredentials, CodexAccountCredentialRecord, } from "./types/accounts"; + +export type { OcxTelemetryConfig, OcxTelemetryDecision } from "./types/telemetry"; diff --git a/src/types/config.ts b/src/types/config.ts index 1bc480ef5c4..87dfc40cd36 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ import type { CodexAccount } from "./accounts"; import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { OcxFallbackConfig } from "./fallback"; +import type { OcxTelemetryConfig } from "./telemetry"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow } from "./anthropic-account-pool"; export type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackKind, OcxFallbackSwitches } from "./fallback"; @@ -574,6 +575,7 @@ export interface OcxConfig { skills?: OcxSkillsConfig; /** Opt-in process-local aggregate request metrics on the authenticated management plane. */ metricsExport?: { enabled?: boolean }; + telemetry?: OcxTelemetryConfig; /** * Opt-in recovery of a managed service proxy that stays alive but stops answering /healthz: * a separate sentinel process terminates it so its supervisor restarts it (#6671). diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts new file mode 100644 index 00000000000..e9ea8039b7c --- /dev/null +++ b/src/types/telemetry.ts @@ -0,0 +1,15 @@ +export type OcxTelemetryDecision = "accepted" | "declined"; + +/** + * Anonymous product telemetry consent state. + * + * Absence of `decision` means undecided. No payload is sent until either `decision` is + * `"accepted"` or a human has seen the disclosure (`noticeShownAt` recorded via interactive + * TTY or dashboard consent session). The collector endpoint is a code constant and is never + * persisted here. + */ +export interface OcxTelemetryConfig { + decision?: OcxTelemetryDecision; + decidedAt?: string; + noticeShownAt?: string; +} diff --git a/tests/preload.ts b/tests/preload.ts index 0e66597830a..6f94c5eac4a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -76,6 +76,7 @@ process.env.OCX_TEST_HOME_GUARD = "1"; // Lets a test assert one preload per process rather than assuming Bun's scheduling. process.env.OCX_TEST_PRELOAD_PID = String(process.pid); process.env.OCX_DISABLE_UPDATE_CHECK = "1"; +process.env.OCX_TELEMETRY = "0"; process.env.OPENCODEX_KIRO_MODEL_DISCOVERY = "0"; if (!isTestHomeGuardArmed() || !protectedHomeForTests()) { From 643dd4f259bc45237cfee1ad1ad643f1eec5047b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 03/63] feat(telemetry): add management API routes, CLI commands, and consent prompts --- AGENTS_INSTALL.md | 10 + scripts/generate-ocx-skill-surface.ts | 2 +- .../ocx/references/01_management_surface.md | 18 +- .../references/01_surface_observe-system.md | 55 ++++- src/cli/capabilities-observe-system.ts | 30 +++ src/cli/config-command.ts | 11 +- src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/init.ts | 4 + src/cli/registry.ts | 10 + src/cli/star-prompt.ts | 2 + src/cli/telemetry.ts | 188 ++++++++++++++++++ src/server/management-api.ts | 7 + src/server/management/route-registry.ts | 4 + src/server/management/telemetry-routes.ts | 20 ++ src/service/cli.ts | 1 + src/telemetry/routes.ts | 172 ++++++++++++++++ 17 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 src/cli/telemetry.ts create mode 100644 src/server/management/telemetry-routes.ts create mode 100644 src/telemetry/routes.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index c4a80069fd7..e5489ddc853 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,16 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +### Telemetry + +Anonymous daily usage telemetry defaults to on only after a human has seen the +disclosure notice (in the GUI or an interactive TTY prompt). An agent must never +enable telemetry or acknowledge the telemetry disclosure on the user's behalf: +`PUT /api/telemetry`, `POST /api/telemetry/notice`, `ocx telemetry enable`, and +`ocx config set telemetry` refuse agent-driven callers with +`agent_consent_required`. Opting out (`ocx telemetry disable` or +`OCX_TELEMETRY=0`) is always permitted. + ## Where things are installed An agent asked to install, inspect, repair, or remove opencodex needs to know what is written diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index 750ea21e566..155e866656b 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -21,7 +21,7 @@ const DOMAINS = [ { name: "accounts", roots: ["account", "auth", "login", "logout"] }, { name: "agents-routing", roots: ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"] }, { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, - { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, + { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"] }, { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"] }, { name: "lab", roots: ["lab"] }, ]; diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index d49db6cfa0b..ab478e6c196 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 43 | | [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 99 | +| [observe-system](01_surface_observe-system.md) | 102 | | [access-remote](01_surface_access-remote.md) | 34 | | [lab](01_surface_lab.md) | 21 | @@ -1371,6 +1371,18 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-companion-timeline) +### `ocx telemetry status` + +[Read-oriented task](01_surface_observe-system.md#ocx-telemetry-status) + +### `ocx telemetry enable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-enable) + +### `ocx telemetry disable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-disable) + ### `ocx disconnect` [State-changing task](01_surface_access-remote.md#ocx-disconnect) @@ -1529,6 +1541,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 370 -- of those, state-changing: 228 +- declared capabilities: 373 +- of those, state-changing: 230 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 363e5d2b026..2e132e543d2 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 99. +Declared capabilities: 102. ### `ocx update` @@ -1871,3 +1871,56 @@ State-changing: no. JSON mode: `payload`. - start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic. - Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference. + +### `ocx telemetry status` + +Usage: `ocx telemetry status [--json]` + +Inspect anonymous telemetry consent status and the next daily payload preview. + +State-changing: no. JSON mode: `payload`. + +| Method | Route | +|---|---| +| GET | `/api/telemetry` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the telemetry status and preview as JSON. | + +- Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped. +- Never creates or transmits a telemetry install identifier. + +### `ocx telemetry enable` + +Usage: `ocx telemetry enable [--json]` + +Opt in to anonymous daily usage telemetry. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json. +- Refuses with agent_consent_required when called from an agent-driven session. + +### `ocx telemetry disable` + +Usage: `ocx telemetry disable [--json]` + +Opt out of anonymous daily usage telemetry and delete the local telemetry identifier. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = declined in local config.json and removes telemetry-id.json. +- Always permitted, including in agent-driven sessions. diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 9f413835d54..4692f318666 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -729,4 +729,34 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic.","Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference."], }, + { + command: ["telemetry", "status"], + usage: "ocx telemetry status [--json]", + summary: "Inspect anonymous telemetry consent status and the next daily payload preview.", + routes: [{"method": "GET", "path": "/api/telemetry"}], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the telemetry status and preview as JSON."}], + mutates: false, + json: "payload", + details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Never creates or transmits a telemetry install identifier."], + }, + { + command: ["telemetry", "enable"], + usage: "ocx telemetry enable [--json]", + summary: "Opt in to anonymous daily usage telemetry.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json.", "Refuses with agent_consent_required when called from an agent-driven session."], + }, + { + command: ["telemetry", "disable"], + usage: "ocx telemetry disable [--json]", + summary: "Opt out of anonymous daily usage telemetry and delete the local telemetry identifier.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = declined in local config.json and removes telemetry-id.json.", "Always permitted, including in agent-driven sessions."], + }, ]; diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 8cd643a8d8e..1a03189d27b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -9,6 +9,7 @@ import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; import { redactUrlForLog } from "../lib/redact"; +import { isAgentDriven } from "./agent-driven"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -258,6 +259,9 @@ export async function handleConfigCommand(argv: string[]): Promise { const raw = action === "set" ? args.shift() : undefined; if (!path || (action === "set" && raw === undefined)) throw new CliUsageError(action === "unset" ? "config path is required" : "config path and value are required", USAGE); rejectArgs(args, USAGE); + if (pathSegments(path)[0] === "telemetry") { + throw new CliUsageError("telemetry cannot be modified via ocx config; use `ocx telemetry enable` or `ocx telemetry disable`", USAGE); + } // #1835/#1838: the read used to happen OUTSIDE the mutation lock, so a concurrent // edit landing between it and the save was reverted by this whole-snapshot write. // `mutatePersistedConfig` reruns this callback against the latest validated disk @@ -332,7 +336,12 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { readCliConfigForMutation(); - const imported = validate(loadInput(path)); + const rawInput = loadInput(path); + if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + delete (rawInput as Record).telemetry; + console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); + } + const imported = validate(rawInput); withConfigMutationLockSync(() => { const client = readClientConnectionState(); if (client.kind === "connected" && (imported.runtimeRole !== "client" diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1a9fd889871..6cbe047f2a1 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -913,6 +913,10 @@ const commandRunners: Record = { const { handleCommandcodeCommand } = await import("./integrations"); return await handleCommandcodeCommand(deps.args.slice(1)); }, + telemetry: async deps => { + const { handleTelemetryCommand } = await import("./telemetry"); + return await handleTelemetryCommand(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 67780c76753..ffa5a3b711f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -102,6 +102,7 @@ Usage: ocx config [sub] Validated configuration show/get/set/import/export ocx advisor Advisor sidecar settings and context-sharing consent ocx companion Menu-bar and widget companion usage settings + ocx telemetry Anonymous usage telemetry status, preview, enable, and disable ocx lab Inspect Lab evidence and control local automation ocx codex-provider Confirm custom Codex provider routing: status|allow|revoke ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) diff --git a/src/cli/init.ts b/src/cli/init.ts index b8866eccb3e..9bb41103e09 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,6 +9,7 @@ import { redactUserPath } from "../lib/redact"; import { enrichProviderFromCatalog } from "../oauth/key-providers"; import { deriveInitProviders } from "../providers/derive"; import { pinSponsorsWithinKind } from "../providers/sponsor-order"; +import { maybeShowTelemetryPrompt } from "./telemetry"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** Parse a setup TCP port; only an empty answer selects the default. */ @@ -293,4 +294,7 @@ export async function runInit(): Promise { } finally { prompt.close(); } + if (configCreated && !process.exitCode) { + await maybeShowTelemetryPrompt(); + } } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a9f48283878..749de00e31e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -834,6 +834,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx __gui-update-worker [channel] [restart]", summary: "Hidden internal: run the GUI update worker.", }, + { + name: "telemetry", + usage: "ocx telemetry [--json]", + summary: "Inspect or change anonymous daily usage telemetry consent and preview the payload.", + details: [ + "status [--json] Show effective state, reason, install channel, last sent day, and exact preview payload.", + "enable [--json] Opt in to anonymous telemetry (refused with agent_consent_required when agent-driven).", + "disable [--json] Opt out of anonymous telemetry and delete telemetry-id.json.", + ], + }, ]; export function findCommand(name: string): CliCommandEntry | undefined { diff --git a/src/cli/star-prompt.ts b/src/cli/star-prompt.ts index 3135e86bc6b..46131ec4124 100644 --- a/src/cli/star-prompt.ts +++ b/src/cli/star-prompt.ts @@ -177,6 +177,8 @@ function printAgentDeferral(): void { */ export async function maybeShowStarPrompt(): Promise { try { + const { maybeShowTelemetryPrompt } = await import("./telemetry"); + await maybeShowTelemetryPrompt(); let isTty = false; try { isTty = depsForTests?.isTty ? depsForTests.isTty() : isatty(0) && isatty(1); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts new file mode 100644 index 00000000000..60ffd2f652d --- /dev/null +++ b/src/cli/telemetry.ts @@ -0,0 +1,188 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { isatty } from "node:tty"; +import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { resolveTelemetryStatus } from "../telemetry/consent"; +import { deleteTelemetryInstallId } from "../telemetry/install-id"; +import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; +import { currentVersion } from "../update/index"; +import { isAgentDriven } from "./agent-driven"; +import { interactiveConfirm } from "./interactive-confirm"; +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx telemetry [status] [--json] + ocx telemetry enable [--json] + ocx telemetry disable [--json]`; + +export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; +const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export function isTelemetryDeferralCurrent(record: string | null, version: string, now: number): boolean { + if (!record) return false; + const match = /^(\S+)\s+(\S+)\s*$/.exec(record.trim()); + if (!match) return false; + const at = Date.parse(match[1]!); + if (Number.isNaN(at)) return false; + const age = now - at; + if (version !== "?" && match[2] === version) return age >= 0; + return age >= 0 && age < DEFERRAL_MAX_AGE_MS; +} + +export interface TelemetryPromptTestDeps { + interactiveConfirm?: typeof interactiveConfirm; + isTty?: () => boolean; +} + +let promptDepsForTests: TelemetryPromptTestDeps | null = null; + +export function setTelemetryPromptDepsForTests(deps: TelemetryPromptTestDeps | null): void { + promptDepsForTests = deps; +} + +async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { + const nowIso = new Date().toISOString(); + const dir = getConfigDir(); + await withConfigDirHardeningSettled(dir, () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + return { changed, value: next }; + }), + ); + if (decision === "declined") { + deleteTelemetryInstallId(dir); + } +} + +/** + * One-time telemetry consent prompt for interactive starts (`ocx start`, `ocx service install`, + * `ocx init`). Under an agent-driven session, non-TTY, or `OCX_SERVICE=1`, prints a one-line + * notice at most once per version and records neither `noticeShownAt` nor `decision`. + */ +export async function maybeShowTelemetryPrompt(): Promise { + try { + const diagnostics = readConfigDiagnostics({ passive: true }); + const status = resolveTelemetryStatus(diagnostics.config, process.env); + if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { + return; + } + let isTty = false; + try { + isTty = promptDepsForTests?.isTty ? promptDepsForTests.isTty() : isatty(0) && isatty(1); + } catch { + /* best-effort */ + } + const dir = getConfigDir(); + if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { + const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); + let record: string | null = null; + try { + if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + } catch { /* best-effort */ } + const version = currentVersion(); + if (isTelemetryDeferralCurrent(record, version, Date.now())) return; + console.log( + " ℹ️ Anonymous telemetry is awaiting human consent (run 'ocx telemetry status' or open the dashboard; disable anytime with 'ocx telemetry disable' or OCX_TELEMETRY=0).", + ); + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + recordOwnedConfigPath(dir, deferralPath); + writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + } catch { /* best-effort */ } + return; + } + const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; + const yes = await ask({ + question: "\n Share anonymous usage statistics? [Y/n]", + defaultYes: true, + }); + await persistTelemetryDecision(yes ? "accepted" : "declined"); + } catch { + /* never disrupt startup or setup */ + } +} + +function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { + return [ + `Telemetry: ${snapshot.effective ? "enabled" : "disabled"} (${snapshot.reason})`, + `Decision: ${snapshot.decision ?? "undecided"}`, + ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), + `Install channel: ${snapshot.installChannel}`, + `Last sent day: ${snapshot.lastSentDay ?? "never"}`, + `Preview (${snapshot.preview.day}):`, + JSON.stringify(snapshot.preview, null, 2), + ]; +} + +async function readLiveOrLocalSnapshot(deps: RuntimeApiDeps): Promise { + try { + return await runtimeRequest("/api/telemetry", {}, deps) as TelemetryStatusSnapshot; + } catch { + const config = readConfigDiagnostics({ passive: true }).config; + return await buildTelemetryStatusSnapshot(config); + } +} + +export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const sub = (args.shift() ?? "status").toLowerCase(); + if (sub === "enable" && isAgentDriven()) { + const error = { + ok: false, + code: "agent_consent_required", + error: "Refused: telemetry consent must be enabled by a human user in their own terminal or dashboard.", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } + return runCliAction(async () => { + if (args.includes("--json")) { + throw new CliUsageError("telemetry flags may only be specified once", USAGE); + } + if (sub === "status") { + rejectArgs(args, USAGE); + const snapshot = await readLiveOrLocalSnapshot(deps); + printData(snapshot, wantsJson, formatStatusLines(snapshot)); + return; + } + if (sub === "enable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("accepted"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, ["Anonymous telemetry enabled."]); + return; + } + if (sub === "disable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("declined"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, [ + "Anonymous telemetry disabled and local telemetry identifier removed.", + ]); + return; + } + throw new CliUsageError(`unknown telemetry command ${sub}`, USAGE); + }); +} + +export const TELEMETRY_USAGE = USAGE; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 58cb7d08c7a..5568ecaa2c2 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -238,6 +238,12 @@ async function handleLinkRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/telemetry")) return null; + const { handleTelemetryRoutes } = await import("./management/telemetry-routes"); + return handleTelemetryRoutes(ctx); +} + export async function handleManagementAPI( req: Request, url: URL, @@ -393,6 +399,7 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleTelemetryRoutesOnDemand(ctx)) ?? (await handleUsageTimelineRoutes(ctx)) ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 6655a587d94..119c45f0156 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -451,6 +451,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false }, { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true }, + // server/management/telemetry-routes + { method: "GET", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: false }, + { method: "PUT", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: changing telemetry consent over HTTP requires a human dashboard session; CLI callers use ocx telemetry enable/disable." } }, + { method: "POST", path: "/api/telemetry/notice", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "Recording that the disclosure notice was seen activates default-on telemetry and requires a human dashboard session." } }, // --- Further routes an equality scan of their own file cannot see. --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/src/server/management/telemetry-routes.ts b/src/server/management/telemetry-routes.ts new file mode 100644 index 00000000000..3739c5380b7 --- /dev/null +++ b/src/server/management/telemetry-routes.ts @@ -0,0 +1,20 @@ +import type { ManagementContext } from "./context"; +import { + handleGetTelemetryRoute, + handlePostTelemetryNoticeRoute, + handlePutTelemetryRoute, +} from "../../telemetry/routes"; + +export async function handleTelemetryRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname === "/api/telemetry" && req.method === "GET") { + return handleGetTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry" && req.method === "PUT") { + return handlePutTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry/notice" && req.method === "POST") { + return handlePostTelemetryNoticeRoute(ctx); + } + return null; +} diff --git a/src/service/cli.ts b/src/service/cli.ts index 3a1c198055d..4daf72dce17 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,6 +362,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise | null | undefined, + options: { now?: number; env?: Record; configDir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const status = resolveTelemetryStatus(config, env); + const installChannel = resolveInstallChannel({ env }); + const state = readTelemetryState(configDir); + const day = latestCompletedUtcDay(now); + const aggregate = await aggregateTelemetryForDay(day); + const identity = readTelemetryInstallId(configDir); + const installDayHash = identity + ? computeInstallDayHash(identity.installId, day) + : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; + const newInstall = identity + ? state.firstReportSentForInstallId !== identity.installId + : true; + const preview = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + installChannel, + now, + }); + return { + ...status, + installChannel, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + preview, + }; +} + +function consentRefusedResponse(ctx: ManagementContext): Response { + return jsonResponse( + { + ok: false, + code: "agent_consent_required", + error: "Telemetry consent requires a human dashboard session.", + message: + "Refused: this request carries no human dashboard session. Telemetry consent must be chosen by the user in the dashboard or an interactive terminal.", + }, + 403, + ctx.req, + ctx.config, + ); +} + +export async function handleGetTelemetryRoute(ctx: ManagementContext): Promise { + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse(snapshot, 200, ctx.req, ctx.config); +} + +export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return jsonResponse({ error: "invalid JSON body", code: "invalid_json" }, 400, ctx.req, ctx.config); + } + const rawDecision = body && typeof body === "object" && !Array.isArray(body) + ? (body as { decision?: unknown }).decision + : undefined; + if (rawDecision !== "accepted" && rawDecision !== "declined") { + return jsonResponse( + { error: "decision must be \"accepted\" or \"declined\"", code: "invalid_decision" }, + 400, + ctx.req, + ctx.config, + ); + } + const decision: OcxTelemetryDecision = rawDecision; + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + committed = structuredClone(next); + return { changed, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + if (decision === "declined") { + deleteTelemetryInstallId(); + } + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} + +export async function handlePostTelemetryNoticeRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + if (prev?.noticeShownAt && !Number.isNaN(Date.parse(prev.noticeShownAt))) { + committed = structuredClone(prev); + return { changed: false, value: prev }; + } + const next: OcxTelemetryConfig = { + ...(prev?.decision ? { decision: prev.decision } : {}), + ...(prev?.decidedAt ? { decidedAt: prev.decidedAt } : {}), + noticeShownAt: nowIso, + }; + fresh.telemetry = next; + committed = structuredClone(next); + return { changed: true, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} From c47ff3de3714352a512f452829afd3f564cd822c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 04/63] test(telemetry): add client telemetry test suite and structure ownership --- scripts/test-layout/layout-usage.json | 6 +- structure/INDEX.md | 5 + structure/manifest.json | 12 + structure/telemetry.md | 54 ++ .../ci-workflows/skill-ocx-generated.test.ts | 2 +- tests/cli/cli-telemetry.test.ts | 200 ++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/server/server-telemetry-routes.test.ts | 177 +++++++ tests/usage/usage-telemetry-client.test.ts | 468 ++++++++++++++++++ .../usage-telemetry-core-boundary.test.ts | 62 +++ 10 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 structure/telemetry.md create mode 100644 tests/cli/cli-telemetry.test.ts create mode 100644 tests/server/server-telemetry-routes.test.ts create mode 100644 tests/usage/usage-telemetry-client.test.ts create mode 100644 tests/usage/usage-telemetry-core-boundary.test.ts diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 0aa98678b73..29b328c527a 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -10,5 +10,9 @@ "request-log-identity-bounds.test.ts": "usage", "safe-response-headers.test.ts": "server", "turn-state-handles.test.ts": "server", - "turn-state-handles-e2e.test.ts": "server" + "turn-state-handles-e2e.test.ts": "server", + "usage-telemetry-client.test.ts": "usage", + "usage-telemetry-core-boundary.test.ts": "usage", + "server-telemetry-routes.test.ts": "server", + "cli-telemetry.test.ts": "cli" } diff --git a/structure/INDEX.md b/structure/INDEX.md index bab3fd26a1b..c2c508ce8a8 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -147,6 +147,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/t3code.md`](clients/t3code.md) | T3 Code settings.json Claude provider-instance ownership, proxy environment, custom models and loopback marker rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | +| [`telemetry.md`](telemetry.md) | Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI. | ### Tier 6 — Operations and process @@ -256,6 +257,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/status-link.ts` | [`remote-link.md`](remote-link.md) | +| `src/cli/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/cli/update-restart.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/client/hub-client.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | @@ -448,6 +450,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | +| `src/server/management/telemetry-routes.ts` | [`telemetry.md`](telemetry.md) | | `src/server/management/usage-aggregate-cache.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/messages-native-tool-guard.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | | `src/server/messages-native.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | @@ -507,6 +510,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`runtime.md`](runtime.md) | | `src/storage/` | [`runtime.md`](runtime.md) | +| `src/telemetry/` | [`telemetry.md`](telemetry.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | @@ -514,6 +518,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | +| `src/types/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/guarded-runtime-ops.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/manifest.json b/structure/manifest.json index b6da2b17b1a..9252acff98c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1426,6 +1426,18 @@ "src/oauth/api-key-pool-preserve.ts", "gui/src/account-pause-display.ts" ] + }, + { + "path": "telemetry.md", + "tier": 5, + "title": "Telemetry", + "scope": "Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI.", + "documents": [ + "src/telemetry/", + "src/types/telemetry.ts", + "src/server/management/telemetry-routes.ts", + "src/cli/telemetry.ts" + ] } ], "contracts": { diff --git a/structure/telemetry.md b/structure/telemetry.md new file mode 100644 index 00000000000..6adefcb16db --- /dev/null +++ b/structure/telemetry.md @@ -0,0 +1,54 @@ +# Telemetry + +Anonymous, default-on daily usage telemetry owned by `src/telemetry/`, `src/types/telemetry.ts`, +`src/server/management/telemetry-routes.ts`, and `src/cli/telemetry.ts`. + +## Wire contract and vocabularies + +`src/telemetry/contract.ts` is an import-free module defining `TELEMETRY_SCHEMA_VERSION = 1`, +the closed rosters (`PROVIDER_KINDS`, `MODEL_FAMILIES`, `INSTALL_CHANNELS`, `RELEASE_CHANNELS`, +`OS_VALUES`, `ARCH_VALUES`, `REQUEST_OUTCOME_CLASSES`, `REQUEST_FAILURE_CAUSES`), and +`validateTelemetryPayloadV1`. Every level of the payload rejects unknown keys, non-roster strings, +out-of-window or non-calendar `day` values, semver build metadata, and more than 64 model rows. + +## Consent and agent boundary + +`src/types/telemetry.ts` defines `OcxTelemetryConfig` (`decision`, `decidedAt`, `noticeShownAt`), +validated in `src/config/schema/leaf-validators.ts`, `src/config/schema/config-schema.ts`, +`src/config/diagnostics.ts`, `src/config/load-degrade.ts`, and adopted into live server state +via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +`src/telemetry/consent.ts` (`resolveTelemetryStatus`) evaluates precedence in order: +`OCX_TELEMETRY` (`0`, `false`, `off`, `no`), `DO_NOT_TRACK` (`1`, `true`, `yes`), +`decision === "declined"`, `decision === "accepted"`, undecided with `noticeShownAt` +(`default-on`), and undecided without notice (`awaiting-notice`). + +Only a human can transition an undecided install to active sending: + +- `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated + from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) + require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. +- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in + `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always + permitted. +- Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and + `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice + and record neither `noticeShownAt` nor `decision`. + +## Identity, aggregation, and scheduling + +`src/telemetry/install-id.ts` manages `/telemetry-id.json` (`0o600`, exclusive +no-follow creation) on the first send and deletes it when telemetry is declined. Only +`sha256(installId + ":" + day)` leaves the machine. + +`src/telemetry/aggregate.ts` scans `usage.jsonl` through `src/usage/ledger-scanner.ts` for one +completed UTC day, mapping providers to `PROVIDER_KINDS`, models to `MODEL_FAMILIES`, outcomes +via `src/usage/request-outcome.ts`, and decomposing usage into five disjoint token buckets +(`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `reasoningTokens`) whose +sum matches `usageDisplayTotalTokens` in `src/usage/totals.ts`. + +`src/telemetry/payload.ts` resolves the install channel and validates the built payload with +`validateTelemetryPayloadV1`. `src/telemetry/sender.ts` posts to the constant collector URL +with a 10-second `signalWithTimeout`. `src/telemetry/scheduler.ts` runs an unref'd background +timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on +each tick and sending at most one completed post-consent day per tick. diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index bfa715af2e0..a82ecb33381 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -14,7 +14,7 @@ const GROUPS: Record = { lifecycle: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"], "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], "agents-routing": ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], - "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], + "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"], "access-remote": ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"], lab: ["lab"], }; const FILENAMES = [INDEX, ...Object.keys(GROUPS).map(domain => `01_surface_${domain}.md`)]; diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts new file mode 100644 index 00000000000..ece7f41245a --- /dev/null +++ b/tests/cli/cli-telemetry.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { + TELEMETRY_DEFERRAL_FILE, + handleTelemetryCommand, + maybeShowTelemetryPrompt, + setTelemetryPromptDepsForTests, +} from "../../src/cli/telemetry"; +import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { + ensureTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const AGENT_ENV_VARS = [ + "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT", + "CODEX_THREAD_ID", "CODEX_SHELL", "CODEX_CI", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", + "CURSOR_TRACE_ID", "CURSOR_SESSION_TOKEN", "CURSOR_AGENT", + "AIDER_CHAT", "OPENCODE_BIN_PATH", "GEMINI_CLI", + "REPL_ID", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION", "CODESPACES", +] as const; + +describe("ocx telemetry CLI and interactive prompt", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + const savedAgentEnv = new Map(); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-cli-telemetry-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + for (const key of AGENT_ENV_VARS) { + savedAgentEnv.set(key, process.env[key]); + delete process.env[key]; + } + resetTelemetryInstallIdMemoForTests(); + setTelemetryPromptDepsForTests(null); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }, null, 2) + "\n", + "utf8", + ); + }); + + afterEach(() => { + setTelemetryPromptDepsForTests(null); + resetTelemetryInstallIdMemoForTests(); + for (const [key, value] of savedAgentEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + test("status --json falls back to local snapshot when proxy is unreachable", async () => { + const lines: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + try { + const code = await handleTelemetryCommand(["status", "--json"], { + fetch: (async () => { throw new Error("connect ECONNREFUSED"); }) as unknown as typeof fetch, + }); + expect(code).toBe(0); + const parsed = JSON.parse(lines.join("\n")) as { effective: boolean; reason: string; preview: { schemaVersion: number } }; + expect(parsed.effective).toBe(false); + expect(parsed.reason).toBe("awaiting-notice"); + expect(parsed.preview.schemaVersion).toBe(1); + } finally { + log.mockRestore(); + } + }); + + test("enable and disable mutate config.json; enable refuses when agent-driven while disable succeeds", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const enableCode = await handleTelemetryCommand(["enable", "--json"]); + expect(enableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); + + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + // Agent-driven session: + process.env.CODEX_THREAD_ID = "agent-123"; + log.mockClear(); + const refusedCode = await handleTelemetryCommand(["enable", "--json"]); + expect(refusedCode).toBe(1); + expect(log.mock.calls.map(c => String(c[0])).join("\n")).toContain("agent_consent_required"); + + // Disable is always allowed, even when agent-driven: + const disableCode = await handleTelemetryCommand(["disable", "--json"]); + expect(disableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + expect(existsSync(telemetryIdPath(home))).toBe(false); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test("ocx config set/unset refuse telemetry and import drops telemetry when agent-driven", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); + expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); + + const importFile = join(home, "imported.json"); + writeFileSync( + importFile, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + process.env.CODEX_THREAD_ID = "agent-import"; + expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + } finally { + log.mockRestore(); + warn.mockRestore(); + err.mockRestore(); + } + }); + + test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + process.env.CODEX_THREAD_ID = "agent-start"; + setTelemetryPromptDepsForTests({ isTty: () => true }); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(1); + expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Second agent-driven run on the same version stays quiet: + log.mockClear(); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Interactive human run prompts with defaultYes: true and records accepted: + delete process.env.CODEX_THREAD_ID; + let observedDefaultYes: boolean | undefined; + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + observedDefaultYes = opts.defaultYes; + return opts.defaultYes; + }, + }); + await maybeShowTelemetryPrompt(); + expect(observedDefaultYes).toBe(true); + const telemetry = readConfigDiagnostics().config.telemetry; + expect(telemetry?.decision).toBe("accepted"); + expect(typeof telemetry?.decidedAt).toBe("string"); + expect(typeof telemetry?.noticeShownAt).toBe("string"); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).telemetry.decision).toBe("accepted"); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71a17ebfca7..46504f81156 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1622,5 +1622,6 @@ "messages-sse-baseline-contract.test.ts": "claude-integration", "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", - "sse-payload-rewrite-continuation.test.ts": "responses" + "sse-payload-rewrite-continuation.test.ts": "responses", + "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" } diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts new file mode 100644 index 00000000000..7c8814ec7e6 --- /dev/null +++ b/tests/server/server-telemetry-routes.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementPrincipal } from "../../src/server/management-auth"; +import type { ManagementRequestIngress } from "../../src/server/management/context"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("/api/telemetry management routes", () => { + let home: string; + let config: OcxConfig; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-routes-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + resetTelemetryInstallIdMemoForTests(); + config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + writeFileSync(join(home, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8"); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + async function call( + method: string, + pathname: string, + options: { + body?: unknown; + principal?: ManagementPrincipal; + ingress?: ManagementRequestIngress; + } = {}, + ) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const req = new Request(url, { + method, + headers: { + host: "127.0.0.1:10100", + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }); + const res = await handleManagementAPI( + req, + url, + config, + {}, + options.principal ?? "admin-token", + undefined, + options.ingress ?? { trustedLoopback: true }, + ); + expect(res).not.toBeNull(); + return { + status: res!.status, + body: (await res!.json()) as Record, + }; + } + + test("GET /api/telemetry returns status and preview with placeholder hash without creating an id", async () => { + const res = await call("GET", "/api/telemetry"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + effective: false, + reason: "awaiting-notice", + decision: null, + lastSentDay: null, + }); + const preview = res.body.preview as { installDayHash: string; schemaVersion: number }; + expect(preview.schemaVersion).toBe(1); + expect(preview.installDayHash).toBe(TELEMETRY_PREVIEW_HASH_PLACEHOLDER); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); + + test("PUT /api/telemetry and POST /api/telemetry/notice refuse admin-token and remembered-admin sessions", async () => { + for (const [principal, guiSessionIssuance] of [ + ["admin-token", null], + ["gui-session", "remembered-admin"], + ] as const) { + const putRes = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(putRes.status).toBe(403); + expect(putRes.body.code).toBe("agent_consent_required"); + + const noticeRes = await call("POST", "/api/telemetry/notice", { + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(noticeRes.status).toBe(403); + expect(noticeRes.body.code).toBe("agent_consent_required"); + } + expect(config.telemetry).toBeUndefined(); + }); + + test("POST /api/telemetry/notice accepts a human dashboard session and is idempotent", async () => { + const first = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + ok: true, + effective: true, + reason: "default-on", + decision: null, + }); + const firstNoticeAt = first.body.noticeShownAt as string; + expect(typeof firstNoticeAt).toBe("string"); + + const second = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(second.status).toBe(200); + expect(second.body.noticeShownAt).toBe(firstNoticeAt); + }); + + test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + const accepted = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(accepted.status).toBe(200); + expect(accepted.body).toMatchObject({ + ok: true, + effective: true, + reason: "accepted", + decision: "accepted", + }); + expect(config.telemetry?.decision).toBe("accepted"); + + const declined = await call("PUT", "/api/telemetry", { + body: { decision: "declined" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(declined.status).toBe(200); + expect(declined.body).toMatchObject({ + ok: true, + effective: false, + reason: "declined", + decision: "declined", + }); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts new file mode 100644 index 00000000000..e8cee325640 --- /dev/null +++ b/tests/usage/usage-telemetry-client.test.ts @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; +import { adoptPersistedTelemetry, armClaudeCodeBaseline } from "../../src/config/live-reconcile"; +import { malformedTelemetryWarning } from "../../src/config/load-degrade"; +import { + aggregateTelemetryEntriesForDay, + aggregateTelemetryForDay, + classifyModelFamily, + classifyProviderKind, + disjointTelemetryTokens, +} from "../../src/telemetry/aggregate"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { + ARCH_VALUES, + INSTALL_CHANNELS, + MODEL_FAMILIES, + OS_VALUES, + PROVIDER_KINDS, + RELEASE_CHANNELS, + REQUEST_FAILURE_CAUSES as TELEMETRY_FAILURE_CAUSES, + REQUEST_OUTCOME_CLASSES as TELEMETRY_OUTCOME_CLASSES, + TELEMETRY_SCHEMA_VERSION, + validateTelemetryPayloadV1, + type TelemetryPayloadV1, +} from "../../src/telemetry/contract"; +import { + computeInstallDayHash, + deleteTelemetryInstallId, + ensureTelemetryInstallId, + readTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; +import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; +import type { OcxConfig } from "../../src/types"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; +import { REQUEST_FAILURE_CAUSES } from "../../src/usage/telemetry-contract"; +import { usageDisplayTotalTokens } from "../../src/usage/totals"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const NOW = Date.parse("2026-11-07T12:00:00.000Z"); + +describe("telemetry contract parity and validation", () => { + test("contract module is import-free and pins outcome and failure-cause rosters", () => { + const src = readFileSync(repoPath("src", "telemetry", "contract.ts"), "utf8"); + expect(src).not.toMatch(/^\s*(?:import|export\s+.*from)\s+/m); + expect([...TELEMETRY_OUTCOME_CLASSES]).toEqual([...REQUEST_OUTCOME_CLASSES]); + expect([...TELEMETRY_FAILURE_CAUSES]).toEqual([...REQUEST_FAILURE_CAUSES]); + expect(PROVIDER_KINDS).toContain("anthropic"); + expect(MODEL_FAMILIES).toContain("claude-opus"); + expect(INSTALL_CHANNELS).toContain("npm-global"); + expect(RELEASE_CHANNELS).toEqual(["latest", "preview"]); + expect(OS_VALUES).toEqual(["darwin", "linux", "win32", "other"]); + expect(ARCH_VALUES).toEqual(["x64", "arm64", "other"]); + }); + + test("validateTelemetryPayloadV1 accepts a canonical payload and rejects extra keys, unknown roster values, semver metadata, and bad dates", () => { + const base: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: "11111111-2222-4333-8444-555555555555", + day: "2026-11-06", + installDayHash: "a".repeat(64), + newInstall: false, + env: { + version: "2.40.1", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }, + activeMinutes: 312, + requests: { completed: 840, failed: 12, incomplete: 3, aborted: 5 }, + models: [ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 400, + inputTokens: 1000, + cacheReadTokens: 2000, + cacheWriteTokens: 500, + outputTokens: 300, + reasoningTokens: 100, + }, + ], + errors: { "rate-limit": 7 }, + }; + + expect(validateTelemetryPayloadV1(base, NOW)).toEqual({ ok: true, payload: base }); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(true); + + expect(validateTelemetryPayloadV1({ ...base, extra: 1 }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, extra: 1 } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1+build.9" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, installChannel: "brew" as never } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-02-30" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-10-20" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-11-09" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, errors: { "unknown-cause": 1 } as never }, NOW).ok).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [{ ...base.models[0]!, providerKind: "custom-vendor" as never }] }, + NOW, + ).ok, + ).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [base.models[0]!, base.models[0]!] }, + NOW, + ).ok, + ).toBe(false); + }); +}); + +describe("consent status matrix and config schema wiring", () => { + test("resolveTelemetryStatus follows the precedence table", () => { + expect(resolveTelemetryStatus(undefined, {})).toEqual({ + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt: null, + noticeShownAt: null, + envOverride: null, + }); + + expect(resolveTelemetryStatus({ noticeShownAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "default-on", + decision: null, + }); + + expect(resolveTelemetryStatus({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "accepted", + decision: "accepted", + }); + + expect( + resolveTelemetryStatus( + { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" }, + {}, + ), + ).toMatchObject({ + effective: false, + reason: "declined", + decision: "declined", + }); + + for (const val of ["0", "false", "off", "no", " FALSE "]) { + expect( + resolveTelemetryStatus({ decision: "accepted" }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), + ).toMatchObject({ + effective: false, + reason: "env-ocx-telemetry", + envOverride: "OCX_TELEMETRY", + }); + } + + for (const val of ["1", "true", "yes", " TRUE "]) { + expect(resolveTelemetryStatus({ decision: "accepted" }, { DO_NOT_TRACK: val })).toMatchObject({ + effective: false, + reason: "env-do-not-track", + envOverride: "DO_NOT_TRACK", + }); + } + }); + + test("config diagnostics, load-degrade, and live-reconcile handle telemetry config", () => { + expect(telemetryConfigError({ telemetry: { decision: "accepted" } })).toBeNull(); + expect(telemetryConfigError({ telemetry: { endpoint: "https://evil.example" } })).toContain("schema_invalid: telemetry"); + expect(malformedTelemetryWarning({ telemetry: { decision: "maybe" } })).toContain("telemetry.decision ignored"); + + const live = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; + armClaudeCodeBaseline(live); + adoptPersistedTelemetry(live, { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + expect(live.telemetry).toEqual({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + adoptPersistedTelemetry(live, undefined); + expect(live.telemetry).toBeUndefined(); + }); +}); + +describe("install identity, aggregation, disjoint tokens, and scheduler", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-unit-")); + process.env.OPENCODEX_HOME = home; + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + removeTreeWithRetry(home); + }); + + test("telemetry-id.json is created with 0o600 mode and deleted on decline", () => { + expect(readTelemetryInstallId(home)).toBeNull(); + const first = ensureTelemetryInstallId(home); + expect(first.freshlyCreated).toBe(true); + const idFile = telemetryIdPath(home); + expect(existsSync(idFile)).toBe(true); + if (process.platform !== "win32") { + expect(statSync(idFile).mode & 0o777).toBe(0o600); + } + const second = ensureTelemetryInstallId(home); + expect(second.freshlyCreated).toBe(false); + expect(second.identity.installId).toBe(first.identity.installId); + + const hashA = computeInstallDayHash(first.identity.installId, "2026-11-05"); + const hashB = computeInstallDayHash(first.identity.installId, "2026-11-06"); + expect(hashA).toMatch(/^[0-9a-f]{64}$/); + expect(hashA).not.toBe(hashB); + + expect(deleteTelemetryInstallId(home)).toBe(true); + expect(existsSync(idFile)).toBe(false); + expect(readTelemetryInstallId(home)).toBeNull(); + }); + + test("aggregates a fixture ledger and preserves the disjoint token bucket identity", async () => { + const day = "2026-11-06"; + const baseTs = Date.parse("2026-11-06T10:00:00.000Z"); + const fixtureEntries: PersistedUsageEntry[] = [ + { + requestId: "req-1", + timestamp: baseTs + 5_000, + provider: "anthropic-p123456", + model: "claude-opus-4-6", + status: 200, + terminalStatus: "completed", + durationMs: 1200, + usageStatus: "reported", + usage: { + inputTokens: 1000, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + }, + }, + { + requestId: "req-2", + timestamp: baseTs + 25_000, + provider: "my-custom-secret-provider", + model: "gpt-5.3-codex", + status: 429, + terminalStatus: "failed", + failureCause: "rate-limit", + durationMs: 300, + usageStatus: "reported", + usage: { + inputTokens: 500, + outputTokens: 50, + }, + }, + { + requestId: "req-3", + timestamp: baseTs + 90_000, + provider: "openai", + model: "o3-mini", + status: 499, + closeReason: "client_cancel", + failureCause: "client-cancelled", + durationMs: 100, + usageStatus: "unreported", + }, + { + requestId: "req-other-day", + timestamp: Date.parse("2026-11-05T23:59:59.000Z"), + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 100, + usageStatus: "reported", + usage: { inputTokens: 9999, outputTokens: 9999 }, + }, + ]; + + writeFileSync( + join(home, "usage.jsonl"), + fixtureEntries.map(row => JSON.stringify(row)).join("\n") + "\n", + "utf8", + ); + + for (const entry of fixtureEntries) { + if (!entry.usage) continue; + const buckets = disjointTelemetryTokens(entry.usage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(entry.usage)!); + } + + const agg = await aggregateTelemetryForDay(day); + expect(agg.activeMinutes).toBe(2); + expect(agg.requests).toEqual({ completed: 1, failed: 1, incomplete: 0, aborted: 1 }); + expect(agg.errors).toEqual({ "rate-limit": 1, "client-cancelled": 1 }); + expect(agg.models).toEqual([ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 1, + inputTokens: 250, + cacheReadTokens: 600, + cacheWriteTokens: 150, + outputTokens: 280, + reasoningTokens: 120, + }, + { + providerKind: "other", + modelFamily: "gpt-5", + requests: 1, + inputTokens: 500, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 50, + reasoningTokens: 0, + }, + { + providerKind: "openai", + modelFamily: "o-series", + requests: 1, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + ]); + expect(JSON.stringify(agg)).not.toContain("my-custom-secret-provider"); + expect(classifyProviderKind("google-vertex")).toBe("vertex"); + expect(classifyModelFamily("deepseek/deepseek-r1")).toBe("deepseek"); + expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); + }); + + test("installChannel mapping covers desktop, docker, and package managers", () => { + expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); + expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "npm" }) })).toBe("npm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "pnpm" }) })).toBe("pnpm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "bun" }) })).toBe("bun-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mise" }) })).toBe("mise"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "standalone" }) })).toBe("standalone"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "source" }) })).toBe("source"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mystery" }) })).toBe("unknown"); + }); + + test("scheduler sends nothing when not effective, skips pre-consent days, and sends at most one day per tick oldest first", async () => { + const sentPayloads: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentPayloads.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + // 1. Undecided without notice -> nothing sent, no id created. + const resAwaiting = await runTelemetryTick({ + configDir: home, + config: {}, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(resAwaiting.sent).toBe(false); + expect(resAwaiting.reason).toBe("awaiting-notice"); + expect(readTelemetryInstallId(home)).toBeNull(); + expect(sentPayloads).toHaveLength(0); + + // 2. Consent given on 2026-11-05 -> 2026-11-04 and earlier are never backfilled; + // first tick sends 2026-11-05 (oldest post-consent day) with newInstall=true, + // second tick sends 2026-11-06 with newInstall=false, + // third tick finds nothing left. + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-05T15:30:00.000Z", + noticeShownAt: "2026-11-05T15:30:00.000Z", + }, + }; + const tick1 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick1).toMatchObject({ sent: true, day: "2026-11-05" }); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0]!.day).toBe("2026-11-05"); + expect(sentPayloads[0]!.newInstall).toBe(true); + + const tick2 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick2).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentPayloads).toHaveLength(2); + expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.newInstall).toBe(false); + + const tick3 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("sendTelemetryPayload enforces its timeout and does not retry", async () => { + const agg = aggregateTelemetryEntriesForDay("2026-11-06", []); + const payload = buildTelemetryPayloadV1({ + aggregate: agg, + installDayHash: "b".repeat(64), + newInstall: false, + now: NOW, + }); + let calls = 0; + const slowFetch = ((_url: string | URL | Request, init?: RequestInit) => { + calls += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted by timeout")); + }); + }); + }) as unknown as typeof fetch; + const result = await sendTelemetryPayload(payload, { + fetch: slowFetch, + timeoutMs: 25, + now: NOW, + }); + expect(result.ok).toBe(false); + expect(calls).toBe(1); + }); + + test("readConfigDiagnostics passively reads persisted telemetry config", () => { + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + const diag = readConfigDiagnostics({ passive: true }); + expect(diag.error).toBeNull(); + expect(diag.config.telemetry?.decision).toBe("accepted"); + }); +}); diff --git a/tests/usage/usage-telemetry-core-boundary.test.ts b/tests/usage/usage-telemetry-core-boundary.test.ts new file mode 100644 index 00000000000..0088bef8342 --- /dev/null +++ b/tests/usage/usage-telemetry-core-boundary.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; + +const PROTECTED = [ + "src/router.ts", + "src/server/lifecycle.ts", + "src/server/responses/core.ts", + "src/server/management-api.ts", +] as const; + +const repoRoot = resolveRepoRoot(); + +const STATIC_IMPORT_RE = + /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/gm; + +function resolveSpec(spec: string, fromFile: string): string | null { + if (!spec.startsWith(".")) return null; + const base = resolve(dirname(fromFile), spec); + for (const candidate of [`${base}.ts`, join(base, "index.ts"), `${base}.mts`, `${base}.mjs`]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function firstTelemetryPath(entry: string): string[] | null { + const start = resolve(repoRoot, entry); + const previous = new Map([[start, null]]); + const queue = [start]; + while (queue.length > 0) { + const current = queue.shift()!; + if (!existsSync(current)) continue; + const source = readFileSync(current, "utf8"); + STATIC_IMPORT_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = STATIC_IMPORT_RE.exec(source)) !== null) { + const spec = match[1] ?? match[2] ?? match[3]; + if (!spec) continue; + const next = resolveSpec(spec, current); + if (!next || previous.has(next)) continue; + previous.set(next, current); + if (next.replaceAll("\\", "/").includes("/src/telemetry/")) { + const chain: string[] = []; + let node: string | null = next; + while (node) { + chain.push(node.slice(repoRoot.length + 1).replaceAll("\\", "/")); + node = previous.get(node) ?? null; + } + return chain.reverse(); + } + queue.push(next); + } + } + return null; +} + +describe("telemetry core load-time boundary", () => { + test.each(PROTECTED)("%s has no load-time import path to src/telemetry/", entry => { + expect(firstTelemetryPath(entry)).toBeNull(); + }); +}); From b5eddc370f4b3a399aab57801c557a179e19b7e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:56:25 +0900 Subject: [PATCH 05/63] fix(telemetry): remove state installId leak, harden writes, and preserve declined config on agent import --- src/cli/config-command.ts | 13 +- src/cli/telemetry.ts | 10 +- src/service/cli.ts | 1 - src/telemetry/aggregate.ts | 8 +- src/telemetry/install-id.ts | 47 +++++++- src/telemetry/routes.ts | 2 +- src/telemetry/scheduler.ts | 41 +++++-- tests/cli/cli-telemetry.test.ts | 84 +++++++++++-- tests/server/server-telemetry-routes.test.ts | 25 +++- tests/usage/usage-telemetry-client.test.ts | 119 ++++++++++++++++++- 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 1a03189d27b..001496c574b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -335,9 +335,10 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { - readCliConfigForMutation(); + const current = readCliConfigForMutation(); + const agentDriven = isAgentDriven(); const rawInput = loadInput(path); - if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + if (agentDriven && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { delete (rawInput as Record).telemetry; console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); } @@ -348,6 +349,14 @@ export async function handleConfigCommand(argv: string[]): Promise { || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } + if (agentDriven) { + const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; + if (persistedTelemetry !== undefined) { + imported.telemetry = structuredClone(persistedTelemetry); + } else { + delete imported.telemetry; + } + } saveConfig(imported); }); }); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 60ffd2f652d..6433278018c 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; @@ -95,7 +96,10 @@ export async function maybeShowTelemetryPrompt(): Promise { const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); let record: string | null = null; try { - if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + const stat = lstatSync(deferralPath); + if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1024) { + record = readFileSync(deferralPath, "utf8"); + } } catch { /* best-effort */ } const version = currentVersion(); if (isTelemetryDeferralCurrent(record, version, Date.now())) return; @@ -105,7 +109,7 @@ export async function maybeShowTelemetryPrompt(): Promise { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); recordOwnedConfigPath(dir, deferralPath); - writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(deferralPath, `${new Date().toISOString()} ${version}\n`); } catch { /* best-effort */ } return; } diff --git a/src/service/cli.ts b/src/service/cli.ts index 4daf72dce17..3a1c198055d 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,7 +362,6 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise> = Object.freeze({ openai: "openai", @@ -183,6 +184,11 @@ export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTe } const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); const outputTokens = Math.max(0, totalOutput - reasoningTokens); + const displayTotal = nonNegInt(usageDisplayTotalTokens(usage)); + const accounted = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens; + if (displayTotal > accounted) { + inputTokens += displayTotal - accounted; + } return { inputTokens, cacheReadTokens, diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index a69e3882c37..2c2a7dd1e0e 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -13,6 +13,7 @@ import { writeSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -22,6 +23,7 @@ export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); export interface TelemetryInstallIdentity { installId: string; createdAt: string; + firstReportSent?: boolean; } const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -44,7 +46,11 @@ function parseTelemetryId(text: string): TelemetryInstallIdentity | null { if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { return null; } - return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + return { + installId: raw.installId.toLowerCase(), + createdAt: raw.createdAt, + firstReportSent: raw.firstReportSent === true, + }; } catch { return null; } @@ -141,6 +147,7 @@ export function ensureTelemetryInstallId( const identity: TelemetryInstallIdentity = { installId: randomUUID().toLowerCase(), createdAt: new Date().toISOString(), + firstReportSent: false, }; const content = `${JSON.stringify({ version: 1, ...identity })}\n`; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; @@ -173,19 +180,47 @@ export function ensureTelemetryInstallId( return { identity, freshlyCreated: true }; } +/** Mark `firstReportSent: true` inside `/telemetry-id.json` after the first report succeeds. */ +export function markTelemetryFirstReportSent(configDir = getConfigDir()): void { + const existing = readTelemetryInstallId(configDir); + if (!existing || existing.firstReportSent === true) return; + const path = telemetryIdPath(configDir); + const updated: TelemetryInstallIdentity = { + installId: existing.installId, + createdAt: existing.createdAt, + firstReportSent: true, + }; + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + atomicWriteFileNoFollow(path, `${JSON.stringify({ version: 1, ...updated })}\n`); + memo = { path, identity: updated }; +} + /** Delete `/telemetry-id.json` when telemetry is declined. */ export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { const path = telemetryIdPath(configDir); if (memo?.path === path) memo = null; + let deleted = false; try { const stat = lstatSync(path); - if (stat.isSymbolicLink() || !stat.isFile()) return false; - unlinkSync(path); - return true; + if (!stat.isSymbolicLink() && stat.isFile()) { + unlinkSync(path); + deleted = true; + } } catch (error) { - if (errorCode(error) === "ENOENT") return false; - return false; + if (errorCode(error) !== "ENOENT") deleted = false; } + const statePath = join(configDir, "telemetry-state.json"); + try { + const stat = lstatSync(statePath); + if (!stat.isSymbolicLink() && stat.isFile() && stat.size <= 64 * 1024) { + const raw = JSON.parse(readFileSync(statePath, "utf8")) as Record; + if (raw && typeof raw === "object" && !Array.isArray(raw) && "firstReportSentForInstallId" in raw) { + delete raw.firstReportSentForInstallId; + atomicWriteFileNoFollow(statePath, `${JSON.stringify(raw, null, 2)}\n`); + } + } + } catch { /* best-effort */ } + return deleted; } export function computeInstallDayHash(installId: string, day: string): string { diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 49707f420e2..65be7e621eb 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -41,7 +41,7 @@ export async function buildTelemetryStatusSnapshot( ? computeInstallDayHash(identity.installId, day) : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; const newInstall = identity - ? state.firstReportSentForInstallId !== identity.installId + ? identity.firstReportSent !== true : true; const preview = buildTelemetryPayloadV1({ aggregate, diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 604c535d0ec..4d98a9562d3 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -1,5 +1,6 @@ -import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -7,7 +8,7 @@ import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; -import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { computeInstallDayHash, ensureTelemetryInstallId, markTelemetryFirstReportSent } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; @@ -22,7 +23,6 @@ export interface TelemetryState { sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; - firstReportSentForInstallId: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -35,7 +35,6 @@ function emptyState(): TelemetryState { sentDays: {}, lastSentDay: null, lastSentAt: null, - firstReportSentForInstallId: null, }; } @@ -59,8 +58,6 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { sentDays, lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, - firstReportSentForInstallId: - typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, }; } catch { return emptyState(); @@ -81,9 +78,8 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig sentDays: prunedSentDays, lastSentDay: state.lastSentDay, lastSentAt: state.lastSentAt, - firstReportSentForInstallId: state.firstReportSentForInstallId, }; - writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } export function utcMidnightMs(epochMs: number): number { @@ -155,7 +151,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = state.firstReportSentForInstallId !== identity.installId; + const newInstall = identity.firstReportSent !== true; const installDayHash = computeInstallDayHash(identity.installId, targetDay); const aggregate = await (options.aggregateDay ? options.aggregateDay(targetDay) @@ -171,23 +167,29 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom if (!sendResult.ok) { return { sent: false, day: targetDay, reason: "send-failed", sendResult }; } + markTelemetryFirstReportSent(configDir); const nowIso = new Date(now).toISOString(); state.sentDays[targetDay] = nowIso; state.lastSentDay = targetDay; state.lastSentAt = nowIso; - state.firstReportSentForInstallId = identity.installId; writeTelemetryState(state, configDir, now); return { sent: true, day: targetDay, reason: "sent", sendResult }; } let startupTimer: ReturnType | null = null; let intervalTimer: ReturnType | null = null; +let nudgeTimer: ReturnType | null = null; let detachShutdownHook: (() => void) | null = null; let activeTick: Promise | null = null; let running = false; function triggerBackgroundTick(): void { - if (!running || activeTick) return; + if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } + if (activeTick) return; activeTick = runTelemetryTick() .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) .finally(() => { @@ -226,6 +228,10 @@ export function stopTelemetryScheduler(): void { clearInterval(intervalTimer); intervalTimer = null; } + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } if (detachShutdownHook) { const detach = detachShutdownHook; detachShutdownHook = null; @@ -235,12 +241,21 @@ export function stopTelemetryScheduler(): void { export function nudgeTelemetryScheduler(): void { if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; } - const timer = setTimeout(() => { + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } + nudgeTimer = setTimeout(() => { + nudgeTimer = null; triggerBackgroundTick(); }, 10); - timer.unref?.(); + nudgeTimer.unref?.(); } diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index ece7f41245a..eaea6062012 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; +import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; import { TELEMETRY_DEFERRAL_FILE, handleTelemetryCommand, @@ -11,10 +12,12 @@ import { } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { - ensureTelemetryInstallId, + readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath, } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import { repoPath } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const AGENT_ENV_VARS = [ @@ -62,6 +65,7 @@ describe("ocx telemetry CLI and interactive prompt", () => { afterEach(() => { setTelemetryPromptDepsForTests(null); + setStarPromptDepsForTests(null); resetTelemetryInstallIdMemoForTests(); for (const [key, value] of savedAgentEnv) { if (value === undefined) delete process.env[key]; @@ -103,7 +107,14 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(enableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); - ensureTelemetryInstallId(home); + const tick = await runTelemetryTick({ + configDir: home, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; expect(existsSync(telemetryIdPath(home))).toBe(true); // Agent-driven session: @@ -118,6 +129,12 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(disableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } } finally { log.mockRestore(); err.mockRestore(); @@ -132,9 +149,9 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); - const importFile = join(home, "imported.json"); + const importAccepted = join(home, "imported-accepted.json"); writeFileSync( - importFile, + importAccepted, JSON.stringify({ port: 10100, defaultProvider: "openai", @@ -150,9 +167,37 @@ describe("ocx telemetry CLI and interactive prompt", () => { "utf8", ); process.env.CODEX_THREAD_ID = "agent-import"; - expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + // Direction 1: undecided on disk -> agent import does not inject telemetry + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + + // Direction 2: declined on disk -> agent import preserves declined whether input has telemetry or omits it + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + const declinedBefore = readConfigDiagnostics().config.telemetry; + expect(declinedBefore?.decision).toBe("declined"); + + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + + const importWithoutTelemetry = join(home, "imported-no-telemetry.json"); + writeFileSync( + importWithoutTelemetry, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }), + "utf8", + ); + expect(await handleConfigCommand(["import", importWithoutTelemetry, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); } finally { log.mockRestore(); warn.mockRestore(); @@ -163,11 +208,18 @@ describe("ocx telemetry CLI and interactive prompt", () => { test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { + // A symlinked .telemetry-deferred marker is neither trusted on read nor followed on write: + const victim = join(home, "deferral-victim.txt"); + writeFileSync(victim, "preserve-deferral-victim", "utf8"); + const deferralPath = join(home, TELEMETRY_DEFERRAL_FILE); + symlinkSync(victim, deferralPath); + process.env.CODEX_THREAD_ID = "agent-start"; setTelemetryPromptDepsForTests({ isTty: () => true }); await maybeShowTelemetryPrompt(); expect(log.mock.calls.length).toBe(1); - expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readFileSync(victim, "utf8")).toBe("preserve-deferral-victim"); + expect(lstatSync(deferralPath).isSymbolicLink()).toBe(false); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); // Second agent-driven run on the same version stays quiet: @@ -197,4 +249,22 @@ describe("ocx telemetry CLI and interactive prompt", () => { log.mockRestore(); } }); + + test("service/cli.ts does not duplicate maybeShowTelemetryPrompt after maybeShowStarPrompt and the prompt runs once", async () => { + const serviceCliSource = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); + expect(serviceCliSource).toContain("await maybeShowStarPrompt()"); + expect(serviceCliSource).not.toContain("maybeShowTelemetryPrompt"); + + let telemetryPromptCount = 0; + setStarPromptDepsForTests({ isTty: () => true, ghAvailable: () => false }); + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + telemetryPromptCount += 1; + return opts.defaultYes; + }, + }); + await maybeShowStarPrompt(); + expect(telemetryPromptCount).toBe(1); + }); }); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 7c8814ec7e6..9062a351829 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; -import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -143,9 +144,6 @@ describe("/api/telemetry management routes", () => { }); test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { - ensureTelemetryInstallId(home); - expect(existsSync(telemetryIdPath(home))).toBe(true); - const accepted = await call("PUT", "/api/telemetry", { body: { decision: "accepted" }, principal: "gui-session", @@ -160,6 +158,17 @@ describe("/api/telemetry management routes", () => { }); expect(config.telemetry?.decision).toBe("accepted"); + const tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(existsSync(telemetryIdPath(home))).toBe(true); + const declined = await call("PUT", "/api/telemetry", { body: { decision: "declined" }, principal: "gui-session", @@ -173,5 +182,11 @@ describe("/api/telemetry management routes", () => { decision: "declined", }); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e8cee325640..e9ae6782ac4 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -35,7 +35,15 @@ import { telemetryIdPath, } from "../../src/telemetry/install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; -import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { + nudgeTelemetryScheduler, + readTelemetryState, + runTelemetryTick, + startTelemetryScheduler, + stopTelemetryScheduler, + telemetryStatePath, + writeTelemetryState, +} from "../../src/telemetry/scheduler"; import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; @@ -346,6 +354,35 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); }); + test("disjointTelemetryTokens legacy fallback keeps 5-bucket sum equal to usageDisplayTotalTokens and ACCOUNT_SUFFIX_RE matches k + 32 hex", () => { + const legacyUsage = { + inputTokens: 250, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + totalTokens: 1400, + }; + const buckets = disjointTelemetryTokens(legacyUsage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(legacyUsage)!); + expect(buckets.inputTokens).toBe(750); + expect(buckets.cacheReadTokens).toBe(100); + expect(buckets.cacheWriteTokens).toBe(150); + expect(buckets.outputTokens).toBe(280); + expect(buckets.reasoningTokens).toBe(120); + + expect(classifyProviderKind(`openai-k${"a1b2c3d4".repeat(4)}`)).toBe("openai"); + expect(classifyProviderKind("openai-p123456")).toBe("openai"); + expect(classifyProviderKind("openai-o123456")).toBe("openai"); + expect(classifyProviderKind("openai-k123456")).toBe("other"); + }); + test("installChannel mapping covers desktop, docker, and package managers", () => { expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); @@ -422,6 +459,82 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => }); expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + + // Deleting the install id after a successful tick leaves no trace of the raw installId in configDir. + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(deleteTelemetryInstallId(home)).toBe(true); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } + }); + + test("writeTelemetryState replaces a symlinked telemetry-state.json without following it", () => { + const victim = join(home, "victim.txt"); + writeFileSync(victim, "preserve-me", "utf8"); + const stateFile = telemetryStatePath(home); + symlinkSync(victim, stateFile); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(true); + + writeTelemetryState( + { + version: 1, + sentDays: { "2026-11-06": "2026-11-07T00:00:00.000Z" }, + lastSentDay: "2026-11-06", + lastSentAt: "2026-11-07T00:00:00.000Z", + } as never, + home, + NOW, + ); + + expect(readFileSync(victim, "utf8")).toBe("preserve-me"); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(false); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("nudgeTelemetryScheduler clears previous nudge timer and runtime env opt-out stops the scheduler in both nudge and tick", async () => { + const prevEnv = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + const clearSpy = spyOn(globalThis, "clearTimeout"); + try { + startTelemetryScheduler({ startupDelayMs: 60_000, intervalMs: 60_000 }); + clearSpy.mockClear(); + // First nudge clears startupTimer (1 call); second and third nudges clear the prior nudgeTimer. + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBeGreaterThanOrEqual(3); + + // Setting OCX_TELEMETRY=0 at runtime stops the scheduler when nudged. + process.env.OCX_TELEMETRY = "0"; + nudgeTelemetryScheduler(); + delete process.env.OCX_TELEMETRY; + + // Because the scheduler stopped, a subsequent nudge is a no-op and sets no new timer. + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + + // Restart and set DO_NOT_TRACK=1 before startupTimer fires triggerBackgroundTick: + startTelemetryScheduler({ startupDelayMs: 5, intervalMs: 60_000 }); + process.env.DO_NOT_TRACK = "1"; + await new Promise(resolve => setTimeout(resolve, 25)); + delete process.env.DO_NOT_TRACK; + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + } finally { + stopTelemetryScheduler(); + clearSpy.mockRestore(); + if (prevEnv === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevEnv; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + } }); test("sendTelemetryPayload enforces its timeout and does not retry", async () => { From f1dd803e1e9bf80ef2470f8f327bf8e7e6e776d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 00:29:06 +0900 Subject: [PATCH 06/63] feat(gui): add telemetry consent popup, Proxy details toggle, and payload preview --- gui/src/App.tsx | 13 +- gui/src/components/TelemetrySettingsPanel.tsx | 233 +++++++++++++ .../components/desktop-star-onboarding.tsx | 19 +- gui/src/components/telemetry-consent-card.tsx | 201 ++++++++++++ gui/src/i18n/v2-pages/proxy-copy.ts | 19 ++ gui/src/pages/AdvancedHub.tsx | 3 +- gui/src/pages/dashboard-overview-panels.tsx | 4 + gui/src/pages/dashboard-section-ids.ts | 1 + gui/src/telemetry-api.ts | 202 ++++++++++++ gui/tests/desktop-star-onboarding.test.tsx | 40 +++ gui/tests/telemetry-consent-card.test.tsx | 307 ++++++++++++++++++ gui/tests/telemetry-settings-panel.test.tsx | 252 ++++++++++++++ 12 files changed, 1291 insertions(+), 3 deletions(-) create mode 100644 gui/src/components/TelemetrySettingsPanel.tsx create mode 100644 gui/src/components/telemetry-consent-card.tsx create mode 100644 gui/src/telemetry-api.ts create mode 100644 gui/tests/telemetry-consent-card.test.tsx create mode 100644 gui/tests/telemetry-settings-panel.test.tsx diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 04fd4287d04..597e8f98464 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -20,6 +20,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import QuotaSummaryBar from "./components/quota-summary-bar/QuotaSummaryBar"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { DesktopStarOnboarding } from "./components/desktop-star-onboarding"; +import { TelemetryConsentCard } from "./components/telemetry-consent-card"; import { IconMenu, IconGlobe, IconPower, IconX, IconRefresh, IconHistory } from "./icons"; import { switchDashboardEdition } from "./lib/dashboard-edition"; import { groupForPage, visibleGroupPages } from "./nav-groups"; @@ -162,6 +163,8 @@ export default function App() { const [hubPairingOpen, setHubPairingOpen] = useState(false); const [remoteWorkspaceAvailableState, setRemoteWorkspaceAvailable] = useState(false); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + const [starOnboardingSettled, setStarOnboardingSettled] = useState(false); + const handleStarOnboardingSettled = useCallback(() => setStarOnboardingSettled(true), []); /* * Results from the two sidebar orbs used to be `alert()`, which the app's webview draws * nowhere, so a refused stop and a completed one looked identical: nothing happened. @@ -467,7 +470,15 @@ export default function App() { return (
- + + {actionFeedback && ( setActionFeedback(null)} dismissLabel={t("common.close")}> {actionFeedback.text} diff --git a/gui/src/components/TelemetrySettingsPanel.tsx b/gui/src/components/TelemetrySettingsPanel.tsx new file mode 100644 index 00000000000..87c97fb93c6 --- /dev/null +++ b/gui/src/components/TelemetrySettingsPanel.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { IconX } from "../icons"; +import { + TELEMETRY_STATUS_CHANGED_EVENT, + fetchTelemetryStatus, + formatTelemetryPreviewJson, + putTelemetryDecision, + type TelemetryDecision, + type TelemetryStatusSnapshot, +} from "../telemetry-api"; +import { Notice, Switch } from "../ui"; +import { startVisibilityPoll } from "../visibility-poll"; +import { SettingsRow } from "./v2-proxy/settings-layout"; + +/** + * Proxy details row and preview dialog for anonymous daily usage telemetry. + */ +export function TelemetrySettingsPanel({ + apiBase, + readOnly = false, +}: { + apiBase: string; + readOnly?: boolean; +}) { + const t = useT(); + const [status, setStatus] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const savingRef = useRef(false); + const seqRef = useRef(0); + const previewTriggerRef = useRef(null); + const previewDialogRef = useRef(null); + const previewTitleId = useId(); + + const load = useCallback(async () => { + if (savingRef.current) return; + const seq = ++seqRef.current; + try { + const next = await fetchTelemetryStatus(apiBase); + if (!savingRef.current && seq === seqRef.current) { + setStatus(next); + setLoadFailed(false); + } + } catch { + if (!savingRef.current && seq === seqRef.current) { + setLoadFailed(true); + } + } + }, [apiBase]); + + useEffect(() => { + const timeout = window.setTimeout(() => { + void load(); + }, 0); + const stop = startVisibilityPoll(() => { + void load(); + }, 30_000); + const onExternalChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && !savingRef.current) { + seqRef.current += 1; + setStatus(detail); + setLoadFailed(false); + } + }; + window.addEventListener(TELEMETRY_STATUS_CHANGED_EVENT, onExternalChange); + return () => { + window.clearTimeout(timeout); + stop(); + window.removeEventListener(TELEMETRY_STATUS_CHANGED_EVENT, onExternalChange); + }; + }, [load]); + + useEffect(() => { + if (!previewOpen) return; + const dialog = previewDialogRef.current; + if (dialog && !dialog.open) { + dialog.showModal?.(); + } + }, [previewOpen]); + + const closePreview = useCallback(() => { + setPreviewOpen(false); + previewTriggerRef.current?.focus(); + }, []); + + const effective = status?.effective ?? false; + const envOverride = status?.envOverride ?? null; + const switchDisabled = !status || saving || readOnly || envOverride !== null; + + const handleToggle = useCallback(async () => { + if (savingRef.current || !status || readOnly || status.envOverride !== null) return; + const nextDecision: TelemetryDecision = status.effective ? "declined" : "accepted"; + savingRef.current = true; + seqRef.current += 1; + setSaving(true); + setSaveFailed(false); + try { + const updated = await putTelemetryDecision(apiBase, nextDecision, status); + seqRef.current += 1; + setStatus(updated); + setLoadFailed(false); + } catch { + setSaveFailed(true); + } finally { + savingRef.current = false; + setSaving(false); + } + }, [apiBase, readOnly, status]); + + const envVarDisplay = + envOverride === "DO_NOT_TRACK" + ? "DO_NOT_TRACK=1" + : envOverride === "OCX_TELEMETRY" + ? "OCX_TELEMETRY=0" + : null; + + return ( + <> + + {loadFailed && !status && ( + + )} + + { + void handleToggle(); + }} + /> +
+ } + > + {envVarDisplay && ( + {t("proxyV2.telemetryEnvOverride", { envVar: envVarDisplay })} + )} + {loadFailed && !status && {t("proxyV2.telemetryLoadFailed")}} + {saveFailed && {t("proxyV2.telemetrySaveFailed")}} + + + {previewOpen && status && ( + { + event.preventDefault(); + closePreview(); + }} + onKeyDown={event => { + if (event.key === "Escape") { + event.preventDefault(); + closePreview(); + } + }} + > + + +
+
+ {t("proxyV2.telemetrySentTitle")}: + {t("proxyV2.telemetrySentItems")} +
+
+ {t("proxyV2.telemetryNeverTitle")}: + {t("proxyV2.telemetryNeverItems")} +
+
+

{t("proxyV2.telemetryPreviewHint")}

+
{formatTelemetryPreviewJson(status)}
+
+ +
+ +
+ )} + + ); +} + +export default TelemetrySettingsPanel; diff --git a/gui/src/components/desktop-star-onboarding.tsx b/gui/src/components/desktop-star-onboarding.tsx index 65b5e2d8839..9e393a98566 100644 --- a/gui/src/components/desktop-star-onboarding.tsx +++ b/gui/src/components/desktop-star-onboarding.tsx @@ -48,9 +48,26 @@ function openInBrowser(url: string) { window.location.assign(url); } -export function DesktopStarOnboarding({ apiBase, enabled }: { apiBase: string; enabled: boolean }) { +export function DesktopStarOnboarding({ + apiBase, + enabled, + onSettled, +}: { + apiBase: string; + enabled: boolean; + onSettled?: () => void; +}) { const [status, setStatus] = useState(null); const [done, setDone] = useState(onboardingSeen); + const settledRef = useRef(false); + + useEffect(() => { + if (!enabled || settledRef.current) return; + if (done || !isDesktopShell()) { + settledRef.current = true; + onSettled?.(); + } + }, [done, enabled, onSettled]); useEffect(() => { if (!enabled || done || !isDesktopShell()) return; diff --git a/gui/src/components/telemetry-consent-card.tsx b/gui/src/components/telemetry-consent-card.tsx new file mode 100644 index 00000000000..3aceb1137cf --- /dev/null +++ b/gui/src/components/telemetry-consent-card.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { + fetchTelemetryStatus, + formatTelemetryPreviewJson, + postTelemetryNotice, + putTelemetryDecision, + shouldShowTelemetryConsentPrompt, + type TelemetryStatusSnapshot, +} from "../telemetry-api"; +import { Notice } from "../ui"; + +/** + * First-run and post-update telemetry disclosure dialog. + * + * Opens only after DesktopStarOnboarding has settled, and only when the proxy reports an + * undecided install with no environment override. Opening the dialog records nothing; + * Continue records the checkbox decision via PUT /api/telemetry, and dismissing via Escape + * or backdrop records only that the notice was seen via POST /api/telemetry/notice. + */ +export function TelemetryConsentCard({ + apiBase, + enabled, +}: { + apiBase: string; + enabled: boolean; +}) { + const [status, setStatus] = useState(null); + const [closed, setClosed] = useState(false); + + useEffect(() => { + if (!enabled || closed) return; + const controller = new AbortController(); + void fetchTelemetryStatus(apiBase, controller.signal) + .then(snapshot => { + if (controller.signal.aborted) return; + if (!shouldShowTelemetryConsentPrompt(snapshot)) { + setClosed(true); + return; + } + setStatus(snapshot); + }) + .catch(() => { + // If status cannot be loaded, keep the dialog closed rather than blocking the dashboard. + }); + return () => controller.abort(); + }, [apiBase, closed, enabled]); + + const handleClose = useCallback(() => { + setClosed(true); + }, []); + + if (!enabled || closed || !status) return null; + return ; +} + +function TelemetryConsentDialog({ + apiBase, + status, + onClose, +}: { + apiBase: string; + status: TelemetryStatusSnapshot; + onClose: () => void; +}) { + const t = useT(); + const titleId = useId(); + const bodyId = useId(); + const dialogRef = useRef(null); + const [checked, setChecked] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [saveError, setSaveError] = useState(false); + const busyRef = useRef(false); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) { + dialog.showModal?.(); + } + }, []); + + const handleContinue = useCallback(async () => { + if (busyRef.current) return; + busyRef.current = true; + setSubmitting(true); + setSaveError(false); + try { + await putTelemetryDecision(apiBase, checked ? "accepted" : "declined", status); + onClose(); + } catch { + busyRef.current = false; + setSubmitting(false); + setSaveError(true); + } + }, [apiBase, checked, onClose, status]); + + const handleDismiss = useCallback(async () => { + if (busyRef.current) return; + busyRef.current = true; + setSubmitting(true); + setSaveError(false); + try { + await postTelemetryNotice(apiBase, status); + } catch { + // Dismissal never traps the user inside the modal even if the notice write fails. + } + onClose(); + }, [apiBase, onClose, status]); + + const handleCancel = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + void handleDismiss(); + }, + [handleDismiss], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + void handleDismiss(); + } + }, + [handleDismiss], + ); + + return ( + + + + + + ); +} + +export default TelemetryConsentCard; diff --git a/gui/src/i18n/v2-pages/proxy-copy.ts b/gui/src/i18n/v2-pages/proxy-copy.ts index 48113c9b65b..d7c0988992d 100644 --- a/gui/src/i18n/v2-pages/proxy-copy.ts +++ b/gui/src/i18n/v2-pages/proxy-copy.ts @@ -24,6 +24,8 @@ const rows = { secMemoryLede: ["Models for Codex's background memory, and how much memory the proxy itself uses.", "Codex 백그라운드 메모리에 쓸 모델과 프록시 자체의 메모리 사용량이에요.", "Modelle für das Hintergrundgedächtnis von Codex und der Speicherverbrauch des Proxys selbst.", "Les modèles de la mémoire d'arrière-plan de Codex, et la mémoire utilisée par le proxy lui-même.", "Codex 后台记忆使用的模型,以及代理自身的内存占用。", "Codex 背景記憶使用的模型,以及代理本身的記憶體用量。", "Модели для фоновой памяти Codex и сколько памяти занимает сам прокси.", "Codex のバックグラウンドメモリに使うモデルと、プロキシ自体のメモリ使用量です。", "Codex'in arka plan belleği için modeller ve proxy'nin kendi bellek kullanımı.", "Mô hình cho bộ nhớ nền của Codex và lượng bộ nhớ proxy đang dùng.", "Modelos da memória em segundo plano do Codex e quanta memória o próprio proxy usa."], secFallback: ["Fallback", "폴백", "Fallback", "Repli", "回退", "備援", "Резервный переход", "フォールバック", "Yedeğe geçiş", "Dự phòng", "Fallback"], secFallbackLede: ["What happens when a model or provider fails.", "모델이나 프로바이더가 실패했을 때의 동작이에요.", "Was passiert, wenn ein Modell oder Anbieter ausfällt.", "Ce qui se passe quand un modèle ou un fournisseur échoue.", "模型或提供商失败时的处理方式。", "模型或供應商失敗時的處理方式。", "Что происходит, когда модель или провайдер дают сбой.", "モデルやプロバイダーが失敗したときの動作です。", "Bir model veya sağlayıcı başarısız olduğunda ne olacağı.", "Điều gì xảy ra khi mô hình hoặc nhà cung cấp gặp lỗi.", "O que acontece quando um modelo ou provedor falha."], + secTelemetry: ["Telemetry", "텔레메트리", "Telemetrie", "Télémétrie", "遥测", "遙測", "Телеметрия", "テレメトリ", "Telemetri", "Telemetri", "Telemetria"], + secTelemetryLede: ["Anonymous daily usage totals shared with the project dashboard.", "프로젝트 대시보드에 공유되는 익명 일일 사용 합계예요.", "Anonyme tägliche Nutzungszahlen, die mit dem Projekt-Dashboard geteilt werden.", "Totaux quotidiens anonymes partagés avec le tableau de bord du projet.", "与项目仪表盘共享的匿名每日使用汇总。", "與專案儀表板共享的匿名每日使用彙總。", "Анонимные дневные сводки использования для дашборда проекта.", "プロジェクトのダッシュボードに共有される匿名の1日ごとの利用集計です。", "Proje panosuyla paylaşılan anonim günlük kullanım toplamları.", "Tổng hợp sử dụng hằng ngày ẩn danh chia sẻ với bảng điều khiển dự án.", "Totais diários anônimos de uso compartilhados com o painel do projeto."], // ---- Proxy details: one-line row hints (the long text stays behind "Details") --------------- maModeHint: ["Which subagent engine Codex uses: v1, base or v2.", "Codex가 쓸 서브에이전트 방식을 골라요: v1, base, v2.", "Welche Subagenten-Engine Codex nutzt: v1, base oder v2.", "Le moteur de sous-agents utilisé par Codex : v1, base ou v2.", "Codex 使用的子代理引擎:v1、base 或 v2。", "Codex 使用的子代理引擎:v1、base 或 v2。", "Какой движок субагентов использует Codex: v1, base или v2.", "Codex が使うサブエージェント方式を選びます:v1、base、v2。", "Codex'in kullandığı alt ajan motoru: v1, base veya v2.", "Bộ máy tác tử phụ mà Codex dùng: v1, base hoặc v2.", "Qual mecanismo de subagentes o Codex usa: v1, base ou v2."], @@ -34,6 +36,23 @@ const rows = { authlessShort: ["Skip the separate Desktop sign-in. Off by default.", "Desktop 로그인을 따로 하지 않아요. 기본값은 꺼짐이에요.", "Die separate Desktop-Anmeldung überspringen. Standardmäßig aus.", "Ignorer la connexion Desktop séparée. Désactivé par défaut.", "跳过单独的桌面端登录。默认关闭。", "略過單獨的桌面版登入。預設關閉。", "Пропускать отдельный вход в Desktop. По умолчанию выключено.", "Desktop の個別サインインを省きます。既定はオフです。", "Ayrı Desktop oturum açmayı atla. Varsayılan olarak kapalı.", "Bỏ qua bước đăng nhập Desktop riêng. Mặc định tắt.", "Pular o login separado do Desktop. Desativado por padrão."], clientCompactionShort: ["Store compaction summaries as portable plain text. Off by default.", "압축 요약을 옮길 수 있는 평문으로 저장해요. 기본값은 꺼짐이에요.", "Komprimierungs-Zusammenfassungen als portablen Klartext speichern. Standardmäßig aus.", "Enregistrer les résumés de compaction en texte brut portable. Désactivé par défaut.", "以可移植的纯文本保存压缩摘要。默认关闭。", "以可攜的純文字儲存壓縮摘要。預設關閉。", "Хранить сводки сжатия переносимым текстом. По умолчанию выключено.", "圧縮の要約を持ち運べるプレーンテキストで保存します。既定はオフです。", "Sıkıştırma özetlerini taşınabilir düz metin olarak sakla. Varsayılan olarak kapalı.", "Lưu bản tóm tắt nén dưới dạng văn bản thuần di chuyển được. Mặc định tắt.", "Guardar resumos de compactação como texto simples portátil. Desativado por padrão."], shadowShort: ["Send Codex App background calls, like titles and commit messages, to another model.", "Codex 앱의 백그라운드 호출(제목, 커밋 메시지 등)을 다른 모델로 보내요.", "Hintergrundaufrufe der Codex-App, etwa Titel und Commit-Nachrichten, an ein anderes Modell senden.", "Envoyer les appels de fond de l'app Codex, comme les titres et messages de commit, à un autre modèle.", "把 Codex 应用的后台调用(如标题、提交信息)发送到另一个模型。", "把 Codex 應用程式的背景呼叫(如標題、提交訊息)傳送到另一個模型。", "Отправлять фоновые вызовы приложения Codex (заголовки, сообщения коммитов) другой модели.", "Codex アプリのバックグラウンド呼び出し(タイトル、コミットメッセージなど)を別のモデルに送ります。", "Codex uygulamasının başlık ve commit mesajı gibi arka plan çağrılarını başka bir modele gönder.", "Gửi các lời gọi nền của ứng dụng Codex, như tiêu đề và thông điệp commit, sang mô hình khác.", "Enviar chamadas em segundo plano do app Codex, como títulos e mensagens de commit, para outro modelo."], + telemetryToggleLabel: ["Share anonymous usage statistics", "익명 사용 통계 공유", "Anonyme Nutzungsstatistiken teilen", "Partager des statistiques d'utilisation anonymes", "共享匿名使用统计", "共享匿名使用統計", "Делиться анонимной статистикой использования", "匿名の利用統計を共有する", "Anonim kullanım istatistiklerini paylaş", "Chia sẻ thống kê sử dụng ẩn danh", "Compartilhar estatísticas anônimas de uso"], + telemetryToggleHint: ["Sends one daily aggregate of version, platform, coarse model family, token totals and error counts. On by default.", "버전, 플랫폼, 모델 계열, 토큰 합계, 오류 건수의 일일 집계만 하루 한 번 보내요. 기본값은 켜짐이에요.", "Sendet einmal täglich aggregierte Angaben zu Version, Plattform, grober Modellfamilie, Token-Summen und Fehlerzahlen. Standardmäßig an.", "Envoie une synthèse quotidienne (version, plateforme, famille de modèles, totaux de jetons et nombres d'erreurs). Activé par défaut.", "每天发送一次版本、平台、模型系列、令牌总数与错误计数的汇总。默认开启。", "每天傳送一次版本、平台、模型系列、Token 總數與錯誤計數的彙總。預設開啟。", "Раз в день отправляет сводку: версия, платформа, семейство моделей, сумма токенов и число ошибок. По умолчанию включено.", "バージョン、OS、モデル系統、トークン合計、エラー件数の日次集計を1日1回送信します。既定はオンです。", "Sürüm, platform, model ailesi, jeton toplamları ve hata sayılarının günlük özetini günde bir kez gönderir. Varsayılan olarak açıktır.", "Gửi một bản tổng hợp mỗi ngày gồm phiên bản, nền tảng, dòng mô hình, tổng token và số lỗi. Mặc định bật.", "Envia um resumo diário com versão, plataforma, família de modelos, totais de tokens e contagem de erros. Ativado por padrão."], + telemetryDetails: ["Reports use a per-day hash of a random local install ID. Disabling telemetry deletes that ID immediately. Prompts, responses, API keys, account emails, custom URLs and file paths are never collected.", "보고서는 무작위 로컬 설치 ID의 일일 해시를 사용하며, 끄면 해당 ID를 즉시 삭제해요. 프롬프트, 응답, API 키, 계정 이메일, 사용자 지정 URL, 파일 경로는 절대 수집하지 않아요.", "Berichte nutzen einen täglichen Hash einer zufälligen lokalen Installations-ID. Beim Deaktivieren wird diese ID sofort gelöscht. Prompts, Antworten, API-Schlüssel, E-Mail-Adressen, eigene URLs und Dateipfade werden nie erfasst.", "Les rapports utilisent un hachage quotidien d'un identifiant local aléatoire, supprimé dès la désactivation. Prompts, réponses, clés API, e-mails, URL personnalisées et chemins de fichiers ne sont jamais collectés.", "报告使用随机本地安装 ID 的每日哈希;关闭遥测会立即删除该 ID。绝不收集提示词、回复、API 密钥、账户邮箱、自定义 URL 或文件路径。", "報告使用隨機本機安裝 ID 的每日雜湊;關閉遙測會立即刪除該 ID。絕不收集提示詞、回覆、API 金鑰、帳號信箱、自訂 URL 或檔案路徑。", "Отчёты используют суточный хеш случайного локального ID установки; при отключении этот ID сразу удаляется. Промпты, ответы, API-ключи, адреса почты, свои URL и пути к файлам никогда не собираются.", "レポートにはランダムなローカルインストールIDの日次ハッシュを使い、オフにするとそのIDは即座に削除されます。プロンプト、応答、APIキー、メールアドレス、カスタムURL、ファイルパスは一切収集されません。", "Raporlar rastgele yerel kurulum kimliğinin günlük karmasını kullanır; kapatıldığında bu kimlik hemen silinir. İstemler, yanıtlar, API anahtarları, e-postalar, özel URL'ler ve dosya yolları asla toplanmaz.", "Báo cáo dùng mã băm theo ngày của ID cài đặt ngẫu nhiên cục bộ; tắt telemetri sẽ xóa ID đó ngay lập tức. Prompt, phản hồi, khóa API, email tài khoản, URL tùy chỉnh và đường dẫn tệp không bao giờ được thu thập.", "Os relatórios usam um hash diário de um ID de instalação local aleatório; desativar a telemetria apaga esse ID imediatamente. Prompts, respostas, chaves de API, e-mails, URLs personalizadas e caminhos de arquivos nunca são coletados."], + telemetryWhatIsSent: ["What is sent", "전송되는 항목", "Was gesendet wird", "Ce qui est envoyé", "发送内容", "傳送內容", "Что отправляется", "送信される内容", "Ne gönderilir", "Dữ liệu được gửi", "O que é enviado"], + telemetryPreviewHint: ["Exact JSON payload prepared from the latest completed UTC day:", "가장 최근 완료된 UTC 일자 기준으로 준비된 실제 JSON 페이로드:", "Exakte JSON-Nutzlast aus dem letzten abgeschlossenen UTC-Tag:", "Charge JSON exacte préparée à partir du dernier jour UTC complet :", "根据最近一个完整 UTC 日生成的完整 JSON 载荷:", "根據最近一個完整 UTC 日產生的完整 JSON 負載:", "Точный JSON-пакет, собранный за последние завершённые сутки UTC:", "直近の完了したUTC日に基づいて生成された実際のJSONペイロード:", "Tamamlanan son UTC gününden hazırlanan tam JSON yükü:", "Payload JSON chính xác được tạo từ ngày UTC hoàn tất gần nhất:", "Payload JSON exato preparado a partir do último dia UTC concluído:"], + telemetryEnvOverride: ["Disabled by environment variable {envVar}. Unset it in the proxy environment to change this setting here.", "환경 변수 {envVar}로 인해 비활성화되었어요. 여기서 바꾸려면 프록시 환경에서 해당 변수를 해제하세요.", "Durch die Umgebungsvariable {envVar} deaktiviert. Entfernen Sie sie aus der Proxy-Umgebung, um die Einstellung hier zu ändern.", "Désactivé par la variable d'environnement {envVar}. Retirez-la de l'environnement du proxy pour modifier ce réglage ici.", "已被环境变量 {envVar} 禁用。若要在此更改,请在代理环境中取消设置该变量。", "已被環境變數 {envVar} 停用。若要在此變更,請在代理環境中取消設定該變數。", "Отключено переменной окружения {envVar}. Уберите её из окружения прокси, чтобы менять настройку здесь.", "環境変数 {envVar} により無効化されています。ここで変更するにはプロキシ環境から解除してください。", "{envVar} ortam değişkeni tarafından devre dışı bırakıldı. Buradan değiştirmek için proxy ortamında kaldırın.", "Đã tắt bởi biến môi trường {envVar}. Hãy bỏ biến này khỏi môi trường proxy để thay đổi tại đây.", "Desativado pela variável de ambiente {envVar}. Remova-a do ambiente do proxy para alterar esta opção aqui."], + telemetryReadOnlyHint: ["Managed on the Home proxy for this Remote Link.", "이 원격 링크의 Home 프록시에서 관리돼요.", "Wird auf dem Home-Proxy dieses Remote Links verwaltet.", "Géré sur le proxy Home de ce lien distant.", "由该远程连接的 Home 代理管理。", "由該遠端連線的 Home 代理管理。", "Управляется на прокси Home этой удалённой связки.", "このリモートリンクの Home プロキシで管理されています。", "Bu Uzak Bağlantının Home proxy'sinde yönetilir.", "Được quản lý trên proxy Home của Liên kết từ xa này.", "Gerenciado no proxy Home deste Remote Link."], + telemetryLoadFailed: ["Could not load telemetry status.", "텔레메트리 상태를 불러오지 못했어요.", "Telemetriestatus konnte nicht geladen werden.", "Impossible de charger l'état de la télémétrie.", "无法加载遥测状态。", "無法載入遙測狀態。", "Не удалось загрузить состояние телеметрии.", "テレメトリの状態を読み込めませんでした。", "Telemetri durumu yüklenemedi.", "Không thể tải trạng thái telemetri.", "Não foi possível carregar o status da telemetria."], + telemetrySaveFailed: ["Could not save telemetry preference.", "텔레메트리 설정을 저장하지 못했어요.", "Telemetrie-Einstellung konnte nicht gespeichert werden.", "Impossible d'enregistrer la préférence de télémétrie.", "无法保存遥测偏好设置。", "無法儲存遙測偏好設定。", "Не удалось сохранить настройку телеметрии.", "テレメトリ設定を保存できませんでした。", "Telemetri tercihi kaydedilemedi.", "Không thể lưu tùy chọn telemetri.", "Não foi possível salvar a preferência de telemetria."], + telemetryConsentTitle: ["Help improve OpenCodex with anonymous usage stats", "익명 사용 통계로 OpenCodex 개선에 함께해 주세요", "OpenCodex mit anonymen Nutzungsstatistiken verbessern", "Aider à améliorer OpenCodex avec des statistiques anonymes", "通过匿名使用统计帮助改进 OpenCodex", "透過匿名使用統計協助改進 OpenCodex", "Помогите улучшать OpenCodex анонимной статистикой", "匿名の利用統計で OpenCodex の改善に協力する", "Anonim kullanım istatistikleriyle OpenCodex'i geliştirmeye yardımcı olun", "Giúp cải thiện OpenCodex bằng thống kê sử dụng ẩn danh", "Ajude a melhorar o OpenCodex com estatísticas anônimas de uso"], + telemetryConsentBody: ["OpenCodex can share one daily anonymous summary with the public project dashboard at opencodex.me/dashboard. You can change this anytime in Proxy details or with ocx telemetry.", "OpenCodex는 하루 한 번 익명 요약 통계를 공개 대시보드(opencodex.me/dashboard)와 공유할 수 있어요. 프록시 상세 설정이나 ocx telemetry 명령으로 언제든 바꿀 수 있어요.", "OpenCodex kann einmal täglich eine anonyme Zusammenfassung mit dem öffentlichen Projekt-Dashboard unter opencodex.me/dashboard teilen. Sie können dies jederzeit in den Proxy-Details oder mit ocx telemetry ändern.", "OpenCodex peut partager un résumé quotidien anonyme avec le tableau de bord public sur opencodex.me/dashboard. Modifiable à tout moment dans les détails du proxy ou via ocx telemetry.", "OpenCodex 可每天向公开项目仪表盘 opencodex.me/dashboard 发送一次匿名汇总。您可以随时在代理详情或通过 ocx telemetry 更改。", "OpenCodex 可每天向公開專案儀表板 opencodex.me/dashboard 傳送一次匿名彙總。您可以隨時在代理詳情或透過 ocx telemetry 變更。", "OpenCodex может раз в день отправлять анонимную сводку в публичный дашборд проекта opencodex.me/dashboard. Это можно изменить в любой момент в настройках прокси или командой ocx telemetry.", "OpenCodex は1日1回、公開ダッシュボード(opencodex.me/dashboard)へ匿名の集計を共有できます。プロキシ詳細や ocx telemetry からいつでも変更できます。", "OpenCodex, opencodex.me/dashboard adresindeki herkese açık proje panosuyla günde bir kez anonim özet paylaşabilir. Bunu Proxy ayrıntılarından veya ocx telemetry ile istediğiniz zaman değiştirebilirsiniz.", "OpenCodex có thể chia sẻ bản tóm tắt ẩn danh mỗi ngày một lần với bảng điều khiển công khai tại opencodex.me/dashboard. Bạn có thể thay đổi bất cứ lúc nào trong Chi tiết proxy hoặc bằng ocx telemetry.", "O OpenCodex pode compartilhar um resumo diário anônimo com o painel público do projeto em opencodex.me/dashboard. Você pode alterar isso a qualquer momento nos detalhes do proxy ou com ocx telemetry."], + telemetrySentTitle: ["What is sent", "보내는 항목", "Gesendet wird", "Envoyé", "发送的数据", "傳送的資料", "Отправляется", "送信されるもの", "Gönderilenler", "Được gửi", "Enviado"], + telemetrySentItems: ["Version, OS/CPU, install channel, active minutes, provider kind, coarse model family, request outcomes, token totals, and closed error codes.", "버전, OS/CPU, 설치 채널, 활성 시간(분), 프로바이더 종류, 모델 계열, 요청 결과, 토큰 합계, 고정된 오류 코드.", "Version, OS/CPU, Installationskanal, aktive Minuten, Anbieterart, grobe Modellfamilie, Anfrageergebnisse, Token-Summen und feste Fehlercodes.", "Version, OS/CPU, canal d'installation, minutes actives, type de fournisseur, famille de modèles, résultats des requêtes, totaux de jetons et codes d'erreur fermés.", "版本、系统与架构、安装渠道、活跃分钟数、提供商类型、粗粒度模型系列、请求结果、令牌总数及固定错误码。", "版本、系統與架構、安裝管道、活躍分鐘數、供應商類型、粗粒度模型系列、請求結果、Token 總數及固定錯誤碼。", "Версия, ОС и архитектура, канал установки, активные минуты, тип провайдера, семейство моделей, исходы запросов, суммы токенов и закрытые коды ошибок.", "バージョン、OS/CPU、インストール経路、アクティブ分数、プロバイダー種別、大まかなモデル系統、リクエスト結果、トークン合計、固定エラーコード。", "Sürüm, İS/CPU, kurulum kanalı, etkin dakikalar, sağlayıcı türü, kaba model ailesi, istek sonuçları, jeton toplamları ve kapalı hata kodları.", "Phiên bản, OS/CPU, kênh cài đặt, số phút hoạt động, loại nhà cung cấp, dòng mô hình, kết quả yêu cầu, tổng token và mã lỗi cố định.", "Versão, SO/CPU, canal de instalação, minutos ativos, tipo de provedor, família de modelos, resultados de requisições, totais de tokens e códigos fechados de erro."], + telemetryNeverTitle: ["Never sent", "절대 보내지 않는 항목", "Niemals gesendet", "Jamais envoyé", "绝不发送", "絕不傳送", "Никогда не отправляется", "送信されないもの", "Asla gönderilmeyenler", "Không bao giờ gửi", "Nunca enviado"], + telemetryNeverItems: ["Prompts, model outputs, request/response bodies, API keys, OAuth tokens, account emails, custom base URLs, hostnames, file paths, or raw error messages.", "프롬프트, 모델 출력, 요청/응답 본문, API 키, OAuth 토큰, 계정 이메일, 사용자 지정 URL, 호스트 이름, 파일 경로, 원본 오류 메시지.", "Prompts, Modellausgaben, Anfrage-/Antwortinhalte, API-Schlüssel, OAuth-Tokens, E-Mail-Adressen, eigene Basis-URLs, Hostnamen, Dateipfade oder rohe Fehlermeldungen.", "Prompts, sorties de modèles, corps de requêtes/réponses, clés API, jetons OAuth, e-mails, URL de base personnalisées, noms d'hôte, chemins de fichiers ou messages d'erreur bruts.", "提示词、模型输出、请求或响应正文、API 密钥、OAuth 令牌、账户邮箱、自定义基础 URL、主机名、文件路径或原始错误信息。", "提示詞、模型輸出、請求或回應正文、API 金鑰、OAuth 權杖、帳號信箱、自訂基礎 URL、主機名稱、檔案路徑或原始錯誤訊息。", "Промпты, ответы моделей, тела запросов и ответов, API-ключи, OAuth-токены, адреса почты, свои базовые URL, имена хостов, пути к файлам и тексты ошибок.", "プロンプト、モデル出力、リクエスト/レスポンス本文、APIキー、OAuthトークン、メールアドレス、カスタムURL、ホスト名、ファイルパス、生のエラーメッセージ。", "İstemler, model çıktıları, istek/yanıt gövdeleri, API anahtarları, OAuth belirteçleri, hesap e-postaları, özel temel URL'ler, ana bilgisayar adları, dosya yolları veya ham hata iletileri.", "Prompt, đầu ra mô hình, nội dung yêu cầu/phản hồi, khóa API, token OAuth, email tài khoản, URL cơ sở tùy chỉnh, tên máy chủ, đường dẫn tệp hoặc thông báo lỗi thô.", "Prompts, saídas de modelos, corpos de requisição/resposta, chaves de API, tokens OAuth, e-mails de contas, URLs base personalizadas, nomes de host, caminhos de arquivos ou mensagens brutas de erro."], + telemetryConsentCheckbox: ["Share anonymous daily usage statistics", "익명 일일 사용 통계 공유하기", "Anonyme tägliche Nutzungsstatistiken teilen", "Partager les statistiques quotidiennes anonymes", "共享匿名每日使用统计", "共享匿名每日使用統計", "Делиться анонимной ежедневной статистикой", "匿名の1日ごとの利用統計を共有する", "Anonim günlük kullanım istatistiklerini paylaş", "Chia sẻ thống kê sử dụng ẩn danh hằng ngày", "Compartilhar estatísticas diárias anônimas de uso"], + telemetryConsentContinue: ["Continue", "계속", "Weiter", "Continuer", "继续", "繼續", "Продолжить", "続行", "Devam", "Tiếp tục", "Continuar"], // ---- Advisor ------------------------------------------------------------------------------------ advisorLede: ["A second model that reviews the worker's progress and sends advice back.", "Worker의 작업을 검토하고 조언을 돌려주는 두 번째 모델이에요.", "Ein zweites Modell, das den Fortschritt des Workers prüft und Rat zurückgibt.", "Un second modèle qui examine l'avancement du worker et lui renvoie des conseils.", "第二个模型,审查 Worker 的进展并返回建议。", "第二個模型,審查 Worker 的進展並回傳建議。", "Вторая модель, которая проверяет ход работы и возвращает советы.", "Worker の作業を確認して助言を返す、2 つ目のモデルです。", "Worker'ın ilerlemesini inceleyip tavsiye döndüren ikinci bir model.", "Mô hình thứ hai xem xét tiến độ của worker và gửi lại lời khuyên.", "Um segundo modelo que revisa o progresso do worker e devolve conselhos."], diff --git a/gui/src/pages/AdvancedHub.tsx b/gui/src/pages/AdvancedHub.tsx index 8ef2013b9f5..6896c054b4f 100644 --- a/gui/src/pages/AdvancedHub.tsx +++ b/gui/src/pages/AdvancedHub.tsx @@ -43,8 +43,9 @@ const ENTRIES: readonly Entry[] = [ "dash.webSearchSidecar", "dash.visionSidecar", "dash.shadowCallIntercept", "compactionRouting.title", "memoryModels.title", "fallback.title", "dash.syncModels", "dash.codexAutoStart", "dash.codexDesktopAuthless", "dash.effortCapLabel", "nativeReasoningRetention.title", "reasoningRetention.title", "dash.checkUpdate", + "proxyV2.secTelemetry", "proxyV2.telemetryToggleLabel", ], - words: ["web search", "vision", "shadow", "fallback", "reasoning", "update", "sync"] }, + words: ["web search", "vision", "shadow", "fallback", "reasoning", "update", "sync", "telemetry", "privacy"] }, { id: "routing", title: "shellV2.advRouting", when: "shellV2.advRoutingWhen", term: { key: "proxyV2.advTermRouting" }, Icon: IconShuffle, page: "models", subPath: "routing" }, { id: "prompt", title: "shellV2.advPrompt", when: "shellV2.advPromptWhen", term: { key: "proxyV2.advTermPrompt" }, Icon: IconTerminal, page: "codex-set", subPath: "prompt" }, { id: "claude", title: "shellV2.advClaude", when: "shellV2.advClaudeWhen", term: { name: "Claude Code" }, Icon: IconClaude, page: "claude", subPath: "code" }, diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 9ae2ce0b8a1..46c710ff706 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -4,6 +4,7 @@ import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import MemoryModelsPanel from "../components/MemoryModelsPanel"; import NativeReasoningRetentionPanel from "../components/NativeReasoningRetentionPanel"; import FallbackPanel from "../components/FallbackPanel"; +import TelemetrySettingsPanel from "../components/TelemetrySettingsPanel"; import type { useDashboardData } from "./use-dashboard-data"; import { DashboardCodexSection, @@ -35,6 +36,9 @@ export function DashboardOverviewPanels({ readOnly = false, ...props }: Dash & { {/* A Remote Link Child shows its Home's settings; they are changed on the Home. */} + + + ); } diff --git a/gui/src/pages/dashboard-section-ids.ts b/gui/src/pages/dashboard-section-ids.ts index f71c4203af8..d45cb3c8548 100644 --- a/gui/src/pages/dashboard-section-ids.ts +++ b/gui/src/pages/dashboard-section-ids.ts @@ -6,4 +6,5 @@ export const PROXY_SECTION_IDS = [ ["proxy-compaction", "proxyV2.secCompaction"], ["proxy-memory", "proxyV2.secMemory"], ["proxy-fallback", "proxyV2.secFallback"], + ["proxy-telemetry", "proxyV2.secTelemetry"], ] as const; diff --git a/gui/src/telemetry-api.ts b/gui/src/telemetry-api.ts new file mode 100644 index 00000000000..aae1692bee9 --- /dev/null +++ b/gui/src/telemetry-api.ts @@ -0,0 +1,202 @@ +import { createBoundedFetch } from "./bounded-fetch"; + +/** Wire shape of GET/PUT /api/telemetry and POST /api/telemetry/notice. */ +export type TelemetryDecision = "accepted" | "declined"; +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 TelemetryStatusSnapshot { + effective: boolean; + reason: TelemetryStatusReason; + decision: TelemetryDecision | null; + decidedAt: string | null; + noticeShownAt: string | null; + envOverride: TelemetryEnvOverride; + installChannel: string; + lastSentDay: string | null; + lastSentAt: string | null; + preview: Record; +} + +export const TELEMETRY_TIMEOUT_MS = 15_000; +export const TELEMETRY_STATUS_CHANGED_EVENT = "ocx:telemetry-status-changed"; + +const VALID_REASONS: ReadonlySet = new Set([ + "env-ocx-telemetry", + "env-do-not-track", + "declined", + "accepted", + "default-on", + "awaiting-notice", +]); + +export function parseTelemetryStatus(value: unknown): TelemetryStatusSnapshot | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = value as Record; + if (typeof raw.effective !== "boolean") return null; + const decision: TelemetryDecision | null = + raw.decision === "accepted" || raw.decision === "declined" ? raw.decision : null; + const reason: TelemetryStatusReason = + typeof raw.reason === "string" && VALID_REASONS.has(raw.reason) + ? (raw.reason as TelemetryStatusReason) + : decision === "accepted" + ? "accepted" + : decision === "declined" + ? "declined" + : raw.effective + ? "default-on" + : "awaiting-notice"; + const envOverride: TelemetryEnvOverride = + raw.envOverride === "OCX_TELEMETRY" || raw.envOverride === "DO_NOT_TRACK" + ? raw.envOverride + : reason === "env-ocx-telemetry" + ? "OCX_TELEMETRY" + : reason === "env-do-not-track" + ? "DO_NOT_TRACK" + : null; + const preview = + raw.preview && typeof raw.preview === "object" && !Array.isArray(raw.preview) + ? (raw.preview as Record) + : {}; + return { + effective: raw.effective, + reason, + decision, + decidedAt: typeof raw.decidedAt === "string" ? raw.decidedAt : null, + noticeShownAt: typeof raw.noticeShownAt === "string" ? raw.noticeShownAt : null, + envOverride, + installChannel: typeof raw.installChannel === "string" ? raw.installChannel : "unknown", + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + preview, + }; +} + +/** + * True when the first-run or post-update consent dialog should be presented: + * undecided install, no environment opt-out override, and reason is awaiting-notice or default-on. + */ +export function shouldShowTelemetryConsentPrompt(status: TelemetryStatusSnapshot): boolean { + if (status.decision !== null) return false; + if (status.envOverride !== null) return false; + return status.reason === "awaiting-notice" || status.reason === "default-on"; +} + +export function formatTelemetryPreviewJson(status: TelemetryStatusSnapshot | null): string { + return JSON.stringify(status?.preview ?? {}, null, 2); +} + +function notifyTelemetryStatusChanged(snapshot: TelemetryStatusSnapshot): void { + if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") return; + try { + window.dispatchEvent(new CustomEvent(TELEMETRY_STATUS_CHANGED_EVENT, { detail: snapshot })); + } catch { + // Best-effort cross-component notification. + } +} + +export async function fetchTelemetryStatus( + apiBase: string, + callerSignal?: AbortSignal, +): Promise { + const bounded = createBoundedFetch(TELEMETRY_TIMEOUT_MS); + const onCallerAbort = () => bounded.controller.abort(callerSignal?.reason); + if (callerSignal?.aborted) { + bounded.clear(); + throw callerSignal.reason ?? new DOMException("Aborted", "AbortError"); + } + callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + try { + const res = await fetch(apiBase + "/api/telemetry", { signal: bounded.signal }); + if (!res.ok) throw new Error("HTTP " + String(res.status)); + const parsed = parseTelemetryStatus(await res.json()); + if (!parsed) throw new Error("invalid telemetry status payload"); + return parsed; + } finally { + callerSignal?.removeEventListener("abort", onCallerAbort); + bounded.clear(); + } +} + +export async function putTelemetryDecision( + apiBase: string, + decision: TelemetryDecision, + previous?: TelemetryStatusSnapshot | null, +): Promise { + const bounded = createBoundedFetch(TELEMETRY_TIMEOUT_MS); + try { + const res = await fetch(apiBase + "/api/telemetry", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision }), + signal: bounded.signal, + }); + if (!res.ok) throw new Error("HTTP " + String(res.status)); + const body: unknown = await res.json().catch(() => null); + const parsed = parseTelemetryStatus(body); + const nowIso = new Date().toISOString(); + const envOverride = previous?.envOverride ?? null; + const snapshot: TelemetryStatusSnapshot = parsed ?? { + effective: envOverride === null && decision === "accepted", + reason: + envOverride === "OCX_TELEMETRY" + ? "env-ocx-telemetry" + : envOverride === "DO_NOT_TRACK" + ? "env-do-not-track" + : decision, + decision, + decidedAt: nowIso, + noticeShownAt: previous?.noticeShownAt ?? nowIso, + envOverride, + installChannel: previous?.installChannel ?? "unknown", + lastSentDay: previous?.lastSentDay ?? null, + lastSentAt: previous?.lastSentAt ?? null, + preview: previous?.preview ?? {}, + }; + notifyTelemetryStatusChanged(snapshot); + return snapshot; + } finally { + bounded.clear(); + } +} + +export async function postTelemetryNotice( + apiBase: string, + previous?: TelemetryStatusSnapshot | null, +): Promise { + const bounded = createBoundedFetch(TELEMETRY_TIMEOUT_MS); + try { + const res = await fetch(apiBase + "/api/telemetry/notice", { + method: "POST", + signal: bounded.signal, + }); + if (!res.ok) throw new Error("HTTP " + String(res.status)); + const body: unknown = await res.json().catch(() => null); + const parsed = parseTelemetryStatus(body); + if (parsed) { + notifyTelemetryStatusChanged(parsed); + return parsed; + } + if (previous) { + const nowIso = new Date().toISOString(); + const fallback: TelemetryStatusSnapshot = { + ...previous, + effective: previous.envOverride === null && previous.decision !== "declined", + reason: + previous.reason === "awaiting-notice" ? "default-on" : previous.reason, + noticeShownAt: previous.noticeShownAt ?? nowIso, + }; + notifyTelemetryStatusChanged(fallback); + return fallback; + } + return null; + } finally { + bounded.clear(); + } +} diff --git a/gui/tests/desktop-star-onboarding.test.tsx b/gui/tests/desktop-star-onboarding.test.tsx index c2bc147a709..ec82b16e1ac 100644 --- a/gui/tests/desktop-star-onboarding.test.tsx +++ b/gui/tests/desktop-star-onboarding.test.tsx @@ -150,3 +150,43 @@ test("a disabled prompt is not shown and not marked seen", async () => { expect(host.querySelector(".star-onboarding")).toBeNull(); expect(win.localStorage.getItem(STAR_ONBOARDING_KEY)).toBeNull(); }); + +test("onSettled waits until the star prompt closes when shown, and fires immediately when skipped", async () => { + setup(DESKTOP_UA); + let settledCount = 0; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + { settledCount += 1; }} /> + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 1400)); }); + expect(host.querySelector(".star-onboarding")).toBeTruthy(); + expect(settledCount).toBe(0); + + await act(async () => { click(host.querySelector(".star-onboarding .btn-ghost")!); }); + expect(host.querySelector(".star-onboarding")).toBeNull(); + expect(settledCount).toBe(1); +}); + +test("onSettled fires without opening the dialog when already starred or outside the desktop shell", async () => { + setup(DESKTOP_UA); + starState = "starred"; + let settledCount = 0; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + { settledCount += 1; }} /> + , + ); + }); + expect(settledCount).toBe(0); + await act(async () => { await new Promise((r) => setTimeout(r, 1400)); }); + expect(host.querySelector(".star-onboarding")).toBeNull(); + expect(settledCount).toBe(1); +}); diff --git a/gui/tests/telemetry-consent-card.test.tsx b/gui/tests/telemetry-consent-card.test.tsx new file mode 100644 index 00000000000..a984b9db01d --- /dev/null +++ b/gui/tests/telemetry-consent-card.test.tsx @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { DesktopStarOnboarding } from "../src/components/desktop-star-onboarding"; +import { TelemetryConsentCard } from "../src/components/telemetry-consent-card"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { TelemetryStatusSnapshot } from "../src/telemetry-api"; + +const DESKTOP_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) OpenCodexDesktop/2.64.0"; +const BROWSER_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"; +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let statusResponse: TelemetryStatusSnapshot; +let calls: Array<{ method: string; path: string; body?: unknown }>; + +function makeStatus(overrides: Partial = {}): TelemetryStatusSnapshot { + return { + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt: null, + noticeShownAt: null, + envOverride: null, + installChannel: "npm-global", + lastSentDay: null, + lastSentAt: null, + preview: { + schemaVersion: 1, + batchId: "00000000-0000-4000-8000-000000000001", + day: "2026-11-06", + installDayHash: "0".repeat(64), + newInstall: true, + env: { + version: "2.40.1", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }, + activeMinutes: 42, + requests: { completed: 10, failed: 1, incomplete: 0, aborted: 0 }, + models: [], + errors: {}, + }, + ...overrides, + }; +} + +function setup(userAgent = BROWSER_UA) { + win = new Window({ url: "http://127.0.0.1:10100/", settings: { navigator: { userAgent } } }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + statusResponse = makeStatus(); + calls = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const path = new URL(String(input), "http://127.0.0.1").pathname; + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + calls.push(body !== undefined ? { method, path, body } : { method, path }); + if (path === "/api/github/star") { + return Response.json({ state: "not-starred", url: "https://github.com/lidge-jun/opencodex" }); + } + if (path === "/api/telemetry" && method === "PUT") { + const decision = (body as { decision: "accepted" | "declined" }).decision; + statusResponse = { + ...statusResponse, + effective: decision === "accepted", + reason: decision, + decision, + decidedAt: "2026-11-07T00:00:00.000Z", + noticeShownAt: "2026-11-07T00:00:00.000Z", + }; + return Response.json({ ok: true, ...statusResponse }); + } + if (path === "/api/telemetry/notice" && method === "POST") { + statusResponse = { + ...statusResponse, + effective: true, + reason: "default-on", + noticeShownAt: "2026-11-07T00:00:00.000Z", + }; + return Response.json({ ok: true, ...statusResponse }); + } + return Response.json(statusResponse); + }, + }); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountCard(enabled = true) { + const { createRoot } = await import("react-dom/client"); + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise(r => setTimeout(r, 20)); + }); +} + +function click(target: Element) { + target.dispatchEvent(new win.MouseEvent("click", { bubbles: true }) as unknown as Event); +} + +test("waits for DesktopStarOnboarding to settle before opening the telemetry consent dialog", async () => { + setup(DESKTOP_UA); + const { createRoot } = await import("react-dom/client"); + function SequencedHost() { + const [settled, setSettled] = useState(false); + return ( + + setSettled(true)} /> + + + ); + } + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { + await new Promise(r => setTimeout(r, 1400)); + }); + + // Only the star dialog is open; telemetry has not even fetched yet. + expect(host.querySelector(".star-onboarding")).toBeTruthy(); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(calls).toEqual([{ method: "GET", path: "/api/github/star" }]); + + // Dismissing the star dialog settles it and allows the telemetry consent card to open. + await act(async () => { + click(host.querySelector(".star-onboarding .btn-ghost")!); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector(".star-onboarding")).toBeNull(); + expect(host.querySelector(".telemetry-consent-card")).toBeTruthy(); + expect(calls).toEqual([ + { method: "GET", path: "/api/github/star" }, + { method: "GET", path: "/api/telemetry" }, + ]); +}); + +test("opens with pre-checked checkbox, shows preview JSON, and records nothing on open alone", async () => { + setup(); + await mountCard(); + const card = host.querySelector(".telemetry-consent-card"); + expect(card).toBeTruthy(); + const checkbox = host.querySelector(".telemetry-consent-checkbox")!; + expect(checkbox).toBeTruthy(); + expect(checkbox.checked).toBe(true); + expect(host.querySelector(".telemetry-preview-json")?.textContent).toContain(`"day": "2026-11-06"`); + // Opening alone must not send POST /api/telemetry/notice or PUT /api/telemetry. + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); +}); + +test("also opens after an update when reason is default-on without a recorded decision", async () => { + setup(); + statusResponse = makeStatus({ + effective: true, + reason: "default-on", + decision: null, + noticeShownAt: "2026-11-01T00:00:00.000Z", + }); + await mountCard(); + expect(host.querySelector(".telemetry-consent-card")).toBeTruthy(); + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); +}); + +test("Continue with pre-checked checkbox sends PUT accepted and closes", async () => { + setup(); + await mountCard(); + const continueBtn = host.querySelector(".telemetry-consent-card .btn-primary")!; + await act(async () => { + click(continueBtn); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([ + { method: "GET", path: "/api/telemetry" }, + { method: "PUT", path: "/api/telemetry", body: { decision: "accepted" } }, + ]); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); +}); + +test("unchecking the checkbox and clicking Continue sends PUT declined", async () => { + setup(); + await mountCard(); + const checkbox = host.querySelector(".telemetry-consent-checkbox")!; + await act(async () => { + click(checkbox); + }); + expect(checkbox.checked).toBe(false); + + const continueBtn = host.querySelector(".telemetry-consent-card .btn-primary")!; + await act(async () => { + click(continueBtn); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([ + { method: "GET", path: "/api/telemetry" }, + { method: "PUT", path: "/api/telemetry", body: { decision: "declined" } }, + ]); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); +}); + +test("Escape and backdrop dismiss send POST /api/telemetry/notice without recording a decision", async () => { + setup(); + await mountCard(); + const dialog = host.querySelector("dialog.telemetry-consent-overlay")!; + expect(dialog).toBeTruthy(); + + const WindowEvent = (win as unknown as { Event: typeof Event }).Event; + await act(async () => { + dialog.dispatchEvent(new WindowEvent("cancel", { bubbles: false, cancelable: true })); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([ + { method: "GET", path: "/api/telemetry" }, + { method: "POST", path: "/api/telemetry/notice" }, + ]); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + + // Backdrop dismiss also sends POST /api/telemetry/notice without recording a decision. + statusResponse = makeStatus(); + calls = []; + await mountCard(); + const backdrop = host.querySelector(".modal-backdrop-dismiss")!; + expect(backdrop).toBeTruthy(); + await act(async () => { + click(backdrop); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([ + { method: "GET", path: "/api/telemetry" }, + { method: "POST", path: "/api/telemetry/notice" }, + ]); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); +}); + +test("stays hidden once decided or when an environment opt-out override is active", async () => { + setup(); + for (const state of [ + makeStatus({ effective: true, reason: "accepted", decision: "accepted" }), + makeStatus({ effective: false, reason: "declined", decision: "declined" }), + makeStatus({ effective: false, reason: "env-ocx-telemetry", envOverride: "OCX_TELEMETRY" }), + makeStatus({ effective: false, reason: "env-do-not-track", envOverride: "DO_NOT_TRACK" }), + ]) { + statusResponse = state; + calls = []; + await mountCard(); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + } +}); diff --git a/gui/tests/telemetry-settings-panel.test.tsx b/gui/tests/telemetry-settings-panel.test.tsx new file mode 100644 index 00000000000..592a02c10b4 --- /dev/null +++ b/gui/tests/telemetry-settings-panel.test.tsx @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import TelemetrySettingsPanel from "../src/components/TelemetrySettingsPanel"; +import { LanguageProvider } from "../src/i18n/provider"; +import AdvancedHub from "../src/pages/AdvancedHub"; +import { PROXY_SECTION_IDS } from "../src/pages/dashboard-section-ids"; +import type { TelemetryStatusSnapshot } from "../src/telemetry-api"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let statusResponse: TelemetryStatusSnapshot; +let calls: Array<{ method: string; path: string; body?: unknown }>; + +function makeStatus(overrides: Partial = {}): TelemetryStatusSnapshot { + return { + effective: true, + reason: "accepted", + decision: "accepted", + decidedAt: "2026-11-07T00:00:00.000Z", + noticeShownAt: "2026-11-07T00:00:00.000Z", + envOverride: null, + installChannel: "npm-global", + lastSentDay: "2026-11-06", + lastSentAt: "2026-11-07T01:00:00.000Z", + preview: { + schemaVersion: 1, + batchId: "00000000-0000-4000-8000-000000000002", + 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: [], + errors: { "rate-limit": 7 }, + }, + ...overrides, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + win = new Window({ url: "http://127.0.0.1:10100/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + statusResponse = makeStatus(); + calls = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const path = new URL(String(input), "http://127.0.0.1").pathname; + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + calls.push(body !== undefined ? { method, path, body } : { method, path }); + if (path === "/api/telemetry" && method === "PUT") { + const decision = (body as { decision: "accepted" | "declined" }).decision; + statusResponse = { + ...statusResponse, + effective: decision === "accepted", + reason: decision, + decision, + }; + return Response.json({ ok: true, ...statusResponse }); + } + return Response.json(statusResponse); + }, + }); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountPanel(readOnly = false) { + const { createRoot } = await import("react-dom/client"); + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise(r => setTimeout(r, 30)); + }); +} + +function click(target: Element) { + target.dispatchEvent(new win.MouseEvent("click", { bubbles: true }) as unknown as Event); +} + +function toggleSwitch(): HTMLButtonElement { + return host.querySelector('button[role="switch"]')!; +} + +test("switch mirrors effective state and toggles off and back on via PUT /api/telemetry", async () => { + await mountPanel(); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("true"); + expect(toggleSwitch().disabled).toBe(false); + + await act(async () => { + click(toggleSwitch()); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toContainEqual({ + method: "PUT", + path: "/api/telemetry", + body: { decision: "declined" }, + }); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + click(toggleSwitch()); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toContainEqual({ + method: "PUT", + path: "/api/telemetry", + body: { decision: "accepted" }, + }); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("true"); +}); + +test("disables switch and renders a warning notice naming the variable when OCX_TELEMETRY=0 or DO_NOT_TRACK=1 is set", async () => { + statusResponse = makeStatus({ + effective: false, + reason: "env-ocx-telemetry", + envOverride: "OCX_TELEMETRY", + }); + await mountPanel(); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("false"); + expect(toggleSwitch().disabled).toBe(true); + const warn = host.querySelector(".notice-warn"); + expect(warn?.textContent).toContain("OCX_TELEMETRY=0"); + + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + statusResponse = makeStatus({ + effective: false, + reason: "env-do-not-track", + envOverride: "DO_NOT_TRACK", + }); + await mountPanel(); + expect(toggleSwitch().disabled).toBe(true); + expect(host.querySelector(".notice-warn")?.textContent).toContain("DO_NOT_TRACK=1"); +}); + +test("disables switch in readOnly mode for Remote Link children", async () => { + await mountPanel(true); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("true"); + expect(toggleSwitch().disabled).toBe(true); + await act(async () => { + click(toggleSwitch()); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); +}); + +test("What is sent button opens the preview dialog with the exact JSON payload and closes cleanly", async () => { + await mountPanel(); + expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); + + const trigger = host.querySelector(".telemetry-preview-trigger")!; + expect(trigger).toBeTruthy(); + await act(async () => { + click(trigger); + }); + + const dialog = host.querySelector("dialog.telemetry-preview-overlay"); + expect(dialog).toBeTruthy(); + const pre = dialog?.querySelector("pre.telemetry-preview-json"); + expect(pre?.textContent).toBe(JSON.stringify(statusResponse.preview, null, 2)); + + const closeBtn = dialog?.querySelector(".modal-actions .btn-primary")!; + await act(async () => { + click(closeBtn); + }); + expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); +}); + +test("PROXY_SECTION_IDS registers proxy-telemetry and AdvancedHub matches telemetry and privacy search queries", async () => { + expect(PROXY_SECTION_IDS).toContainEqual(["proxy-telemetry", "proxyV2.secTelemetry"]); + + const { createRoot } = await import("react-dom/client"); + const navigated: string[] = []; + await act(async () => { + root = createRoot(host); + root.render( + + navigated.push(page)} /> + , + ); + }); + const searchInput = host.querySelector('input[type="search"]')!; + expect(searchInput).toBeTruthy(); + + for (const query of ["telemetry", "privacy"]) { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")?.set; + setter?.call(searchInput, query); + searchInput.dispatchEvent(new win.Event("input", { bubbles: true }) as unknown as Event); + searchInput.dispatchEvent(new win.Event("change", { bubbles: true }) as unknown as Event); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.textContent).toContain("1 shown"); + } +}); From 9bb285643ccc7004b37bdaa174b07d883ff88322 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:04:38 +0900 Subject: [PATCH 07/63] fix(gui): prevent stacked telemetry dialogs, restore preview focus, and cover failure paths --- gui/src/App.tsx | 4 +- gui/src/components/TelemetrySettingsPanel.tsx | 69 ++++++- gui/src/components/telemetry-consent-card.tsx | 55 +++++- gui/src/i18n/v2-pages/proxy-copy.ts | 6 +- gui/src/telemetry-api.ts | 51 ++++- gui/tests/telemetry-consent-card.test.tsx | 174 +++++++++++++++++- gui/tests/telemetry-settings-panel.test.tsx | 118 +++++++++++- 7 files changed, 457 insertions(+), 20 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 597e8f98464..b72127e683e 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -472,12 +472,12 @@ export default function App() {
{actionFeedback && ( setActionFeedback(null)} dismissLabel={t("common.close")}> diff --git a/gui/src/components/TelemetrySettingsPanel.tsx b/gui/src/components/TelemetrySettingsPanel.tsx index 87c97fb93c6..2f17de27f22 100644 --- a/gui/src/components/TelemetrySettingsPanel.tsx +++ b/gui/src/components/TelemetrySettingsPanel.tsx @@ -5,7 +5,9 @@ import { TELEMETRY_STATUS_CHANGED_EVENT, fetchTelemetryStatus, formatTelemetryPreviewJson, + hasOtherOpenModalDialog, putTelemetryDecision, + subscribeModalDialogChanges, type TelemetryDecision, type TelemetryStatusSnapshot, } from "../telemetry-api"; @@ -13,6 +15,8 @@ import { Notice, Switch } from "../ui"; import { startVisibilityPoll } from "../visibility-poll"; import { SettingsRow } from "./v2-proxy/settings-layout"; +const PREVIEW_DIALOG_SELECTOR = "dialog.telemetry-preview-overlay"; + /** * Proxy details row and preview dialog for anonymous daily usage telemetry. */ @@ -29,11 +33,15 @@ export function TelemetrySettingsPanel({ const [saveFailed, setSaveFailed] = useState(false); const [saving, setSaving] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); + const [, setModalRevision] = useState(0); const savingRef = useRef(false); const seqRef = useRef(0); const previewTriggerRef = useRef(null); + const restoreFocusRef = useRef(null); const previewDialogRef = useRef(null); + const previewCloseBtnRef = useRef(null); const previewTitleId = useId(); + const previewDescId = useId(); const load = useCallback(async () => { if (savingRef.current) return; @@ -76,15 +84,50 @@ export function TelemetrySettingsPanel({ useEffect(() => { if (!previewOpen) return; + let lastBlocked = hasOtherOpenModalDialog(PREVIEW_DIALOG_SELECTOR); + const check = () => { + const nextBlocked = hasOtherOpenModalDialog(PREVIEW_DIALOG_SELECTOR); + if (nextBlocked !== lastBlocked) { + lastBlocked = nextBlocked; + setModalRevision(rev => rev + 1); + } + }; + check(); + return subscribeModalDialogChanges(check); + }, [previewOpen]); + + const blockedByOtherModal = previewOpen && hasOtherOpenModalDialog(PREVIEW_DIALOG_SELECTOR); + const showPreviewDialog = Boolean(previewOpen && status && !blockedByOtherModal); + + useEffect(() => { + if (!showPreviewDialog) return; const dialog = previewDialogRef.current; if (dialog && !dialog.open) { - dialog.showModal?.(); + if (typeof dialog.showModal === "function") { + dialog.showModal(); + } else { + dialog.setAttribute("open", ""); + } } + previewCloseBtnRef.current?.focus(); + return () => { + if (dialog?.open) dialog.close?.(); + }; + }, [showPreviewDialog]); + + useEffect(() => { + if (previewOpen) return; + const trigger = restoreFocusRef.current; + if (!trigger) return; + restoreFocusRef.current = null; + if (trigger.isConnected) trigger.focus(); }, [previewOpen]); const closePreview = useCallback(() => { + if (previewDialogRef.current?.open) { + previewDialogRef.current.close?.(); + } setPreviewOpen(false); - previewTriggerRef.current?.focus(); }, []); const effective = status?.effective ?? false; @@ -146,8 +189,11 @@ export function TelemetrySettingsPanel({ className="btn btn-ghost btn-sm telemetry-preview-trigger" disabled={!status} aria-haspopup="dialog" - aria-expanded={previewOpen} - onClick={() => setPreviewOpen(true)} + aria-expanded={showPreviewDialog} + onClick={() => { + restoreFocusRef.current = previewTriggerRef.current; + setPreviewOpen(true); + }} > {t("proxyV2.telemetryWhatIsSent")} @@ -169,10 +215,11 @@ export function TelemetrySettingsPanel({ {saveFailed && {t("proxyV2.telemetrySaveFailed")}} - {previewOpen && status && ( + {showPreviewDialog && status && ( { event.preventDefault(); @@ -216,10 +263,18 @@ export function TelemetrySettingsPanel({ {t("proxyV2.telemetryNeverItems")}
-

{t("proxyV2.telemetryPreviewHint")}

+

+ {t("proxyV2.telemetryPreviewHint")} +

{formatTelemetryPreviewJson(status)}
-
diff --git a/gui/src/components/telemetry-consent-card.tsx b/gui/src/components/telemetry-consent-card.tsx index 3aceb1137cf..24768935ba3 100644 --- a/gui/src/components/telemetry-consent-card.tsx +++ b/gui/src/components/telemetry-consent-card.tsx @@ -1,15 +1,20 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import { + TELEMETRY_STATUS_CHANGED_EVENT, fetchTelemetryStatus, formatTelemetryPreviewJson, + hasOtherOpenModalDialog, postTelemetryNotice, putTelemetryDecision, shouldShowTelemetryConsentPrompt, + subscribeModalDialogChanges, type TelemetryStatusSnapshot, } from "../telemetry-api"; import { Notice } from "../ui"; +const CONSENT_DIALOG_SELECTOR = "dialog.telemetry-consent-overlay"; + /** * First-run and post-update telemetry disclosure dialog. * @@ -27,13 +32,31 @@ export function TelemetryConsentCard({ }) { const [status, setStatus] = useState(null); const [closed, setClosed] = useState(false); + const [, setModalRevision] = useState(0); + const externalUpdateRef = useRef(false); + + useEffect(() => { + const onStatusChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + externalUpdateRef.current = true; + setStatus(detail); + if (!shouldShowTelemetryConsentPrompt(detail)) { + setClosed(true); + } + }; + window.addEventListener(TELEMETRY_STATUS_CHANGED_EVENT, onStatusChanged); + return () => { + window.removeEventListener(TELEMETRY_STATUS_CHANGED_EVENT, onStatusChanged); + }; + }, []); useEffect(() => { if (!enabled || closed) return; const controller = new AbortController(); void fetchTelemetryStatus(apiBase, controller.signal) .then(snapshot => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || externalUpdateRef.current) return; if (!shouldShowTelemetryConsentPrompt(snapshot)) { setClosed(true); return; @@ -46,11 +69,30 @@ export function TelemetryConsentCard({ return () => controller.abort(); }, [apiBase, closed, enabled]); + const wantsOpen = Boolean( + enabled && !closed && status && shouldShowTelemetryConsentPrompt(status), + ); + const blockedByOtherModal = wantsOpen && hasOtherOpenModalDialog(CONSENT_DIALOG_SELECTOR); + + useEffect(() => { + if (!wantsOpen) return; + let lastBlocked = hasOtherOpenModalDialog(CONSENT_DIALOG_SELECTOR); + const check = () => { + const nextBlocked = hasOtherOpenModalDialog(CONSENT_DIALOG_SELECTOR); + if (nextBlocked !== lastBlocked) { + lastBlocked = nextBlocked; + setModalRevision(rev => rev + 1); + } + }; + check(); + return subscribeModalDialogChanges(check); + }, [wantsOpen]); + const handleClose = useCallback(() => { setClosed(true); }, []); - if (!enabled || closed || !status) return null; + if (!wantsOpen || blockedByOtherModal || !status) return null; return ; } @@ -75,8 +117,15 @@ function TelemetryConsentDialog({ useEffect(() => { const dialog = dialogRef.current; if (dialog && !dialog.open) { - dialog.showModal?.(); + if (typeof dialog.showModal === "function") { + dialog.showModal(); + } else { + dialog.setAttribute("open", ""); + } } + return () => { + if (dialog?.open) dialog.close?.(); + }; }, []); const handleContinue = useCallback(async () => { diff --git a/gui/src/i18n/v2-pages/proxy-copy.ts b/gui/src/i18n/v2-pages/proxy-copy.ts index d7c0988992d..cb6856bc1b6 100644 --- a/gui/src/i18n/v2-pages/proxy-copy.ts +++ b/gui/src/i18n/v2-pages/proxy-copy.ts @@ -39,14 +39,14 @@ const rows = { telemetryToggleLabel: ["Share anonymous usage statistics", "익명 사용 통계 공유", "Anonyme Nutzungsstatistiken teilen", "Partager des statistiques d'utilisation anonymes", "共享匿名使用统计", "共享匿名使用統計", "Делиться анонимной статистикой использования", "匿名の利用統計を共有する", "Anonim kullanım istatistiklerini paylaş", "Chia sẻ thống kê sử dụng ẩn danh", "Compartilhar estatísticas anônimas de uso"], telemetryToggleHint: ["Sends one daily aggregate of version, platform, coarse model family, token totals and error counts. On by default.", "버전, 플랫폼, 모델 계열, 토큰 합계, 오류 건수의 일일 집계만 하루 한 번 보내요. 기본값은 켜짐이에요.", "Sendet einmal täglich aggregierte Angaben zu Version, Plattform, grober Modellfamilie, Token-Summen und Fehlerzahlen. Standardmäßig an.", "Envoie une synthèse quotidienne (version, plateforme, famille de modèles, totaux de jetons et nombres d'erreurs). Activé par défaut.", "每天发送一次版本、平台、模型系列、令牌总数与错误计数的汇总。默认开启。", "每天傳送一次版本、平台、模型系列、Token 總數與錯誤計數的彙總。預設開啟。", "Раз в день отправляет сводку: версия, платформа, семейство моделей, сумма токенов и число ошибок. По умолчанию включено.", "バージョン、OS、モデル系統、トークン合計、エラー件数の日次集計を1日1回送信します。既定はオンです。", "Sürüm, platform, model ailesi, jeton toplamları ve hata sayılarının günlük özetini günde bir kez gönderir. Varsayılan olarak açıktır.", "Gửi một bản tổng hợp mỗi ngày gồm phiên bản, nền tảng, dòng mô hình, tổng token và số lỗi. Mặc định bật.", "Envia um resumo diário com versão, plataforma, família de modelos, totais de tokens e contagem de erros. Ativado por padrão."], telemetryDetails: ["Reports use a per-day hash of a random local install ID. Disabling telemetry deletes that ID immediately. Prompts, responses, API keys, account emails, custom URLs and file paths are never collected.", "보고서는 무작위 로컬 설치 ID의 일일 해시를 사용하며, 끄면 해당 ID를 즉시 삭제해요. 프롬프트, 응답, API 키, 계정 이메일, 사용자 지정 URL, 파일 경로는 절대 수집하지 않아요.", "Berichte nutzen einen täglichen Hash einer zufälligen lokalen Installations-ID. Beim Deaktivieren wird diese ID sofort gelöscht. Prompts, Antworten, API-Schlüssel, E-Mail-Adressen, eigene URLs und Dateipfade werden nie erfasst.", "Les rapports utilisent un hachage quotidien d'un identifiant local aléatoire, supprimé dès la désactivation. Prompts, réponses, clés API, e-mails, URL personnalisées et chemins de fichiers ne sont jamais collectés.", "报告使用随机本地安装 ID 的每日哈希;关闭遥测会立即删除该 ID。绝不收集提示词、回复、API 密钥、账户邮箱、自定义 URL 或文件路径。", "報告使用隨機本機安裝 ID 的每日雜湊;關閉遙測會立即刪除該 ID。絕不收集提示詞、回覆、API 金鑰、帳號信箱、自訂 URL 或檔案路徑。", "Отчёты используют суточный хеш случайного локального ID установки; при отключении этот ID сразу удаляется. Промпты, ответы, API-ключи, адреса почты, свои URL и пути к файлам никогда не собираются.", "レポートにはランダムなローカルインストールIDの日次ハッシュを使い、オフにするとそのIDは即座に削除されます。プロンプト、応答、APIキー、メールアドレス、カスタムURL、ファイルパスは一切収集されません。", "Raporlar rastgele yerel kurulum kimliğinin günlük karmasını kullanır; kapatıldığında bu kimlik hemen silinir. İstemler, yanıtlar, API anahtarları, e-postalar, özel URL'ler ve dosya yolları asla toplanmaz.", "Báo cáo dùng mã băm theo ngày của ID cài đặt ngẫu nhiên cục bộ; tắt telemetri sẽ xóa ID đó ngay lập tức. Prompt, phản hồi, khóa API, email tài khoản, URL tùy chỉnh và đường dẫn tệp không bao giờ được thu thập.", "Os relatórios usam um hash diário de um ID de instalação local aleatório; desativar a telemetria apaga esse ID imediatamente. Prompts, respostas, chaves de API, e-mails, URLs personalizadas e caminhos de arquivos nunca são coletados."], - telemetryWhatIsSent: ["What is sent", "전송되는 항목", "Was gesendet wird", "Ce qui est envoyé", "发送内容", "傳送內容", "Что отправляется", "送信される内容", "Ne gönderilir", "Dữ liệu được gửi", "O que é enviado"], + telemetryWhatIsSent: ["Preview the JSON", "JSON 미리보기", "JSON-Vorschau", "Aperçu du JSON", "预览 JSON", "預覽 JSON", "Предпросмотр JSON", "JSONをプレビュー", "JSON önizlemesi", "Xem trước JSON", "Pré-visualizar o JSON"], telemetryPreviewHint: ["Exact JSON payload prepared from the latest completed UTC day:", "가장 최근 완료된 UTC 일자 기준으로 준비된 실제 JSON 페이로드:", "Exakte JSON-Nutzlast aus dem letzten abgeschlossenen UTC-Tag:", "Charge JSON exacte préparée à partir du dernier jour UTC complet :", "根据最近一个完整 UTC 日生成的完整 JSON 载荷:", "根據最近一個完整 UTC 日產生的完整 JSON 負載:", "Точный JSON-пакет, собранный за последние завершённые сутки UTC:", "直近の完了したUTC日に基づいて生成された実際のJSONペイロード:", "Tamamlanan son UTC gününden hazırlanan tam JSON yükü:", "Payload JSON chính xác được tạo từ ngày UTC hoàn tất gần nhất:", "Payload JSON exato preparado a partir do último dia UTC concluído:"], telemetryEnvOverride: ["Disabled by environment variable {envVar}. Unset it in the proxy environment to change this setting here.", "환경 변수 {envVar}로 인해 비활성화되었어요. 여기서 바꾸려면 프록시 환경에서 해당 변수를 해제하세요.", "Durch die Umgebungsvariable {envVar} deaktiviert. Entfernen Sie sie aus der Proxy-Umgebung, um die Einstellung hier zu ändern.", "Désactivé par la variable d'environnement {envVar}. Retirez-la de l'environnement du proxy pour modifier ce réglage ici.", "已被环境变量 {envVar} 禁用。若要在此更改,请在代理环境中取消设置该变量。", "已被環境變數 {envVar} 停用。若要在此變更,請在代理環境中取消設定該變數。", "Отключено переменной окружения {envVar}. Уберите её из окружения прокси, чтобы менять настройку здесь.", "環境変数 {envVar} により無効化されています。ここで変更するにはプロキシ環境から解除してください。", "{envVar} ortam değişkeni tarafından devre dışı bırakıldı. Buradan değiştirmek için proxy ortamında kaldırın.", "Đã tắt bởi biến môi trường {envVar}. Hãy bỏ biến này khỏi môi trường proxy để thay đổi tại đây.", "Desativado pela variável de ambiente {envVar}. Remova-a do ambiente do proxy para alterar esta opção aqui."], telemetryReadOnlyHint: ["Managed on the Home proxy for this Remote Link.", "이 원격 링크의 Home 프록시에서 관리돼요.", "Wird auf dem Home-Proxy dieses Remote Links verwaltet.", "Géré sur le proxy Home de ce lien distant.", "由该远程连接的 Home 代理管理。", "由該遠端連線的 Home 代理管理。", "Управляется на прокси Home этой удалённой связки.", "このリモートリンクの Home プロキシで管理されています。", "Bu Uzak Bağlantının Home proxy'sinde yönetilir.", "Được quản lý trên proxy Home của Liên kết từ xa này.", "Gerenciado no proxy Home deste Remote Link."], telemetryLoadFailed: ["Could not load telemetry status.", "텔레메트리 상태를 불러오지 못했어요.", "Telemetriestatus konnte nicht geladen werden.", "Impossible de charger l'état de la télémétrie.", "无法加载遥测状态。", "無法載入遙測狀態。", "Не удалось загрузить состояние телеметрии.", "テレメトリの状態を読み込めませんでした。", "Telemetri durumu yüklenemedi.", "Không thể tải trạng thái telemetri.", "Não foi possível carregar o status da telemetria."], telemetrySaveFailed: ["Could not save telemetry preference.", "텔레메트리 설정을 저장하지 못했어요.", "Telemetrie-Einstellung konnte nicht gespeichert werden.", "Impossible d'enregistrer la préférence de télémétrie.", "无法保存遥测偏好设置。", "無法儲存遙測偏好設定。", "Не удалось сохранить настройку телеметрии.", "テレメトリ設定を保存できませんでした。", "Telemetri tercihi kaydedilemedi.", "Không thể lưu tùy chọn telemetri.", "Não foi possível salvar a preferência de telemetria."], - telemetryConsentTitle: ["Help improve OpenCodex with anonymous usage stats", "익명 사용 통계로 OpenCodex 개선에 함께해 주세요", "OpenCodex mit anonymen Nutzungsstatistiken verbessern", "Aider à améliorer OpenCodex avec des statistiques anonymes", "通过匿名使用统计帮助改进 OpenCodex", "透過匿名使用統計協助改進 OpenCodex", "Помогите улучшать OpenCodex анонимной статистикой", "匿名の利用統計で OpenCodex の改善に協力する", "Anonim kullanım istatistikleriyle OpenCodex'i geliştirmeye yardımcı olun", "Giúp cải thiện OpenCodex bằng thống kê sử dụng ẩn danh", "Ajude a melhorar o OpenCodex com estatísticas anônimas de uso"], - telemetryConsentBody: ["OpenCodex can share one daily anonymous summary with the public project dashboard at opencodex.me/dashboard. You can change this anytime in Proxy details or with ocx telemetry.", "OpenCodex는 하루 한 번 익명 요약 통계를 공개 대시보드(opencodex.me/dashboard)와 공유할 수 있어요. 프록시 상세 설정이나 ocx telemetry 명령으로 언제든 바꿀 수 있어요.", "OpenCodex kann einmal täglich eine anonyme Zusammenfassung mit dem öffentlichen Projekt-Dashboard unter opencodex.me/dashboard teilen. Sie können dies jederzeit in den Proxy-Details oder mit ocx telemetry ändern.", "OpenCodex peut partager un résumé quotidien anonyme avec le tableau de bord public sur opencodex.me/dashboard. Modifiable à tout moment dans les détails du proxy ou via ocx telemetry.", "OpenCodex 可每天向公开项目仪表盘 opencodex.me/dashboard 发送一次匿名汇总。您可以随时在代理详情或通过 ocx telemetry 更改。", "OpenCodex 可每天向公開專案儀表板 opencodex.me/dashboard 傳送一次匿名彙總。您可以隨時在代理詳情或透過 ocx telemetry 變更。", "OpenCodex может раз в день отправлять анонимную сводку в публичный дашборд проекта opencodex.me/dashboard. Это можно изменить в любой момент в настройках прокси или командой ocx telemetry.", "OpenCodex は1日1回、公開ダッシュボード(opencodex.me/dashboard)へ匿名の集計を共有できます。プロキシ詳細や ocx telemetry からいつでも変更できます。", "OpenCodex, opencodex.me/dashboard adresindeki herkese açık proje panosuyla günde bir kez anonim özet paylaşabilir. Bunu Proxy ayrıntılarından veya ocx telemetry ile istediğiniz zaman değiştirebilirsiniz.", "OpenCodex có thể chia sẻ bản tóm tắt ẩn danh mỗi ngày một lần với bảng điều khiển công khai tại opencodex.me/dashboard. Bạn có thể thay đổi bất cứ lúc nào trong Chi tiết proxy hoặc bằng ocx telemetry.", "O OpenCodex pode compartilhar um resumo diário anônimo com o painel público do projeto em opencodex.me/dashboard. Você pode alterar isso a qualquer momento nos detalhes do proxy ou com ocx telemetry."], + telemetryConsentTitle: ["Help improve opencodex with anonymous usage stats", "익명 사용 통계로 opencodex 개선에 함께해 주세요", "opencodex mit anonymen Nutzungsstatistiken verbessern", "Aider à améliorer opencodex avec des statistiques anonymes", "通过匿名使用统计帮助改进 opencodex", "透過匿名使用統計協助改進 opencodex", "Помогите улучшать opencodex анонимной статистикой", "匿名の利用統計で opencodex の改善に協力する", "Anonim kullanım istatistikleriyle opencodex'i geliştirmeye yardımcı olun", "Giúp cải thiện opencodex bằng thống kê sử dụng ẩn danh", "Ajude a melhorar o opencodex com estatísticas anônimas de uso"], + telemetryConsentBody: ["opencodex can share one daily anonymous summary with the public project dashboard at opencodex.me/dashboard. You can change this anytime in Proxy details or with ocx telemetry.", "opencodex는 하루 한 번 익명 요약 통계를 공개 대시보드(opencodex.me/dashboard)와 공유할 수 있어요. 프록시 상세 설정이나 ocx telemetry 명령으로 언제든 바꿀 수 있어요.", "opencodex kann einmal täglich eine anonyme Zusammenfassung mit dem öffentlichen Projekt-Dashboard unter opencodex.me/dashboard teilen. Sie können dies jederzeit in den Proxy-Details oder mit ocx telemetry ändern.", "opencodex peut partager un résumé quotidien anonyme avec le tableau de bord public sur opencodex.me/dashboard. Modifiable à tout moment dans les détails du proxy ou via ocx telemetry.", "opencodex 可每天向公开项目仪表盘 opencodex.me/dashboard 发送一次匿名汇总。您可以随时在代理详情或通过 ocx telemetry 更改。", "opencodex 可每天向公開專案儀表板 opencodex.me/dashboard 傳送一次匿名彙總。您可以隨時在代理詳情或透過 ocx telemetry 變更。", "opencodex может раз в день отправлять анонимную сводку в публичный дашборд проекта opencodex.me/dashboard. Это можно изменить в любой момент в настройках прокси или командой ocx telemetry.", "opencodex は1日1回、公開ダッシュボード(opencodex.me/dashboard)へ匿名の集計を共有できます。プロキシ詳細や ocx telemetry からいつでも変更できます。", "opencodex, opencodex.me/dashboard adresindeki herkese açık proje panosuyla günde bir kez anonim özet paylaşabilir. Bunu Proxy ayrıntılarından veya ocx telemetry ile istediğiniz zaman değiştirebilirsiniz.", "opencodex có thể chia sẻ bản tóm tắt ẩn danh mỗi ngày một lần với bảng điều khiển công khai tại opencodex.me/dashboard. Bạn có thể thay đổi bất cứ lúc nào trong Chi tiết proxy hoặc bằng ocx telemetry.", "O opencodex pode compartilhar um resumo diário anônimo com o painel público do projeto em opencodex.me/dashboard. Você pode alterar isso a qualquer momento nos detalhes do proxy ou com ocx telemetry."], telemetrySentTitle: ["What is sent", "보내는 항목", "Gesendet wird", "Envoyé", "发送的数据", "傳送的資料", "Отправляется", "送信されるもの", "Gönderilenler", "Được gửi", "Enviado"], telemetrySentItems: ["Version, OS/CPU, install channel, active minutes, provider kind, coarse model family, request outcomes, token totals, and closed error codes.", "버전, OS/CPU, 설치 채널, 활성 시간(분), 프로바이더 종류, 모델 계열, 요청 결과, 토큰 합계, 고정된 오류 코드.", "Version, OS/CPU, Installationskanal, aktive Minuten, Anbieterart, grobe Modellfamilie, Anfrageergebnisse, Token-Summen und feste Fehlercodes.", "Version, OS/CPU, canal d'installation, minutes actives, type de fournisseur, famille de modèles, résultats des requêtes, totaux de jetons et codes d'erreur fermés.", "版本、系统与架构、安装渠道、活跃分钟数、提供商类型、粗粒度模型系列、请求结果、令牌总数及固定错误码。", "版本、系統與架構、安裝管道、活躍分鐘數、供應商類型、粗粒度模型系列、請求結果、Token 總數及固定錯誤碼。", "Версия, ОС и архитектура, канал установки, активные минуты, тип провайдера, семейство моделей, исходы запросов, суммы токенов и закрытые коды ошибок.", "バージョン、OS/CPU、インストール経路、アクティブ分数、プロバイダー種別、大まかなモデル系統、リクエスト結果、トークン合計、固定エラーコード。", "Sürüm, İS/CPU, kurulum kanalı, etkin dakikalar, sağlayıcı türü, kaba model ailesi, istek sonuçları, jeton toplamları ve kapalı hata kodları.", "Phiên bản, OS/CPU, kênh cài đặt, số phút hoạt động, loại nhà cung cấp, dòng mô hình, kết quả yêu cầu, tổng token và mã lỗi cố định.", "Versão, SO/CPU, canal de instalação, minutos ativos, tipo de provedor, família de modelos, resultados de requisições, totais de tokens e códigos fechados de erro."], telemetryNeverTitle: ["Never sent", "절대 보내지 않는 항목", "Niemals gesendet", "Jamais envoyé", "绝不发送", "絕不傳送", "Никогда не отправляется", "送信されないもの", "Asla gönderilmeyenler", "Không bao giờ gửi", "Nunca enviado"], diff --git a/gui/src/telemetry-api.ts b/gui/src/telemetry-api.ts index aae1692bee9..7cdb565017d 100644 --- a/gui/src/telemetry-api.ts +++ b/gui/src/telemetry-api.ts @@ -95,12 +95,61 @@ export function formatTelemetryPreviewJson(status: TelemetryStatusSnapshot | nul function notifyTelemetryStatusChanged(snapshot: TelemetryStatusSnapshot): void { if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") return; try { - window.dispatchEvent(new CustomEvent(TELEMETRY_STATUS_CHANGED_EVENT, { detail: snapshot })); + const CustomEventCtor = + (window as Window & { CustomEvent?: typeof CustomEvent }).CustomEvent ?? CustomEvent; + window.dispatchEvent(new CustomEventCtor(TELEMETRY_STATUS_CHANGED_EVENT, { detail: snapshot })); } catch { // Best-effort cross-component notification. } } +/** + * True when another modal `` in `document` is currently open. + * Used by telemetry dialogs so two modal overlays never stack. + */ +export function hasOtherOpenModalDialog(ownSelector?: string): boolean { + if (typeof document === "undefined") return false; + const dialogs = document.querySelectorAll("dialog"); + for (const dialog of dialogs) { + if (ownSelector && dialog.matches(ownSelector)) continue; + const isOpen = + dialog.open || + dialog.hasAttribute("open") || + (dialog.classList.contains("modal-overlay") && dialog.style.display !== "none"); + if (isOpen) return true; + } + return false; +} + +export function subscribeModalDialogChanges(onChange: () => void): () => void { + if (typeof document === "undefined") return () => {}; + const target = document.body ?? document.documentElement; + const ObserverCtor = + (typeof window !== "undefined" + ? (window as Window & { MutationObserver?: typeof MutationObserver }).MutationObserver + : undefined) ?? + (typeof MutationObserver !== "undefined" ? MutationObserver : undefined); + const observer = + ObserverCtor && target + ? new ObserverCtor(() => { + onChange(); + }) + : null; + observer?.observe(target, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["open", "style", "class"], + }); + document.addEventListener("close", onChange, true); + document.addEventListener("cancel", onChange, true); + return () => { + observer?.disconnect(); + document.removeEventListener("close", onChange, true); + document.removeEventListener("cancel", onChange, true); + }; +} + export async function fetchTelemetryStatus( apiBase: string, callerSignal?: AbortSignal, diff --git a/gui/tests/telemetry-consent-card.test.tsx b/gui/tests/telemetry-consent-card.test.tsx index a984b9db01d..5e1c1bcc0c9 100644 --- a/gui/tests/telemetry-consent-card.test.tsx +++ b/gui/tests/telemetry-consent-card.test.tsx @@ -5,7 +5,10 @@ import type { Root } from "react-dom/client"; import { DesktopStarOnboarding } from "../src/components/desktop-star-onboarding"; import { TelemetryConsentCard } from "../src/components/telemetry-consent-card"; import { LanguageProvider } from "../src/i18n/provider"; -import type { TelemetryStatusSnapshot } from "../src/telemetry-api"; +import { + TELEMETRY_STATUS_CHANGED_EVENT, + type TelemetryStatusSnapshot, +} from "../src/telemetry-api"; const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) OpenCodexDesktop/2.64.0"; @@ -19,6 +22,8 @@ let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let statusResponse: TelemetryStatusSnapshot; let calls: Array<{ method: string; path: string; body?: unknown }>; +let failMethods: Set; +let statusEvents: TelemetryStatusSnapshot[]; function makeStatus(overrides: Partial = {}): TelemetryStatusSnapshot { return { @@ -72,6 +77,8 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; statusResponse = makeStatus(); calls = []; + failMethods = new Set(); + statusEvents = []; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -79,6 +86,9 @@ beforeEach(() => { const path = new URL(String(input), "http://127.0.0.1").pathname; const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; calls.push(body !== undefined ? { method, path, body } : { method, path }); + if (failMethods.has(`${method} ${path}`)) { + return new Response(JSON.stringify({ error: "unavailable" }), { status: 503 }); + } if (path === "/api/github/star") { return Response.json({ state: "not-starred", url: "https://github.com/lidge-jun/opencodex" }); } @@ -192,6 +202,9 @@ test("opens with pre-checked checkbox, shows preview JSON, and records nothing o await mountCard(); const card = host.querySelector(".telemetry-consent-card"); expect(card).toBeTruthy(); + expect(card?.querySelector("h3")?.textContent).toBe("Help improve opencodex with anonymous usage stats"); + expect(card?.querySelector(".modal-desc")?.textContent).toContain("opencodex can share"); + expect(card?.querySelector("summary")?.textContent).toBe("Preview the JSON"); const checkbox = host.querySelector(".telemetry-consent-checkbox")!; expect(checkbox).toBeTruthy(); expect(checkbox.checked).toBe(true); @@ -305,3 +318,162 @@ test("stays hidden once decided or when an environment opt-out override is activ } } }); + +test("subscribes to TELEMETRY_STATUS_CHANGED_EVENT and closes when a decision is recorded elsewhere", async () => { + setup(); + await mountCard(); + expect(host.querySelector(".telemetry-consent-card")).toBeTruthy(); + + const CustomEventCtor = (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent; + const decided = makeStatus({ + effective: false, + reason: "declined", + decision: "declined", + decidedAt: "2026-11-07T00:00:00.000Z", + }); + await act(async () => { + win.dispatchEvent(new CustomEventCtor(TELEMETRY_STATUS_CHANGED_EVENT, { detail: decided })); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); +}); + +test("defers opening while another modal dialog is open and opens once it closes", async () => { + setup(); + const otherDialog = win.document.createElement("dialog") as unknown as HTMLDialogElement; + otherDialog.className = "modal-overlay other-modal"; + otherDialog.setAttribute("open", ""); + win.document.body.appendChild(otherDialog as never); + + await mountCard(); + expect(host.querySelector("dialog.telemetry-consent-overlay")).toBeNull(); + + await act(async () => { + otherDialog.removeAttribute("open"); + otherDialog.remove(); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector("dialog.telemetry-consent-overlay")).toBeTruthy(); +}); + +test("handles GET, PUT, and POST failures without recording consent or trapping the user", async () => { + setup(); + win.addEventListener(TELEMETRY_STATUS_CHANGED_EVENT, ((event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) statusEvents.push(detail); + }) as never); + + // 1. GET /api/telemetry failure keeps the consent card closed and writes nothing. + failMethods.add("GET /api/telemetry"); + await mountCard(); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); + expect(statusEvents).toEqual([]); + + // 2. PUT /api/telemetry failure keeps the card open, shows error notice, and emits no status change. + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + failMethods.clear(); + failMethods.add("PUT /api/telemetry"); + calls = []; + await mountCard(); + const continueBtn = host.querySelector(".telemetry-consent-card .btn-primary")!; + await act(async () => { + click(continueBtn); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector(".telemetry-consent-card")).toBeTruthy(); + expect(host.querySelector(".notice-err")?.textContent).toContain("Could not save telemetry preference."); + expect(continueBtn.disabled).toBe(false); + expect(statusEvents).toEqual([]); + + // Retry after clearing failure succeeds and closes the dialog. + failMethods.clear(); + await act(async () => { + click(continueBtn); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(statusEvents).toHaveLength(1); + expect(statusEvents[0]?.decision).toBe("accepted"); + + // 3. POST /api/telemetry/notice failure on Escape dismiss still closes without emitting status change. + if (root) { + const current = root; + await act(async () => { + current.unmount(); + }); + root = null; + } + statusResponse = makeStatus(); + statusEvents = []; + failMethods.add("POST /api/telemetry/notice"); + calls = []; + await mountCard(); + const dialog = host.querySelector("dialog.telemetry-consent-overlay")!; + const WindowEvent = (win as unknown as { Event: typeof Event }).Event; + await act(async () => { + dialog.dispatchEvent(new WindowEvent("cancel", { bubbles: false, cancelable: true })); + await new Promise(r => setTimeout(r, 20)); + }); + expect(calls).toEqual([ + { method: "GET", path: "/api/telemetry" }, + { method: "POST", path: "/api/telemetry/notice" }, + ]); + expect(host.querySelector(".telemetry-consent-card")).toBeNull(); + expect(statusEvents).toEqual([]); +}); + +test("App does not enable DesktopStarOnboarding or TelemetryConsentCard on a Remote Link Child when target discovery fails", async () => { + setup(DESKTOP_UA); + win.localStorage.setItem("ocx-desktop-star-onboarding", "seen"); + for (const [name, content] of [ + ["opencodex-runtime-role", "client"], + ["opencodex-session-token", "ocx_session_test"], + ["opencodex-session-csrf", "test-csrf"], + ["opencodex-session-origin", "http://127.0.0.1:10100"], + ["opencodex-session-server-origin", "http://127.0.0.1:10100"], + ] as const) { + const meta = win.document.createElement("meta"); + meta.setAttribute("name", name); + meta.setAttribute("content", content); + win.document.head.appendChild(meta); + } + (globalThis as Record).__APP_VERSION__ = "0.0.0-test"; + failMethods.add("GET /api/machine/status"); + + const [{ createRoot }, { default: App }, { resetApiAuthFetchForTests, installApiAuthFetch, installApiSessionFromHtml }] = await Promise.all([ + import("react-dom/client"), + import("../src/App"), + import("../src/api"), + ]); + resetApiAuthFetchForTests(); + Object.defineProperty(win, "fetch", { configurable: true, value: globalThis.fetch }); + installApiAuthFetch(); + installApiSessionFromHtml( + "shared", + '', + ); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: win.fetch }); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise(r => setTimeout(r, 60)); + }); + resetApiAuthFetchForTests(); + expect(host.querySelector(".star-onboarding")).toBeNull(); + expect(calls.some(c => c.path === "/api/telemetry")).toBe(false); + expect(host.querySelector("dialog.telemetry-consent-overlay")).toBeNull(); +}); diff --git a/gui/tests/telemetry-settings-panel.test.tsx b/gui/tests/telemetry-settings-panel.test.tsx index 592a02c10b4..8b832a7b483 100644 --- a/gui/tests/telemetry-settings-panel.test.tsx +++ b/gui/tests/telemetry-settings-panel.test.tsx @@ -3,10 +3,14 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import TelemetrySettingsPanel from "../src/components/TelemetrySettingsPanel"; +import { TelemetryConsentCard } from "../src/components/telemetry-consent-card"; import { LanguageProvider } from "../src/i18n/provider"; import AdvancedHub from "../src/pages/AdvancedHub"; import { PROXY_SECTION_IDS } from "../src/pages/dashboard-section-ids"; -import type { TelemetryStatusSnapshot } from "../src/telemetry-api"; +import { + TELEMETRY_STATUS_CHANGED_EVENT, + type TelemetryStatusSnapshot, +} from "../src/telemetry-api"; const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previous: Record<(typeof globals)[number], unknown>; @@ -16,6 +20,8 @@ let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let statusResponse: TelemetryStatusSnapshot; let calls: Array<{ method: string; path: string; body?: unknown }>; +let failMethods: Set; +let statusEvents: TelemetryStatusSnapshot[]; function makeStatus(overrides: Partial = {}): TelemetryStatusSnapshot { return { @@ -66,6 +72,8 @@ beforeEach(() => { win.document.body.appendChild(host as never); statusResponse = makeStatus(); calls = []; + failMethods = new Set(); + statusEvents = []; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -73,6 +81,9 @@ beforeEach(() => { const path = new URL(String(input), "http://127.0.0.1").pathname; const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; calls.push(body !== undefined ? { method, path, body } : { method, path }); + if (failMethods.has(`${method} ${path}`)) { + return new Response(JSON.stringify({ error: "unavailable" }), { status: 503 }); + } if (path === "/api/telemetry" && method === "PUT") { const decision = (body as { decision: "accepted" | "declined" }).decision; statusResponse = { @@ -83,6 +94,15 @@ beforeEach(() => { }; return Response.json({ ok: true, ...statusResponse }); } + if (path === "/api/telemetry/notice" && method === "POST") { + statusResponse = { + ...statusResponse, + effective: true, + reason: "default-on", + noticeShownAt: "2026-11-07T00:00:00.000Z", + }; + return Response.json({ ok: true, ...statusResponse }); + } return Response.json(statusResponse); }, }); @@ -201,26 +221,118 @@ test("disables switch in readOnly mode for Remote Link children", async () => { expect(calls).toEqual([{ method: "GET", path: "/api/telemetry" }]); }); -test("What is sent button opens the preview dialog with the exact JSON payload and closes cleanly", async () => { +test("Preview the JSON button opens the preview dialog with autoFocus on Close and restores focus after unmount", async () => { await mountPanel(); expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); const trigger = host.querySelector(".telemetry-preview-trigger")!; expect(trigger).toBeTruthy(); + expect(trigger.textContent).toBe("Preview the JSON"); await act(async () => { click(trigger); }); - const dialog = host.querySelector("dialog.telemetry-preview-overlay"); + const dialog = host.querySelector("dialog.telemetry-preview-overlay"); expect(dialog).toBeTruthy(); + const descId = dialog?.getAttribute("aria-describedby"); + expect(descId).toBeTruthy(); + expect(dialog?.querySelector(`#${descId}`)?.classList.contains("modal-desc")).toBe(true); const pre = dialog?.querySelector("pre.telemetry-preview-json"); expect(pre?.textContent).toBe(JSON.stringify(statusResponse.preview, null, 2)); const closeBtn = dialog?.querySelector(".modal-actions .btn-primary")!; + expect(win.document.activeElement).toBe(closeBtn as unknown as Element); + + let dialogPresentDuringFocusRestore = true; + const origFocus = trigger.focus.bind(trigger); + trigger.focus = () => { + dialogPresentDuringFocusRestore = host.querySelector("dialog.telemetry-preview-overlay") !== null; + origFocus(); + }; await act(async () => { click(closeBtn); }); expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); + expect(dialogPresentDuringFocusRestore).toBe(false); + expect(win.document.activeElement).toBe(trigger as unknown as Element); +}); + +test("never stacks preview dialog over an open TelemetryConsentCard and opens once the consent dialog closes", async () => { + statusResponse = makeStatus({ + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt: null, + noticeShownAt: null, + }); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + + , + ); + }); + await act(async () => { + await new Promise(r => setTimeout(r, 30)); + }); + + expect(host.querySelector("dialog.telemetry-consent-overlay")).toBeTruthy(); + expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); + + const trigger = host.querySelector(".telemetry-preview-trigger")!; + await act(async () => { + click(trigger); + }); + // Preview dialog must defer while the consent modal is open. + expect(host.querySelectorAll("dialog")).toHaveLength(1); + expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeNull(); + + // Dismissing the consent dialog allows the deferred preview dialog to open alone. + const backdrop = host.querySelector("dialog.telemetry-consent-overlay .modal-backdrop-dismiss")!; + await act(async () => { + click(backdrop); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector("dialog.telemetry-consent-overlay")).toBeNull(); + expect(host.querySelector("dialog.telemetry-preview-overlay")).toBeTruthy(); + expect(host.querySelectorAll("dialog")).toHaveLength(1); +}); + +test("surfaces GET and PUT failures with error notices and preserves switch state on write failure", async () => { + win.addEventListener(TELEMETRY_STATUS_CHANGED_EVENT, ((event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) statusEvents.push(detail); + }) as never); + + // 1. GET /api/telemetry failure renders error notice, disables switch, and recovers on Retry. + failMethods.add("GET /api/telemetry"); + await mountPanel(); + expect(toggleSwitch().disabled).toBe(true); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("false"); + expect(host.querySelector(".notice-err")?.textContent).toContain("Could not load telemetry status."); + + failMethods.clear(); + const retryBtn = host.querySelector(".telemetry-settings-row .btn-ghost")!; + await act(async () => { + click(retryBtn); + await new Promise(r => setTimeout(r, 20)); + }); + expect(host.querySelector(".notice-err")).toBeNull(); + expect(toggleSwitch().disabled).toBe(false); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("true"); + + // 2. PUT /api/telemetry failure preserves aria-checked, renders error notice, and emits no status change. + failMethods.add("PUT /api/telemetry"); + await act(async () => { + click(toggleSwitch()); + await new Promise(r => setTimeout(r, 20)); + }); + expect(toggleSwitch().getAttribute("aria-checked")).toBe("true"); + expect(host.querySelector(".notice-err")?.textContent).toContain("Could not save telemetry preference."); + expect(statusEvents).toEqual([]); }); test("PROXY_SECTION_IDS registers proxy-telemetry and AdvancedHub matches telemetry and privacy search queries", async () => { From f16231e0f5ee48508bc2c1ce53af45c161d99810 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 00:24:44 +0900 Subject: [PATCH 08/63] feat(telemetry-collector): add Cloudflare Worker + D1 collector and stats API --- scripts/test-layout/layout-usage.json | 2 + services/telemetry-collector/README.md | 39 + services/telemetry-collector/schema.sql | 55 ++ services/telemetry-collector/src/index.ts | 113 +++ services/telemetry-collector/src/ingest.ts | 236 ++++++ services/telemetry-collector/src/prune.ts | 38 + services/telemetry-collector/src/stats.ts | 342 ++++++++ services/telemetry-collector/src/types.ts | 115 +++ services/telemetry-collector/tsconfig.json | 12 + services/telemetry-collector/wrangler.toml | 13 + tests/fixtures/test-layout-expected.json | 2 +- ...sage-telemetry-collector-typecheck.test.ts | 84 ++ .../usage-telemetry-collector-worker.test.ts | 797 ++++++++++++++++++ 13 files changed, 1847 insertions(+), 1 deletion(-) create mode 100644 services/telemetry-collector/README.md create mode 100644 services/telemetry-collector/schema.sql create mode 100644 services/telemetry-collector/src/index.ts create mode 100644 services/telemetry-collector/src/ingest.ts create mode 100644 services/telemetry-collector/src/prune.ts create mode 100644 services/telemetry-collector/src/stats.ts create mode 100644 services/telemetry-collector/src/types.ts create mode 100644 services/telemetry-collector/tsconfig.json create mode 100644 services/telemetry-collector/wrangler.toml create mode 100644 tests/usage/usage-telemetry-collector-typecheck.test.ts create mode 100644 tests/usage/usage-telemetry-collector-worker.test.ts diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 29b328c527a..659b463b624 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -13,6 +13,8 @@ "turn-state-handles-e2e.test.ts": "server", "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", + "usage-telemetry-collector-worker.test.ts": "usage", + "usage-telemetry-collector-typecheck.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" } diff --git a/services/telemetry-collector/README.md b/services/telemetry-collector/README.md new file mode 100644 index 00000000000..b308e6936ea --- /dev/null +++ b/services/telemetry-collector/README.md @@ -0,0 +1,39 @@ +# OpenCodex Telemetry Collector + +Cloudflare Worker + D1 service backing `https://telemetry.opencodex.me`. + +Deployment is a maintainer step. Nothing in local development or CI deploys this Worker or mutates Cloudflare resources. + +## Endpoints + +- `POST /v1/events` — validates `TelemetryPayloadV1` from `src/telemetry/contract.ts`, enforces a 16 KiB body cap, rate-limits to 30 reports/hour per salted hourly IP hash, deduplicates on `(day, install_day_hash)`, and rolls up daily totals, model/provider buckets, and error counts in a single D1 batch. +- `GET /v1/stats?range=7d|30d|90d|365d` — returns `TelemetryStatsV1` with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and `http://localhost:4321`. +- Scheduled cron (`15 * * * *`) — prunes `daily_installs` older than 400 days and `rate_limits` buckets older than 2 hours. + +## Maintainer deployment steps + +Run from `services/telemetry-collector/`: + +1. Create the D1 database and update `database_id` in `wrangler.toml` (kept as `00000000-0000-0000-0000-000000000000` in the repository): + +```bash +bunx wrangler d1 create opencodex-telemetry +``` + +2. Apply the schema: + +```bash +bunx wrangler d1 execute opencodex-telemetry --remote --file schema.sql +``` + +3. Configure the rate-limit salt secret: + +```bash +bunx wrangler secret put RATE_LIMIT_SALT +``` + +4. Deploy the Worker: + +```bash +bunx wrangler deploy +``` diff --git a/services/telemetry-collector/schema.sql b/services/telemetry-collector/schema.sql new file mode 100644 index 00000000000..969f1c6bf3a --- /dev/null +++ b/services/telemetry-collector/schema.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS daily_installs ( + day TEXT NOT NULL, + install_day_hash TEXT NOT NULL, + version TEXT NOT NULL, + release_channel TEXT NOT NULL, + os TEXT NOT NULL, + arch TEXT NOT NULL, + install_channel TEXT NOT NULL, + new_install INTEGER NOT NULL, + active_minutes INTEGER NOT NULL, + PRIMARY KEY (day, install_day_hash) +); + +CREATE TABLE IF NOT EXISTS daily_totals ( + day TEXT NOT NULL PRIMARY KEY, + reports INTEGER NOT NULL DEFAULT 0, + new_installs INTEGER NOT NULL DEFAULT 0, + active_minutes INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + incomplete INTEGER NOT NULL DEFAULT 0, + aborted INTEGER NOT NULL DEFAULT 0, + input INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_write INTEGER NOT NULL DEFAULT 0, + output INTEGER NOT NULL DEFAULT 0, + reasoning INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS daily_models ( + day TEXT NOT NULL, + provider_kind TEXT NOT NULL, + model_family TEXT NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + input INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_write INTEGER NOT NULL DEFAULT 0, + output INTEGER NOT NULL DEFAULT 0, + reasoning INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, provider_kind, model_family) +); + +CREATE TABLE IF NOT EXISTS daily_errors ( + day TEXT NOT NULL, + cause TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, cause) +); + +CREATE TABLE IF NOT EXISTS rate_limits ( + bucket_hour TEXT NOT NULL, + ip_hash TEXT NOT NULL, + hits INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_hour, ip_hash) +); diff --git a/services/telemetry-collector/src/index.ts b/services/telemetry-collector/src/index.ts new file mode 100644 index 00000000000..8070f87c5f8 --- /dev/null +++ b/services/telemetry-collector/src/index.ts @@ -0,0 +1,113 @@ +import { handleIngest } from "./ingest"; +import { pruneCollectorTables } from "./prune"; +import { handleStats } from "./stats"; +import type { CollectorEnv, ExecutionContext, ScheduledController } from "./types"; + +export const ALLOWED_CORS_ORIGINS = Object.freeze([ + "https://opencodex.me", + "http://localhost:4321", +] as const); + +const ALLOWED_ORIGIN_SET: ReadonlySet = new Set(ALLOWED_CORS_ORIGINS); + +export function resolveCorsHeaders(request: Request): Record { + const origin = request.headers.get("origin")?.trim() ?? ""; + if (!ALLOWED_ORIGIN_SET.has(origin)) { + return { vary: "Origin" }; + } + return { + "access-control-allow-origin": origin, + vary: "Origin", + }; +} + +function withCors(request: Request, response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(resolveCorsHeaders(request))) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function handlePreflight(request: Request, allowedMethods: string): Response { + const headers = new Headers(resolveCorsHeaders(request)); + headers.set("access-control-allow-methods", allowedMethods); + headers.set("access-control-allow-headers", "content-type"); + headers.set("access-control-max-age", "86400"); + return new Response(null, { status: 204, headers }); +} + +function jsonError(status: number, error: string, extraHeaders?: Record): Response { + return new Response(JSON.stringify({ ok: false, error }), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...(extraHeaders ?? {}), + }, + }); +} + +export async function handleCollectorFetch( + request: Request, + env: CollectorEnv, + _ctx?: ExecutionContext, + now: number = Date.now(), +): Promise { + const url = new URL(request.url); + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + const method = request.method.toUpperCase(); + + if (pathname === "/v1/events") { + if (method === "OPTIONS") { + return handlePreflight(request, "POST, OPTIONS"); + } + if (method !== "POST") { + return withCors( + request, + jsonError(405, "method not allowed", { allow: "POST, OPTIONS" }), + ); + } + return withCors(request, await handleIngest(request, env, now)); + } + + if (pathname === "/v1/stats") { + if (method === "OPTIONS") { + return handlePreflight(request, "GET, OPTIONS"); + } + if (method !== "GET") { + return withCors( + request, + jsonError(405, "method not allowed", { allow: "GET, OPTIONS" }), + ); + } + return withCors(request, await handleStats(request, env, now)); + } + + if (method === "OPTIONS") { + return handlePreflight(request, "GET, POST, OPTIONS"); + } + + return withCors(request, jsonError(404, "not found")); +} + +export async function handleCollectorScheduled( + controller: ScheduledController | undefined, + env: CollectorEnv, + ctx?: ExecutionContext, +): Promise { + const now = typeof controller?.scheduledTime === "number" ? controller.scheduledTime : Date.now(); + const task = pruneCollectorTables(env.DB, now); + ctx?.waitUntil(task); + await task; +} + +const worker = { + fetch: handleCollectorFetch, + scheduled: handleCollectorScheduled, +}; + +export default worker; diff --git a/services/telemetry-collector/src/ingest.ts b/services/telemetry-collector/src/ingest.ts new file mode 100644 index 00000000000..831a82786a9 --- /dev/null +++ b/services/telemetry-collector/src/ingest.ts @@ -0,0 +1,236 @@ +import { validateTelemetryPayloadV1 } from "../../../src/telemetry/contract"; +import type { CollectorEnv, D1PreparedStatement } from "./types"; + +export const MAX_INGEST_BODY_BYTES = 16 * 1024; +export const MAX_REPORTS_PER_HOUR = 30; +const HOUR_MS = 3_600_000; + +export function formatBucketHour(now: number): string { + return new Date(now).toISOString().slice(0, 13); +} + +export function extractClientIp(request: Request): string { + const cfIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfIp) return cfIp; + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + return "0.0.0.0"; +} + +export async function computeHourlyIpHash( + salt: string, + ip: string, + bucketHour: string, +): Promise { + const bytes = new TextEncoder().encode(`${salt}${ip}${bucketHour}`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const view = new Uint8Array(digest); + let hex = ""; + for (let i = 0; i < view.length; i++) { + hex += view[i]!.toString(16).padStart(2, "0"); + } + return hex; +} + +function jsonResult(body: Record, status: number, extraHeaders?: Record): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...(extraHeaders ?? {}), + }, + }); +} + +export async function handleIngest( + request: Request, + env: CollectorEnv, + now: number = Date.now(), +): Promise { + const contentLengthHeader = request.headers.get("content-length"); + if (contentLengthHeader !== null) { + const declaredLength = Number(contentLengthHeader); + if (Number.isFinite(declaredLength) && declaredLength > MAX_INGEST_BODY_BYTES) { + return jsonResult({ ok: false, error: "payload exceeds 16 KiB limit" }, 413); + } + } + + const contentType = request.headers.get("content-type") ?? ""; + if (!/^application\/json\b/i.test(contentType.trim())) { + return jsonResult({ ok: false, error: "content-type must be application/json" }, 400); + } + + const rawBytes = new Uint8Array(await request.arrayBuffer()); + if (rawBytes.byteLength > MAX_INGEST_BODY_BYTES) { + return jsonResult({ ok: false, error: "payload exceeds 16 KiB limit" }, 413); + } + if (rawBytes.byteLength === 0) { + return jsonResult({ ok: false, error: "request body is empty" }, 400); + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(rawBytes)); + } catch { + return jsonResult({ ok: false, error: "invalid JSON body" }, 400); + } + + const validated = validateTelemetryPayloadV1(parsed, now); + if (!validated.ok) { + return jsonResult({ ok: false, error: validated.error }, 400); + } + + const bucketHour = formatBucketHour(now); + const ipHash = await computeHourlyIpHash( + env.RATE_LIMIT_SALT ?? "opencodex-telemetry-default-salt", + extractClientIp(request), + bucketHour, + ); + const existingRate = await env.DB + .prepare("SELECT hits FROM rate_limits WHERE bucket_hour = ?1 AND ip_hash = ?2") + .bind(bucketHour, ipHash) + .first<{ hits: number }>(); + const currentHits = Number(existingRate?.hits ?? 0); + if (currentHits >= MAX_REPORTS_PER_HOUR) { + const nextHourMs = (Math.floor(now / HOUR_MS) + 1) * HOUR_MS; + const retryAfterSeconds = Math.max(1, Math.ceil((nextHourMs - now) / 1000)); + return jsonResult( + { ok: false, error: "rate limit exceeded" }, + 429, + { "retry-after": String(retryAfterSeconds) }, + ); + } + + await env.DB + .prepare( + "INSERT INTO rate_limits (bucket_hour, ip_hash, hits) VALUES (?1, ?2, 1) " + + "ON CONFLICT(bucket_hour, ip_hash) DO UPDATE SET hits = hits + 1", + ) + .bind(bucketHour, ipHash) + .run(); + + const payload = validated.payload; + const insertInstallResult = await env.DB + .prepare( + "INSERT OR IGNORE INTO daily_installs " + + "(day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind( + payload.day, + payload.installDayHash, + payload.env.version, + payload.env.releaseChannel, + payload.env.os, + payload.env.arch, + payload.env.installChannel, + payload.newInstall ? 1 : 0, + payload.activeMinutes, + ) + .run(); + + const insertedNewInstallDay = Number(insertInstallResult.meta?.changes ?? 0) > 0; + if (!insertedNewInstallDay) { + return jsonResult({ ok: true, deduplicated: true }, 200); + } + + let totalInput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalOutput = 0; + let totalReasoning = 0; + for (const row of payload.models) { + totalInput += row.inputTokens; + totalCacheRead += row.cacheReadTokens; + totalCacheWrite += row.cacheWriteTokens; + totalOutput += row.outputTokens; + totalReasoning += row.reasoningTokens; + } + + const rollupStatements: D1PreparedStatement[] = [ + env.DB + .prepare( + "INSERT INTO daily_totals (" + + "day, reports, new_installs, active_minutes, " + + "completed, failed, incomplete, aborted, " + + "input, cache_read, cache_write, output, reasoning" + + ") VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) " + + "ON CONFLICT(day) DO UPDATE SET " + + "reports = reports + 1, " + + "new_installs = new_installs + excluded.new_installs, " + + "active_minutes = active_minutes + excluded.active_minutes, " + + "completed = completed + excluded.completed, " + + "failed = failed + excluded.failed, " + + "incomplete = incomplete + excluded.incomplete, " + + "aborted = aborted + excluded.aborted, " + + "input = input + excluded.input, " + + "cache_read = cache_read + excluded.cache_read, " + + "cache_write = cache_write + excluded.cache_write, " + + "output = output + excluded.output, " + + "reasoning = reasoning + excluded.reasoning", + ) + .bind( + payload.day, + payload.newInstall ? 1 : 0, + payload.activeMinutes, + payload.requests.completed, + payload.requests.failed, + payload.requests.incomplete, + payload.requests.aborted, + totalInput, + totalCacheRead, + totalCacheWrite, + totalOutput, + totalReasoning, + ), + ]; + + for (const row of payload.models) { + rollupStatements.push( + env.DB + .prepare( + "INSERT INTO daily_models (" + + "day, provider_kind, model_family, requests, " + + "input, cache_read, cache_write, output, reasoning" + + ") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " + + "ON CONFLICT(day, provider_kind, model_family) DO UPDATE SET " + + "requests = requests + excluded.requests, " + + "input = input + excluded.input, " + + "cache_read = cache_read + excluded.cache_read, " + + "cache_write = cache_write + excluded.cache_write, " + + "output = output + excluded.output, " + + "reasoning = reasoning + excluded.reasoning", + ) + .bind( + payload.day, + row.providerKind, + row.modelFamily, + row.requests, + row.inputTokens, + row.cacheReadTokens, + row.cacheWriteTokens, + row.outputTokens, + row.reasoningTokens, + ), + ); + } + + for (const [cause, count] of Object.entries(payload.errors)) { + if (typeof count === "number" && count > 0) { + rollupStatements.push( + env.DB + .prepare( + "INSERT INTO daily_errors (day, cause, count) VALUES (?1, ?2, ?3) " + + "ON CONFLICT(day, cause) DO UPDATE SET count = count + excluded.count", + ) + .bind(payload.day, cause, count), + ); + } + } + + await env.DB.batch(rollupStatements); + return jsonResult({ ok: true, deduplicated: false }, 200); +} diff --git a/services/telemetry-collector/src/prune.ts b/services/telemetry-collector/src/prune.ts new file mode 100644 index 00000000000..3ad72705096 --- /dev/null +++ b/services/telemetry-collector/src/prune.ts @@ -0,0 +1,38 @@ +import { formatBucketHour } from "./ingest"; +import { formatUtcDay, utcMidnightMs } from "./stats"; +import type { D1Database } from "./types"; + +export const DAILY_INSTALLS_RETENTION_DAYS = 400; +export const RATE_LIMIT_RETENTION_HOURS = 2; + +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; + +export interface PruneResult { + deletedInstalls: number; + deletedRateLimits: number; + installCutoffDay: string; + rateLimitCutoffHour: string; +} + +export async function pruneCollectorTables( + db: D1Database, + now: number = Date.now(), +): Promise { + const installCutoffDay = formatUtcDay( + utcMidnightMs(now) - DAILY_INSTALLS_RETENTION_DAYS * DAY_MS, + ); + const rateLimitCutoffHour = formatBucketHour( + now - RATE_LIMIT_RETENTION_HOURS * HOUR_MS, + ); + const results = await db.batch([ + db.prepare("DELETE FROM daily_installs WHERE day < ?1").bind(installCutoffDay), + db.prepare("DELETE FROM rate_limits WHERE bucket_hour <= ?1").bind(rateLimitCutoffHour), + ]); + return { + deletedInstalls: Number(results[0]?.meta?.changes ?? 0), + deletedRateLimits: Number(results[1]?.meta?.changes ?? 0), + installCutoffDay, + rateLimitCutoffHour, + }; +} diff --git a/services/telemetry-collector/src/stats.ts b/services/telemetry-collector/src/stats.ts new file mode 100644 index 00000000000..7c56b8f08fa --- /dev/null +++ b/services/telemetry-collector/src/stats.ts @@ -0,0 +1,342 @@ +import { TELEMETRY_SCHEMA_VERSION } from "../../../src/telemetry/contract"; +import type { CollectorEnv, TelemetryStatsRange, TelemetryStatsV1 } from "./types"; + +const DAY_MS = 86_400_000; +const HEATMAP_DAYS = 365; + +const RANGE_TO_DAYS: Readonly> = Object.freeze({ + "7d": 7, + "30d": 30, + "90d": 90, + "365d": 365, +}); + +export function utcMidnightMs(now: number): number { + const date = new Date(now); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} + +export function parseStatsRange(raw: string | null): TelemetryStatsRange | null { + if (raw === null || raw === "") return "30d"; + if (raw === "7d" || raw === "30d" || raw === "90d" || raw === "365d") { + return raw; + } + return null; +} + +interface DailyTotalsRow { + day: string; + reports: number; + new_installs: number; + active_minutes: number; + completed: number; + failed: number; + incomplete: number; + aborted: number; + input: number; + cache_read: number; + cache_write: number; + output: number; + reasoning: number; +} + +interface DailyProviderRow { + day: string; + provider_kind: string; + requests: number; + tokens: number; +} + +interface ProviderRow { + provider_kind: string; + requests: number; + tokens: number; +} + +interface ModelRow { + model_family: string; + requests: number; + tokens: number; +} + +interface VersionRow { + version: string; + installs: number; + new_installs: number; +} + +interface PlatformRow { + os: string; + arch: string; + installs: number; +} + +interface ChannelRow { + install_channel: string; + installs: number; + new_installs: number; +} + +interface ErrorRow { + cause: string; + count: number; +} + +export async function buildStatsSnapshot( + env: CollectorEnv, + range: TelemetryStatsRange, + now: number = Date.now(), +): Promise { + const todayMs = utcMidnightMs(now); + const rangeDays = RANGE_TO_DAYS[range]; + const rangeStartDay = formatUtcDay(todayMs - (rangeDays - 1) * DAY_MS); + const rangeEndDay = formatUtcDay(todayMs + DAY_MS); + const heatmapStartDay = formatUtcDay(todayMs - (HEATMAP_DAYS - 1) * DAY_MS); + + const [ + dailyTotalsRes, + dailyByProviderRes, + heatmapRes, + providersRes, + modelsRes, + versionsRes, + platformsRes, + channelsRes, + errorsRes, + ] = await Promise.all([ + env.DB + .prepare( + "SELECT day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, " + + "input, cache_read, cache_write, output, reasoning " + + "FROM daily_totals WHERE day >= ?1 AND day <= ?2 ORDER BY day ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT day, provider_kind, SUM(requests) AS requests, " + + "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + + "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY day, provider_kind ORDER BY day ASC, tokens DESC, requests DESC, provider_kind ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT day, reports FROM daily_totals WHERE day >= ?1 AND day <= ?2 ORDER BY day ASC", + ) + .bind(heatmapStartDay, rangeEndDay) + .all<{ day: string; reports: number }>(), + env.DB + .prepare( + "SELECT provider_kind, SUM(requests) AS requests, " + + "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + + "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY provider_kind ORDER BY tokens DESC, requests DESC, provider_kind ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT model_family, SUM(requests) AS requests, " + + "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + + "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY model_family ORDER BY tokens DESC, requests DESC, model_family ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT version, COUNT(*) AS installs, COALESCE(SUM(new_install), 0) AS new_installs " + + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY version ORDER BY installs DESC, version DESC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT os, arch, COUNT(*) AS installs " + + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY os, arch ORDER BY installs DESC, os ASC, arch ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT install_channel, COUNT(*) AS installs, COALESCE(SUM(new_install), 0) AS new_installs " + + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY install_channel ORDER BY installs DESC, install_channel ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + env.DB + .prepare( + "SELECT cause, SUM(count) AS count " + + "FROM daily_errors WHERE day >= ?1 AND day <= ?2 " + + "GROUP BY cause ORDER BY count DESC, cause ASC", + ) + .bind(rangeStartDay, rangeEndDay) + .all(), + ]); + + let activeInstalls = 0; + let newInstalls = 0; + let activeMinutes = 0; + let completed = 0; + let failed = 0; + let incomplete = 0; + let aborted = 0; + let input = 0; + let cacheRead = 0; + let cacheWrite = 0; + let output = 0; + let reasoning = 0; + + const daily: TelemetryStatsV1["daily"] = []; + for (const row of dailyTotalsRes.results ?? []) { + const rowReports = Number(row.reports); + const rowNewInstalls = Number(row.new_installs); + const rowActiveMinutes = Number(row.active_minutes); + const rowCompleted = Number(row.completed); + const rowFailed = Number(row.failed); + const rowIncomplete = Number(row.incomplete); + const rowAborted = Number(row.aborted); + const rowInput = Number(row.input); + const rowCacheRead = Number(row.cache_read); + const rowCacheWrite = Number(row.cache_write); + const rowOutput = Number(row.output); + const rowReasoning = Number(row.reasoning); + const rowRequests = rowCompleted + rowFailed + rowIncomplete + rowAborted; + const rowTokens = rowInput + rowCacheRead + rowCacheWrite + rowOutput + rowReasoning; + + activeInstalls += rowReports; + newInstalls += rowNewInstalls; + activeMinutes += rowActiveMinutes; + completed += rowCompleted; + failed += rowFailed; + incomplete += rowIncomplete; + aborted += rowAborted; + input += rowInput; + cacheRead += rowCacheRead; + cacheWrite += rowCacheWrite; + output += rowOutput; + reasoning += rowReasoning; + + daily.push({ + day: row.day, + activeInstalls: rowReports, + newInstalls: rowNewInstalls, + activeMinutes: rowActiveMinutes, + requests: rowRequests, + failed: rowFailed, + tokens: rowTokens, + }); + } + + const heatmapByDay = new Map(); + for (const row of heatmapRes.results ?? []) { + heatmapByDay.set(row.day, Number(row.reports)); + } + const heatmap: TelemetryStatsV1["heatmap"] = []; + for (let offset = HEATMAP_DAYS - 1; offset >= 0; offset--) { + const day = formatUtcDay(todayMs - offset * DAY_MS); + heatmap.push({ + day, + activeInstalls: heatmapByDay.get(day) ?? 0, + }); + } + + return { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + generatedAt: new Date(now).toISOString(), + range, + totals: { + activeInstalls, + newInstalls, + activeMinutes, + requests: completed + failed + incomplete + aborted, + tokens: { + input, + cacheRead, + cacheWrite, + output, + reasoning, + }, + }, + daily, + dailyByProvider: (dailyByProviderRes.results ?? []).map(row => ({ + day: row.day, + providerKind: row.provider_kind, + requests: Number(row.requests), + tokens: Number(row.tokens), + })), + heatmap, + providers: (providersRes.results ?? []).map(row => ({ + providerKind: row.provider_kind, + requests: Number(row.requests), + tokens: Number(row.tokens), + })), + models: (modelsRes.results ?? []).map(row => ({ + modelFamily: row.model_family, + requests: Number(row.requests), + tokens: Number(row.tokens), + })), + versions: (versionsRes.results ?? []).map(row => ({ + version: row.version, + installs: Number(row.installs), + newInstalls: Number(row.new_installs), + })), + platforms: (platformsRes.results ?? []).map(row => ({ + os: row.os, + arch: row.arch, + installs: Number(row.installs), + })), + channels: (channelsRes.results ?? []).map(row => ({ + installChannel: row.install_channel, + installs: Number(row.installs), + newInstalls: Number(row.new_installs), + })), + outcomes: { + completed, + failed, + incomplete, + aborted, + }, + errors: (errorsRes.results ?? []).map(row => ({ + cause: row.cause, + count: Number(row.count), + })), + }; +} + +export async function handleStats( + request: Request, + env: CollectorEnv, + now: number = Date.now(), +): Promise { + const url = new URL(request.url); + const range = parseStatsRange(url.searchParams.get("range")); + if (!range) { + return new Response( + JSON.stringify({ ok: false, error: "range must be one of 7d, 30d, 90d, 365d" }), + { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ); + } + const stats = await buildStatsSnapshot(env, range, now); + return new Response(JSON.stringify(stats), { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }); +} diff --git a/services/telemetry-collector/src/types.ts b/services/telemetry-collector/src/types.ts new file mode 100644 index 00000000000..b7d35b97108 --- /dev/null +++ b/services/telemetry-collector/src/types.ts @@ -0,0 +1,115 @@ +export interface D1ResultMeta { + changes: number; + duration?: number; + last_row_id?: number; + changed_db?: boolean; +} + +export interface D1Result> { + success: boolean; + meta: D1ResultMeta; + results?: T[]; +} + +export interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + run>(): Promise>; + all>(): Promise>; + first>(colName?: string): Promise; +} + +export interface D1Database { + prepare(query: string): D1PreparedStatement; + batch>(statements: D1PreparedStatement[]): Promise[]>; +} + +export interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} + +export interface ScheduledController { + scheduledTime: number; + cron?: string; + noRetry?: () => void; +} + +export interface CollectorEnv { + DB: D1Database; + RATE_LIMIT_SALT?: string; +} + +export type TelemetryStatsRange = "7d" | "30d" | "90d" | "365d"; + +export type TelemetryStatsV1 = { + schemaVersion: 1; + generatedAt: string; + range: TelemetryStatsRange; + totals: { + activeInstalls: number; + newInstalls: number; + activeMinutes: number; + requests: number; + tokens: { + input: number; + cacheRead: number; + cacheWrite: number; + output: number; + reasoning: number; + }; + }; + daily: Array<{ + day: string; + activeInstalls: number; + newInstalls: number; + activeMinutes: number; + requests: number; + failed: number; + tokens: number; + }>; + dailyByProvider: Array<{ + day: string; + providerKind: string; + requests: number; + tokens: number; + }>; + heatmap: Array<{ + day: string; + activeInstalls: number; + }>; + providers: Array<{ + providerKind: string; + requests: number; + tokens: number; + }>; + models: Array<{ + modelFamily: string; + requests: number; + tokens: number; + }>; + versions: Array<{ + version: string; + installs: number; + newInstalls: number; + }>; + platforms: Array<{ + os: string; + arch: string; + installs: number; + }>; + channels: Array<{ + installChannel: string; + installs: number; + newInstalls: number; + }>; + outcomes: { + completed: number; + failed: number; + incomplete: number; + aborted: number; + }; + errors: Array<{ + cause: string; + count: number; + }>; +}; diff --git a/services/telemetry-collector/tsconfig.json b/services/telemetry-collector/tsconfig.json new file mode 100644 index 00000000000..5b3f9c6cdc3 --- /dev/null +++ b/services/telemetry-collector/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true, + "declaration": false + }, + "include": [ + "src/**/*.ts", + "../../src/telemetry/contract.ts" + ] +} diff --git a/services/telemetry-collector/wrangler.toml b/services/telemetry-collector/wrangler.toml new file mode 100644 index 00000000000..a821d41fb99 --- /dev/null +++ b/services/telemetry-collector/wrangler.toml @@ -0,0 +1,13 @@ +name = "opencodex-telemetry-collector" +main = "src/index.ts" +compatibility_date = "2026-01-01" +route = "telemetry.opencodex.me/*" + +# Real D1 database_id is configured by a maintainer at deploy time. +[[d1_databases]] +binding = "DB" +database_name = "opencodex-telemetry" +database_id = "00000000-0000-0000-0000-000000000000" + +[triggers] +crons = ["15 * * * *"] diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46504f81156..808cb8d39a3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1623,5 +1623,5 @@ "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", "sse-payload-rewrite-continuation.test.ts": "responses", - "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" + "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" } diff --git a/tests/usage/usage-telemetry-collector-typecheck.test.ts b/tests/usage/usage-telemetry-collector-typecheck.test.ts new file mode 100644 index 00000000000..a999a2f9e84 --- /dev/null +++ b/tests/usage/usage-telemetry-collector-typecheck.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { cpSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +describe("telemetry collector typecheck", () => { + test("services/telemetry-collector typechecks cleanly with the repository toolchain", () => { + const proc = Bun.spawnSync( + [ + process.execPath, + "check", + "--no-script", + "--noEmit", + "-p", + repoPath("services", "telemetry-collector", "tsconfig.json"), + ], + { + cwd: repoRoot(), + stdout: "pipe", + stderr: "pipe", + }, + ); + const output = proc.stdout.toString() + proc.stderr.toString(); + expect({ exitCode: proc.exitCode, output }).toEqual({ + exitCode: 0, + output: expect.any(String), + }); + }); + + test("collector typecheck catches an injected type error in a temporary copy", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ocx-collector-typecheck-")); + try { + mkdirSync(join(tempRoot, "src", "telemetry"), { recursive: true }); + mkdirSync(join(tempRoot, "services", "telemetry-collector"), { recursive: true }); + cpSync(repoPath("tsconfig.json"), join(tempRoot, "tsconfig.json")); + symlinkSync( + repoPath("node_modules"), + join(tempRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + cpSync( + repoPath("src", "telemetry", "contract.ts"), + join(tempRoot, "src", "telemetry", "contract.ts"), + ); + cpSync( + repoPath("services", "telemetry-collector"), + join(tempRoot, "services", "telemetry-collector"), + { recursive: true }, + ); + + // Inject a type error against TelemetryStatsV1 / contract types + writeFileSync( + join(tempRoot, "services", "telemetry-collector", "src", "injected-error.ts"), + [ + 'import type { TelemetryStatsV1 } from "./types";', + 'export const brokenStats: TelemetryStatsV1["range"] = "14d";', + ].join("\n"), + ); + + const proc = Bun.spawnSync( + [ + process.execPath, + "check", + "--no-script", + "--noEmit", + "-p", + join(tempRoot, "services", "telemetry-collector", "tsconfig.json"), + ], + { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + }, + ); + const output = proc.stdout.toString() + proc.stderr.toString(); + expect(proc.exitCode).not.toBe(0); + expect(output).toContain("TS2322"); + } finally { + removeTreeWithRetry(tempRoot); + } + }); +}); diff --git a/tests/usage/usage-telemetry-collector-worker.test.ts b/tests/usage/usage-telemetry-collector-worker.test.ts new file mode 100644 index 00000000000..2d5f16e47a0 --- /dev/null +++ b/tests/usage/usage-telemetry-collector-worker.test.ts @@ -0,0 +1,797 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import worker, { + ALLOWED_CORS_ORIGINS, + handleCollectorFetch, + handleCollectorScheduled, +} from "../../services/telemetry-collector/src/index"; +import { + MAX_INGEST_BODY_BYTES, + MAX_REPORTS_PER_HOUR, + computeHourlyIpHash, + formatBucketHour, +} from "../../services/telemetry-collector/src/ingest"; +import { pruneCollectorTables } from "../../services/telemetry-collector/src/prune"; +import type { + CollectorEnv, + D1Database, + D1PreparedStatement, + D1Result, + TelemetryStatsV1, +} from "../../services/telemetry-collector/src/types"; +import { aggregateTelemetryEntriesForDay, type DailyTelemetryAggregate } from "../../src/telemetry/aggregate"; +import { computeInstallDayHash } from "../../src/telemetry/install-id"; +import { buildTelemetryPayloadV1 } from "../../src/telemetry/payload"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { repoPath } from "../helpers/repo-root"; + +const NOW = Date.parse("2026-11-07T12:30:00.000Z"); +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; + +interface InMemoryD1Harness { + sqlite: Database; + db: D1Database; + env: CollectorEnv; + batchCalls: number[]; + close: () => void; +} + +function createInMemoryD1(salt = "test-rate-limit-salt"): InMemoryD1Harness { + const sqlite = new Database(":memory:"); + const schemaSql = readFileSync( + repoPath("services", "telemetry-collector", "schema.sql"), + "utf8", + ); + sqlite.exec(schemaSql); + const batchCalls: number[] = []; + + function makeStatement(query: string, boundValues: unknown[] = []): D1PreparedStatement { + const executeRun = >(): D1Result => { + const stmt = sqlite.query(query); + const info = stmt.run(...(boundValues as never[])); + return { + success: true, + meta: { + changes: Number(info.changes ?? 0), + last_row_id: Number(info.lastInsertRowid ?? 0), + changed_db: Number(info.changes ?? 0) > 0, + }, + }; + }; + + return { + bind(...values: unknown[]): D1PreparedStatement { + return makeStatement(query, values); + }, + async run>(): Promise> { + return executeRun(); + }, + async all>(): Promise> { + const stmt = sqlite.query(query); + const results = stmt.all(...(boundValues as never[])) as T[]; + return { + success: true, + meta: { changes: 0 }, + results, + }; + }, + async first>(colName?: string): Promise { + const stmt = sqlite.query(query); + const row = stmt.get(...(boundValues as never[])) as Record | null; + if (!row) return null; + if (colName !== undefined) { + return ((row[colName] as T | undefined) ?? null); + } + return row as T; + }, + }; + } + + const db: D1Database = { + prepare(query: string): D1PreparedStatement { + return makeStatement(query); + }, + async batch>(statements: D1PreparedStatement[]): Promise[]> { + batchCalls.push(statements.length); + const out: D1Result[] = []; + sqlite.exec("BEGIN IMMEDIATE"); + try { + for (const stmt of statements) { + out.push(await stmt.run()); + } + sqlite.exec("COMMIT"); + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + return out; + }, + }; + + return { + sqlite, + db, + env: { DB: db, RATE_LIMIT_SALT: salt }, + batchCalls, + close: () => sqlite.close(), + }; +} + +function makeAggregate(day: string, overrides?: Partial): DailyTelemetryAggregate { + const entries: PersistedUsageEntry[] = [ + { + requestId: "req-1", + timestamp: Date.parse(`${day}T09:10:00.000Z`), + provider: "anthropic2-main", + model: "claude-opus-4-6", + status: 200, + terminalStatus: "completed", + durationMs: 1200, + usageStatus: "reported", + usage: { + inputTokens: 1000, + cacheReadInputTokens: 300, + cacheCreationInputTokens: 100, + outputTokens: 250, + reasoningOutputTokens: 50, + totalTokens: 1250, + }, + }, + { + requestId: "req-2", + timestamp: Date.parse(`${day}T09:10:30.000Z`), + provider: "openai-multi-p123456", + model: "gpt-5.4", + status: 429, + terminalStatus: "failed", + failureCause: "rate-limit", + durationMs: 400, + usageStatus: "reported", + usage: { + inputTokens: 200, + outputTokens: 40, + totalTokens: 240, + }, + }, + { + requestId: "req-3", + timestamp: Date.parse(`${day}T14:22:00.000Z`), + provider: "google-antigravity", + model: "gemini-3-pro", + status: 200, + terminalStatus: "incomplete", + failureCause: "empty-output", + durationMs: 800, + usageStatus: "reported", + usage: { + inputTokens: 400, + outputTokens: 60, + totalTokens: 460, + }, + }, + { + requestId: "req-4", + timestamp: Date.parse(`${day}T18:05:00.000Z`), + provider: "deepseek", + model: "deepseek-r1", + status: 499, + closeReason: "client_cancel", + failureCause: "client-cancelled", + durationMs: 300, + usageStatus: "unreported", + }, + ]; + const built = aggregateTelemetryEntriesForDay(day, entries); + return { + ...built, + ...overrides, + }; +} + +function postEventRequest( + body: string | Uint8Array, + options: { + ip?: string; + origin?: string; + contentType?: string; + contentLength?: string; + } = {}, +): Request { + const headers = new Headers(); + if (options.contentType !== "") { + headers.set("content-type", options.contentType ?? "application/json"); + } + if (options.ip) { + headers.set("cf-connecting-ip", options.ip); + } + if (options.origin) { + headers.set("origin", options.origin); + } + if (options.contentLength) { + headers.set("content-length", options.contentLength); + } + return new Request("https://telemetry.opencodex.me/v1/events", { + method: "POST", + headers, + body, + }); +} + +describe("telemetry collector worker + D1", () => { + test("wrangler.toml pins placeholder D1 id 00000000-0000-0000-0000-000000000000 and cron trigger", () => { + const wranglerToml = readFileSync( + repoPath("services", "telemetry-collector", "wrangler.toml"), + "utf8", + ); + expect(wranglerToml).toContain('database_id = "00000000-0000-0000-0000-000000000000"'); + expect(wranglerToml).toContain('binding = "DB"'); + expect(wranglerToml).toContain("telemetry.opencodex.me/*"); + expect(wranglerToml).toContain("[triggers]"); + expect(wranglerToml).toContain("crons ="); + }); + + test("accepts a payload built by buildTelemetryPayloadV1, deduplicates re-sent days with a new batchId, and stores no raw installId or IP", async () => { + const h = createInMemoryD1("unit-salt-secret"); + try { + const rawInstallId = "9f8e7d6c-5b4a-4321-8765-123456789abc"; + const rawIp = "203.0.113.42"; + const day = "2026-11-06"; + const installDayHash = computeInstallDayHash(rawInstallId, day); + const aggregate = makeAggregate(day); + const payload1 = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall: true, + batchId: "11111111-2222-4333-8444-555555555555", + version: "2.41.0", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + now: NOW, + }); + + const res1 = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload1), { + ip: rawIp, + origin: "https://opencodex.me", + }), + h.env, + undefined, + NOW, + ); + expect(res1.status).toBe(200); + expect(res1.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + expect(await res1.json()).toEqual({ ok: true, deduplicated: false }); + expect(h.batchCalls).toEqual([8]); + + // Re-send the same (day, installDayHash) with a brand-new batchId and inflated numbers. + const payload2 = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day, { + activeMinutes: 999, + requests: { completed: 500, failed: 500, incomplete: 500, aborted: 500 }, + }), + installDayHash, + newInstall: false, + batchId: "99999999-8888-4777-8666-555555555555", + version: "2.41.0", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + now: NOW, + }); + const res2 = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload2), { ip: rawIp }), + h.env, + undefined, + NOW, + ); + expect(res2.status).toBe(200); + expect(await res2.json()).toEqual({ ok: true, deduplicated: true }); + // Dedup must NOT execute a second rollup batch. + expect(h.batchCalls).toEqual([8]); + + const installs = h.sqlite.query("SELECT * FROM daily_installs").all() as Array>; + expect(installs).toHaveLength(1); + expect(installs[0]).toMatchObject({ + day, + install_day_hash: installDayHash, + version: "2.41.0", + release_channel: "latest", + os: "darwin", + arch: "arm64", + install_channel: "npm-global", + new_install: 1, + active_minutes: 3, + }); + + const totals = h.sqlite.query("SELECT * FROM daily_totals").all() as Array>; + expect(totals).toEqual([ + { + day, + reports: 1, + new_installs: 1, + active_minutes: 3, + completed: 1, + failed: 1, + incomplete: 1, + aborted: 1, + input: 1200, + cache_read: 300, + cache_write: 100, + output: 300, + reasoning: 50, + }, + ]); + + // Privacy check: dump every row of every table and ensure no raw installId, batchId, or IP appears. + const tables = ["daily_installs", "daily_totals", "daily_models", "daily_errors", "rate_limits"] as const; + for (const table of tables) { + const dump = JSON.stringify(h.sqlite.query(`SELECT * FROM ${table}`).all()); + expect(dump).not.toContain(rawInstallId); + expect(dump).not.toContain(rawIp); + expect(dump).not.toContain(payload1.batchId); + expect(dump).not.toContain(payload2.batchId); + } + + const rateRows = h.sqlite.query("SELECT * FROM rate_limits").all() as Array<{ + bucket_hour: string; + ip_hash: string; + hits: number; + }>; + const expectedBucket = formatBucketHour(NOW); + const expectedIpHash = await computeHourlyIpHash("unit-salt-secret", rawIp, expectedBucket); + expect(rateRows).toEqual([ + { + bucket_hour: expectedBucket, + ip_hash: expectedIpHash, + hits: 2, + }, + ]); + expect(expectedIpHash).toMatch(/^[0-9a-f]{64}$/); + } finally { + h.close(); + } + }); + + test("rejects non-JSON content-type, malformed JSON, unknown keys, and non-roster values with 400", async () => { + const h = createInMemoryD1(); + try { + const day = "2026-11-06"; + const valid = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", day), + newInstall: false, + version: "2.41.0", + os: "linux", + arch: "x64", + installChannel: "standalone", + now: NOW, + }); + + const nonJsonRes = await handleCollectorFetch( + postEventRequest(JSON.stringify(valid), { contentType: "text/plain" }), + h.env, + undefined, + NOW, + ); + expect(nonJsonRes.status).toBe(400); + + const badJsonRes = await handleCollectorFetch( + postEventRequest("{not-valid-json"), + h.env, + undefined, + NOW, + ); + expect(badJsonRes.status).toBe(400); + + const invalidPayloads: unknown[] = [ + { ...valid, extraTopKey: true }, + { ...valid, env: { ...valid.env, extraEnvKey: "nope" } }, + { ...valid, env: { ...valid.env, os: "freebsd" } }, + { ...valid, env: { ...valid.env, arch: "mips" } }, + { ...valid, env: { ...valid.env, installChannel: "brew" } }, + { ...valid, env: { ...valid.env, releaseChannel: "nightly" } }, + { ...valid, env: { ...valid.env, version: "2.41.0+build123" } }, + { ...valid, models: [{ ...valid.models[0]!, providerKind: "custom-secret-provider" }] }, + { ...valid, models: [{ ...valid.models[0]!, modelFamily: "secret-finetune" }] }, + { ...valid, errors: { "free-form-error": 1 } }, + ]; + + for (const bad of invalidPayloads) { + const res = await handleCollectorFetch( + postEventRequest(JSON.stringify(bad)), + h.env, + undefined, + NOW, + ); + expect(res.status).toBe(400); + } + + const installsCount = h.sqlite.query("SELECT COUNT(*) AS n FROM daily_installs").get() as { n: number }; + expect(installsCount.n).toBe(0); + } finally { + h.close(); + } + }); + + test("enforces 16 KiB body cap with 413 and 30/hour IP hash rate limit with 429 + Retry-After", async () => { + const h = createInMemoryD1("rate-salt"); + try { + const day = "2026-11-06"; + const basePayload = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("12121212-3434-4567-89ab-cdef01234567", day), + newInstall: false, + version: "2.41.0", + os: "darwin", + arch: "arm64", + installChannel: "desktop", + now: NOW, + }); + + // 413 via oversized body (> 16 KiB) + const oversizedBody = new Uint8Array(MAX_INGEST_BODY_BYTES + 1).fill(0x20); + const res413Body = await handleCollectorFetch( + postEventRequest(oversizedBody), + h.env, + undefined, + NOW, + ); + expect(res413Body.status).toBe(413); + + // 413 via declared Content-Length header + const res413Header = await handleCollectorFetch( + postEventRequest(JSON.stringify(basePayload), { + contentLength: String(MAX_INGEST_BODY_BYTES + 100), + }), + h.env, + undefined, + NOW, + ); + expect(res413Header.status).toBe(413); + + // Send 30 valid reports from the same IP in the same hour; all 30 succeed. + const clientIp = "198.51.100.77"; + for (let i = 0; i < MAX_REPORTS_PER_HOUR; i++) { + const res = await handleCollectorFetch( + postEventRequest(JSON.stringify(basePayload), { ip: clientIp }), + h.env, + undefined, + NOW, + ); + expect(res.status).toBe(200); + } + + // 31st report in the same hour returns 429 with Retry-After. + const res429 = await handleCollectorFetch( + postEventRequest(JSON.stringify(basePayload), { ip: clientIp }), + h.env, + undefined, + NOW, + ); + expect(res429.status).toBe(429); + const retryAfter = Number(res429.headers.get("retry-after")); + expect(Number.isInteger(retryAfter)).toBe(true); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(3600); + expect(retryAfter).toBe(1800); // NOW is 12:30:00Z -> 1800s until 13:00:00Z + + // A different IP in the same hour is not rate-limited. + const resOtherIp = await handleCollectorFetch( + postEventRequest(JSON.stringify(basePayload), { ip: "198.51.100.78" }), + h.env, + undefined, + NOW, + ); + expect(resOtherIp.status).toBe(200); + + // The original IP succeeds again in the next UTC hour. + const resNextHour = await handleCollectorFetch( + postEventRequest(JSON.stringify(basePayload), { ip: clientIp }), + h.env, + undefined, + NOW + HOUR_MS, + ); + expect(resNextHour.status).toBe(200); + } finally { + h.close(); + } + }); + + test("handles CORS for allowed origins, OPTIONS preflight, and 404/405 responses", async () => { + const h = createInMemoryD1(); + try { + for (const origin of ALLOWED_CORS_ORIGINS) { + const preflight = await worker.fetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d", { + method: "OPTIONS", + headers: { origin }, + }), + h.env, + ); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe(origin); + expect(preflight.headers.get("access-control-allow-methods")).toContain("GET"); + expect(preflight.headers.get("vary")).toBe("Origin"); + } + + const untrusted = await worker.fetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d", { + method: "GET", + headers: { origin: "https://evil.example" }, + }), + h.env, + ); + expect(untrusted.status).toBe(200); + expect(untrusted.headers.get("access-control-allow-origin")).toBeNull(); + + const getEvents = await worker.fetch( + new Request("https://telemetry.opencodex.me/v1/events", { + method: "GET", + headers: { origin: "http://localhost:4321" }, + }), + h.env, + ); + expect(getEvents.status).toBe(405); + expect(getEvents.headers.get("allow")).toBe("POST, OPTIONS"); + expect(getEvents.headers.get("access-control-allow-origin")).toBe("http://localhost:4321"); + + const postStats = await worker.fetch( + new Request("https://telemetry.opencodex.me/v1/stats", { + method: "POST", + headers: { origin: "https://opencodex.me" }, + }), + h.env, + ); + expect(postStats.status).toBe(405); + expect(postStats.headers.get("allow")).toBe("GET, OPTIONS"); + expect(postStats.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + + const notFound = await worker.fetch( + new Request("https://telemetry.opencodex.me/v1/unknown", { + method: "GET", + headers: { origin: "https://opencodex.me" }, + }), + h.env, + ); + expect(notFound.status).toBe(404); + expect(notFound.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + } finally { + h.close(); + } + }); + + test("serves TelemetryStatsV1 across 7d, 30d, 90d, and 365d with every field and a 365-day heatmap", async () => { + const h = createInMemoryD1(); + try { + const ingestAt = async ( + dayOffset: number, + installUuid: string, + options: { + newInstall: boolean; + version: string; + os: "darwin" | "linux" | "win32"; + arch: "arm64" | "x64"; + installChannel: "npm-global" | "desktop" | "standalone"; + }, + ) => { + const eventNow = NOW - (dayOffset - 1) * DAY_MS; + const day = new Date(NOW - dayOffset * DAY_MS).toISOString().slice(0, 10); + const payload = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash(installUuid, day), + newInstall: options.newInstall, + version: options.version, + os: options.os, + arch: options.arch, + installChannel: options.installChannel, + now: eventNow, + }); + const res = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload), { ip: `10.0.0.${dayOffset}` }), + h.env, + undefined, + eventNow, + ); + expect(res.status).toBe(200); + }; + + // Seed reports at 1d ago (in 7d), 20d ago (in 30d), 60d ago (in 90d), and 200d ago (in 365d) + await ingestAt(1, "10000000-0000-4000-8000-000000000001", { + newInstall: true, + version: "2.41.0", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }); + await ingestAt(20, "10000000-0000-4000-8000-000000000002", { + newInstall: false, + version: "2.40.0", + os: "linux", + arch: "x64", + installChannel: "standalone", + }); + await ingestAt(60, "10000000-0000-4000-8000-000000000003", { + newInstall: true, + version: "2.39.0", + os: "win32", + arch: "x64", + installChannel: "desktop", + }); + await ingestAt(200, "10000000-0000-4000-8000-000000000004", { + newInstall: false, + version: "2.35.0", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }); + + const expectedReportsByRange: Record<"7d" | "30d" | "90d" | "365d", number> = { + "7d": 1, + "30d": 2, + "90d": 3, + "365d": 4, + }; + + for (const [range, expectedReports] of Object.entries(expectedReportsByRange) as Array< + ["7d" | "30d" | "90d" | "365d", number] + >) { + const res = await handleCollectorFetch( + new Request(`https://telemetry.opencodex.me/v1/stats?range=${range}`, { + method: "GET", + headers: { origin: "https://opencodex.me" }, + }), + h.env, + undefined, + NOW, + ); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("public, max-age=300"); + expect(res.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + + const stats = (await res.json()) as TelemetryStatsV1; + expect(stats.schemaVersion).toBe(1); + expect(stats.range).toBe(range); + expect(stats.generatedAt).toBe(new Date(NOW).toISOString()); + expect(stats.totals).toEqual({ + activeInstalls: expectedReports, + newInstalls: range === "7d" || range === "30d" ? 1 : 2, + activeMinutes: expectedReports * 3, + requests: expectedReports * 4, + tokens: { + input: expectedReports * 1200, + cacheRead: expectedReports * 300, + cacheWrite: expectedReports * 100, + output: expectedReports * 300, + reasoning: expectedReports * 50, + }, + }); + expect(stats.daily).toHaveLength(expectedReports); + expect(stats.daily[0]).toMatchObject({ + activeInstalls: 1, + activeMinutes: 3, + requests: 4, + failed: 1, + tokens: 1950, + }); + expect(stats.dailyByProvider.length).toBe(expectedReports * 4); + expect(stats.heatmap).toHaveLength(365); + expect(stats.heatmap[0]?.day).toBe("2025-11-08"); + expect(stats.heatmap[364]?.day).toBe("2026-11-07"); + const nonZeroHeatmapDays = stats.heatmap.filter(d => d.activeInstalls > 0); + expect(nonZeroHeatmapDays).toHaveLength(4); + + expect(stats.providers).toEqual([ + { providerKind: "claude-subscription", requests: expectedReports, tokens: expectedReports * 1250 }, + { providerKind: "google", requests: expectedReports, tokens: expectedReports * 460 }, + { providerKind: "chatgpt", requests: expectedReports, tokens: expectedReports * 240 }, + { providerKind: "deepseek", requests: expectedReports, tokens: 0 }, + ]); + expect(stats.models).toEqual([ + { modelFamily: "claude-opus", requests: expectedReports, tokens: expectedReports * 1250 }, + { modelFamily: "gemini-3", requests: expectedReports, tokens: expectedReports * 460 }, + { modelFamily: "gpt-5", requests: expectedReports, tokens: expectedReports * 240 }, + { modelFamily: "deepseek", requests: expectedReports, tokens: 0 }, + ]); + expect(stats.versions.reduce((sum, row) => sum + row.installs, 0)).toBe(expectedReports); + expect(stats.platforms.reduce((sum, row) => sum + row.installs, 0)).toBe(expectedReports); + expect(stats.channels.reduce((sum, row) => sum + row.installs, 0)).toBe(expectedReports); + expect(stats.outcomes).toEqual({ + completed: expectedReports, + failed: expectedReports, + incomplete: expectedReports, + aborted: expectedReports, + }); + expect(stats.errors).toEqual([ + { cause: "client-cancelled", count: expectedReports }, + { cause: "empty-output", count: expectedReports }, + { cause: "rate-limit", count: expectedReports }, + ]); + } + + // Invalid range returns 400 + const badRange = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=14d"), + h.env, + undefined, + NOW, + ); + expect(badRange.status).toBe(400); + } finally { + h.close(); + } + }); + + test("scheduled pruning removes daily_installs older than 400 days and rate_limits older than 2 hours", async () => { + const h = createInMemoryD1(); + try { + const oldDay = new Date(NOW - 401 * DAY_MS).toISOString().slice(0, 10); + const recentDay = new Date(NOW - 399 * DAY_MS).toISOString().slice(0, 10); + + await h.db + .prepare( + "INSERT INTO daily_installs (day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + + "VALUES (?1, ?2, '2.0.0', 'latest', 'darwin', 'arm64', 'npm-global', 0, 10)", + ) + .bind(oldDay, "a".repeat(64)) + .run(); + await h.db + .prepare( + "INSERT INTO daily_installs (day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + + "VALUES (?1, ?2, '2.1.0', 'latest', 'darwin', 'arm64', 'npm-global', 0, 15)", + ) + .bind(recentDay, "b".repeat(64)) + .run(); + + const oldHour = formatBucketHour(NOW - 3 * HOUR_MS); + const twoHoursAgo = formatBucketHour(NOW - 2 * HOUR_MS); + const oneHourAgo = formatBucketHour(NOW - 1 * HOUR_MS); + const currentHour = formatBucketHour(NOW); + + for (const bucket of [oldHour, twoHoursAgo, oneHourAgo, currentHour]) { + await h.db + .prepare("INSERT INTO rate_limits (bucket_hour, ip_hash, hits) VALUES (?1, ?2, 5)") + .bind(bucket, "c".repeat(64)) + .run(); + } + + const pruneRes = await pruneCollectorTables(h.db, NOW); + expect(pruneRes.deletedInstalls).toBe(1); + expect(pruneRes.deletedRateLimits).toBe(2); + + // Verify scheduled handler also runs cleanly + let waited = false; + await handleCollectorScheduled( + { scheduledTime: NOW, cron: "15 * * * *" }, + h.env, + { + waitUntil(promise) { + waited = true; + void promise; + }, + passThroughOnException() {}, + }, + ); + expect(waited).toBe(true); + + const remainingInstalls = h.sqlite + .query("SELECT day FROM daily_installs ORDER BY day ASC") + .all() as Array<{ day: string }>; + expect(remainingInstalls).toEqual([{ day: recentDay }]); + + const remainingRates = h.sqlite + .query("SELECT bucket_hour FROM rate_limits ORDER BY bucket_hour ASC") + .all() as Array<{ bucket_hour: string }>; + expect(remainingRates).toEqual([ + { bucket_hour: oneHourAgo }, + { bucket_hour: currentHour }, + ]); + } finally { + h.close(); + } + }); +}); From 03cddb67679accbd0faff2c7b26a6393ec888e48 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 00:48:48 +0900 Subject: [PATCH 09/63] fix(telemetry-collector): enforce streaming bounds, atomic D1 rollups, mandatory salt, and stats caching --- .../030_collector.md | 8 +- services/telemetry-collector/README.md | 6 +- services/telemetry-collector/src/index.ts | 58 +- services/telemetry-collector/src/ingest.ts | 275 +++++++--- services/telemetry-collector/src/stats.ts | 343 ++++++++---- services/telemetry-collector/src/types.ts | 2 +- services/telemetry-collector/tsconfig.json | 2 + ...sage-telemetry-collector-typecheck.test.ts | 49 ++ .../usage-telemetry-collector-worker.test.ts | 503 +++++++++++++++++- 9 files changed, 1007 insertions(+), 239 deletions(-) diff --git a/devlog/_plan/261107_telemetry_dashboard/030_collector.md b/devlog/_plan/261107_telemetry_dashboard/030_collector.md index a8a02208b02..cbb0b51afd7 100644 --- a/devlog/_plan/261107_telemetry_dashboard/030_collector.md +++ b/devlog/_plan/261107_telemetry_dashboard/030_collector.md @@ -31,17 +31,13 @@ chosen and proven in B, since `node_modules/.bin/tsc` is absent and `bun check` - `daily_errors(day, cause, count)`. - `rate_limits(bucket_hour, ip_hash, hits)` — pruned after 2 h. -**Dedup:** ingest first `INSERT OR IGNORE` into `daily_installs`; only when that inserted a new -`(day, install_day_hash)` row are the rollups incremented (one D1 `batch`). A re-sent day — new -`batchId` or not — changes nothing, so lost client state cannot inflate totals. Raw install ids and IPs -are never stored; `ip_hash = sha256(RATE_LIMIT_SALT + ip + hour)`. +**Dedup:** ingest executes conditional rollup statements (`WHERE NOT EXISTS (SELECT 1 FROM daily_installs WHERE day = ? AND install_day_hash = ?)`) followed by `INSERT OR IGNORE INTO daily_installs` as the final statement of a single atomic D1 `batch`. A re-sent day — new `batchId` or not — changes nothing, and a failed batch rolls back completely so retries succeed. Raw install ids and IPs are never stored; `RATE_LIMIT_SALT` is mandatory (503 when missing) and `ip_hash = sha256(RATE_LIMIT_SALT + "\0" + hour + "\0" + ip)`. ## Validation and limits `validateTelemetryPayloadV1` from the contract (closed keys at every level, closed rosters for provider kind, model family, channels, os, arch, failure causes; calendar-checked `day` in -[today − 7, today + 1]; strict version regex; bounded integers; ≤ 64 model rows). Body ≤ 16 KiB -(413), JSON only (400), 30 reports/hour per IP hash (429 with `Retry-After`). +[today − 7, today + 1]; strict version regex ≤ 32 chars; bounded per-row and per-report integers clamped to `Number.MAX_SAFE_INTEGER`; ≤ 64 model rows). Streaming body ≤ 16 KiB (413), JSON only (400), atomic 30 reports/hour per IP hash (429 with `Retry-After`), 503 if `RATE_LIMIT_SALT` is unset. ## Stats API (`TelemetryStatsV1`, consumed by 050) diff --git a/services/telemetry-collector/README.md b/services/telemetry-collector/README.md index b308e6936ea..44f55d80c33 100644 --- a/services/telemetry-collector/README.md +++ b/services/telemetry-collector/README.md @@ -6,8 +6,8 @@ Deployment is a maintainer step. Nothing in local development or CI deploys this ## Endpoints -- `POST /v1/events` — validates `TelemetryPayloadV1` from `src/telemetry/contract.ts`, enforces a 16 KiB body cap, rate-limits to 30 reports/hour per salted hourly IP hash, deduplicates on `(day, install_day_hash)`, and rolls up daily totals, model/provider buckets, and error counts in a single D1 batch. -- `GET /v1/stats?range=7d|30d|90d|365d` — returns `TelemetryStatsV1` with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and `http://localhost:4321`. +- `POST /v1/events` — validates `TelemetryPayloadV1` from `src/telemetry/contract.ts`, enforces a 16 KiB streaming body cap and per-report aggregate ceilings, requires `RATE_LIMIT_SALT` (returns HTTP `503` if unset), atomically rate-limits to 30 reports/hour per salted hourly IP hash, and deduplicates on `(day, install_day_hash)` while rolling up daily totals, model/provider buckets, and error counts atomically in a single conditional D1 batch. +- `GET /v1/stats?range=7d|30d|90d|365d` — queries D1 in a single batch and returns `TelemetryStatsV1` cached by canonical range for 300 seconds (`caches.default` plus an in-isolate TTL cache) with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and `http://localhost:4321`. - Scheduled cron (`15 * * * *`) — prunes `daily_installs` older than 400 days and `rate_limits` buckets older than 2 hours. ## Maintainer deployment steps @@ -26,7 +26,7 @@ bunx wrangler d1 create opencodex-telemetry bunx wrangler d1 execute opencodex-telemetry --remote --file schema.sql ``` -3. Configure the rate-limit salt secret: +3. Configure the mandatory rate-limit salt secret (`POST /v1/events` fails closed with `503` when `RATE_LIMIT_SALT` is missing): ```bash bunx wrangler secret put RATE_LIMIT_SALT diff --git a/services/telemetry-collector/src/index.ts b/services/telemetry-collector/src/index.ts index 8070f87c5f8..f7d9a480124 100644 --- a/services/telemetry-collector/src/index.ts +++ b/services/telemetry-collector/src/index.ts @@ -57,41 +57,45 @@ export async function handleCollectorFetch( _ctx?: ExecutionContext, now: number = Date.now(), ): Promise { - const url = new URL(request.url); - const pathname = url.pathname.replace(/\/+$/, "") || "/"; - const method = request.method.toUpperCase(); + try { + const url = new URL(request.url); + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + const method = request.method.toUpperCase(); - if (pathname === "/v1/events") { - if (method === "OPTIONS") { - return handlePreflight(request, "POST, OPTIONS"); + if (pathname === "/v1/events") { + if (method === "OPTIONS") { + return handlePreflight(request, "POST, OPTIONS"); + } + if (method !== "POST") { + return withCors( + request, + jsonError(405, "method not allowed", { allow: "POST, OPTIONS" }), + ); + } + return withCors(request, await handleIngest(request, env, now)); } - if (method !== "POST") { - return withCors( - request, - jsonError(405, "method not allowed", { allow: "POST, OPTIONS" }), - ); + + if (pathname === "/v1/stats") { + if (method === "OPTIONS") { + return handlePreflight(request, "GET, OPTIONS"); + } + if (method !== "GET") { + return withCors( + request, + jsonError(405, "method not allowed", { allow: "GET, OPTIONS" }), + ); + } + return withCors(request, await handleStats(request, env, now)); } - return withCors(request, await handleIngest(request, env, now)); - } - if (pathname === "/v1/stats") { if (method === "OPTIONS") { - return handlePreflight(request, "GET, OPTIONS"); + return handlePreflight(request, "GET, POST, OPTIONS"); } - if (method !== "GET") { - return withCors( - request, - jsonError(405, "method not allowed", { allow: "GET, OPTIONS" }), - ); - } - return withCors(request, await handleStats(request, env, now)); - } - if (method === "OPTIONS") { - return handlePreflight(request, "GET, POST, OPTIONS"); + return withCors(request, jsonError(404, "not found")); + } catch { + return withCors(request, jsonError(500, "internal error")); } - - return withCors(request, jsonError(404, "not found")); } export async function handleCollectorScheduled( diff --git a/services/telemetry-collector/src/ingest.ts b/services/telemetry-collector/src/ingest.ts index 831a82786a9..858cf4cde61 100644 --- a/services/telemetry-collector/src/ingest.ts +++ b/services/telemetry-collector/src/ingest.ts @@ -3,6 +3,10 @@ import type { CollectorEnv, D1PreparedStatement } from "./types"; export const MAX_INGEST_BODY_BYTES = 16 * 1024; export const MAX_REPORTS_PER_HOUR = 30; +export const MAX_VERSION_LENGTH = 32; +export const MAX_REPORT_REQUEST_COUNT = 10_000_000; +export const MAX_REPORT_TOKEN_COUNT = 1_000_000_000_000; +const MAX_SAFE_SQL_INT = Number.MAX_SAFE_INTEGER; const HOUR_MS = 3_600_000; export function formatBucketHour(now: number): string { @@ -25,7 +29,7 @@ export async function computeHourlyIpHash( ip: string, bucketHour: string, ): Promise { - const bytes = new TextEncoder().encode(`${salt}${ip}${bucketHour}`); + const bytes = new TextEncoder().encode(`${salt}\0${bucketHour}\0${ip}`); const digest = await crypto.subtle.digest("SHA-256", bytes); const view = new Uint8Array(digest); let hex = ""; @@ -45,17 +49,71 @@ function jsonResult(body: Record, status: number, extraHeaders? }); } +function clampedAddSql(column: string, deltaExpr: string): string { + return `CASE WHEN ${column} >= ${MAX_SAFE_SQL_INT} - (${deltaExpr}) THEN ${MAX_SAFE_SQL_INT} ELSE ${column} + (${deltaExpr}) END`; +} + +async function readBoundedBody( + request: Request, + maxBytes: number, +): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 400 | 413; error: string }> { + const contentLengthHeader = request.headers.get("content-length"); + if (contentLengthHeader !== null) { + const trimmed = contentLengthHeader.trim(); + if (!/^\d+$/.test(trimmed)) { + return { ok: false, status: 400, error: "invalid content-length header" }; + } + const declaredLength = Number(trimmed); + if (!Number.isSafeInteger(declaredLength) || declaredLength > maxBytes) { + return { ok: false, status: 413, error: "payload exceeds 16 KiB limit" }; + } + } + + if (!request.body) { + return { ok: false, status: 400, error: "request body is empty" }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value && value.byteLength > 0) { + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel(); + } catch { + // Ignore stream cancellation errors once the size limit is exceeded. + } + return { ok: false, status: 413, error: "payload exceeds 16 KiB limit" }; + } + chunks.push(value); + } + } + + if (totalBytes === 0) { + return { ok: false, status: 400, error: "request body is empty" }; + } + + const rawBytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + rawBytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { ok: true, bytes: rawBytes }; +} + export async function handleIngest( request: Request, env: CollectorEnv, now: number = Date.now(), ): Promise { - const contentLengthHeader = request.headers.get("content-length"); - if (contentLengthHeader !== null) { - const declaredLength = Number(contentLengthHeader); - if (Number.isFinite(declaredLength) && declaredLength > MAX_INGEST_BODY_BYTES) { - return jsonResult({ ok: false, error: "payload exceeds 16 KiB limit" }, 413); - } + const rateLimitSalt = typeof env.RATE_LIMIT_SALT === "string" ? env.RATE_LIMIT_SALT.trim() : ""; + if (rateLimitSalt.length === 0) { + return jsonResult({ ok: false, error: "collector rate-limit salt is not configured" }, 503); } const contentType = request.headers.get("content-type") ?? ""; @@ -63,13 +121,11 @@ export async function handleIngest( return jsonResult({ ok: false, error: "content-type must be application/json" }, 400); } - const rawBytes = new Uint8Array(await request.arrayBuffer()); - if (rawBytes.byteLength > MAX_INGEST_BODY_BYTES) { - return jsonResult({ ok: false, error: "payload exceeds 16 KiB limit" }, 413); - } - if (rawBytes.byteLength === 0) { - return jsonResult({ ok: false, error: "request body is empty" }, 400); + const bodyResult = await readBoundedBody(request, MAX_INGEST_BODY_BYTES); + if (!bodyResult.ok) { + return jsonResult({ ok: false, error: bodyResult.error }, bodyResult.status); } + const rawBytes = bodyResult.bytes; let parsed: unknown; try { @@ -83,72 +139,87 @@ export async function handleIngest( return jsonResult({ ok: false, error: validated.error }, 400); } - const bucketHour = formatBucketHour(now); - const ipHash = await computeHourlyIpHash( - env.RATE_LIMIT_SALT ?? "opencodex-telemetry-default-salt", - extractClientIp(request), - bucketHour, - ); - const existingRate = await env.DB - .prepare("SELECT hits FROM rate_limits WHERE bucket_hour = ?1 AND ip_hash = ?2") - .bind(bucketHour, ipHash) - .first<{ hits: number }>(); - const currentHits = Number(existingRate?.hits ?? 0); - if (currentHits >= MAX_REPORTS_PER_HOUR) { - const nextHourMs = (Math.floor(now / HOUR_MS) + 1) * HOUR_MS; - const retryAfterSeconds = Math.max(1, Math.ceil((nextHourMs - now) / 1000)); - return jsonResult( - { ok: false, error: "rate limit exceeded" }, - 429, - { "retry-after": String(retryAfterSeconds) }, - ); - } - - await env.DB - .prepare( - "INSERT INTO rate_limits (bucket_hour, ip_hash, hits) VALUES (?1, ?2, 1) " + - "ON CONFLICT(bucket_hour, ip_hash) DO UPDATE SET hits = hits + 1", - ) - .bind(bucketHour, ipHash) - .run(); - const payload = validated.payload; - const insertInstallResult = await env.DB - .prepare( - "INSERT OR IGNORE INTO daily_installs " + - "(day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - ) - .bind( - payload.day, - payload.installDayHash, - payload.env.version, - payload.env.releaseChannel, - payload.env.os, - payload.env.arch, - payload.env.installChannel, - payload.newInstall ? 1 : 0, - payload.activeMinutes, - ) - .run(); + if (payload.env.version.length > MAX_VERSION_LENGTH) { + return jsonResult({ ok: false, error: "env.version exceeds maximum length of 32 characters" }, 400); + } - const insertedNewInstallDay = Number(insertInstallResult.meta?.changes ?? 0) > 0; - if (!insertedNewInstallDay) { - return jsonResult({ ok: true, deduplicated: true }, 200); + const totalOutcomeRequests = + payload.requests.completed + + payload.requests.failed + + payload.requests.incomplete + + payload.requests.aborted; + if (totalOutcomeRequests > MAX_REPORT_REQUEST_COUNT) { + return jsonResult({ ok: false, error: "total requests exceed per-report limit" }, 400); } + let totalModelRequests = 0; let totalInput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalOutput = 0; let totalReasoning = 0; for (const row of payload.models) { + const rowTokens = + row.inputTokens + + row.cacheReadTokens + + row.cacheWriteTokens + + row.outputTokens + + row.reasoningTokens; + if (row.requests === 0 && rowTokens > 0) { + return jsonResult({ ok: false, error: "model row with zero requests cannot report tokens" }, 400); + } + if (rowTokens > MAX_REPORT_TOKEN_COUNT) { + return jsonResult({ ok: false, error: "model row tokens exceed per-report limit" }, 400); + } + totalModelRequests += row.requests; totalInput += row.inputTokens; totalCacheRead += row.cacheReadTokens; totalCacheWrite += row.cacheWriteTokens; totalOutput += row.outputTokens; totalReasoning += row.reasoningTokens; } + const totalReportTokens = + totalInput + totalCacheRead + totalCacheWrite + totalOutput + totalReasoning; + if (totalModelRequests > MAX_REPORT_REQUEST_COUNT) { + return jsonResult({ ok: false, error: "total model requests exceed per-report limit" }, 400); + } + if (totalReportTokens > MAX_REPORT_TOKEN_COUNT) { + return jsonResult({ ok: false, error: "total report tokens exceed per-report limit" }, 400); + } + + let totalErrorCount = 0; + for (const count of Object.values(payload.errors)) { + if (typeof count === "number" && count > 0) { + totalErrorCount += count; + } + } + if (totalErrorCount > MAX_REPORT_REQUEST_COUNT) { + return jsonResult({ ok: false, error: "total error count exceeds per-report limit" }, 400); + } + + const bucketHour = formatBucketHour(now); + const ipHash = await computeHourlyIpHash( + rateLimitSalt, + extractClientIp(request), + bucketHour, + ); + const rateResult = await env.DB + .prepare( + "INSERT INTO rate_limits (bucket_hour, ip_hash, hits) VALUES (?1, ?2, 1) " + + "ON CONFLICT(bucket_hour, ip_hash) DO UPDATE SET hits = hits + 1 WHERE rate_limits.hits < ?3", + ) + .bind(bucketHour, ipHash, MAX_REPORTS_PER_HOUR) + .run(); + if (Number(rateResult.meta?.changes ?? 0) === 0) { + const nextHourMs = (Math.floor(now / HOUR_MS) + 1) * HOUR_MS; + const retryAfterSeconds = Math.max(1, Math.ceil((nextHourMs - now) / 1000)); + return jsonResult( + { ok: false, error: "rate limit exceeded" }, + 429, + { "retry-after": String(retryAfterSeconds) }, + ); + } const rollupStatements: D1PreparedStatement[] = [ env.DB @@ -157,20 +228,21 @@ export async function handleIngest( "day, reports, new_installs, active_minutes, " + "completed, failed, incomplete, aborted, " + "input, cache_read, cache_write, output, reasoning" + - ") VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) " + + ") SELECT ?1, 1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 " + + "WHERE NOT EXISTS (SELECT 1 FROM daily_installs WHERE day = ?1 AND install_day_hash = ?13) " + "ON CONFLICT(day) DO UPDATE SET " + - "reports = reports + 1, " + - "new_installs = new_installs + excluded.new_installs, " + - "active_minutes = active_minutes + excluded.active_minutes, " + - "completed = completed + excluded.completed, " + - "failed = failed + excluded.failed, " + - "incomplete = incomplete + excluded.incomplete, " + - "aborted = aborted + excluded.aborted, " + - "input = input + excluded.input, " + - "cache_read = cache_read + excluded.cache_read, " + - "cache_write = cache_write + excluded.cache_write, " + - "output = output + excluded.output, " + - "reasoning = reasoning + excluded.reasoning", + `reports = ${clampedAddSql("reports", "1")}, ` + + `new_installs = ${clampedAddSql("new_installs", "excluded.new_installs")}, ` + + `active_minutes = ${clampedAddSql("active_minutes", "excluded.active_minutes")}, ` + + `completed = ${clampedAddSql("completed", "excluded.completed")}, ` + + `failed = ${clampedAddSql("failed", "excluded.failed")}, ` + + `incomplete = ${clampedAddSql("incomplete", "excluded.incomplete")}, ` + + `aborted = ${clampedAddSql("aborted", "excluded.aborted")}, ` + + `input = ${clampedAddSql("input", "excluded.input")}, ` + + `cache_read = ${clampedAddSql("cache_read", "excluded.cache_read")}, ` + + `cache_write = ${clampedAddSql("cache_write", "excluded.cache_write")}, ` + + `output = ${clampedAddSql("output", "excluded.output")}, ` + + `reasoning = ${clampedAddSql("reasoning", "excluded.reasoning")}`, ) .bind( payload.day, @@ -185,6 +257,7 @@ export async function handleIngest( totalCacheWrite, totalOutput, totalReasoning, + payload.installDayHash, ), ]; @@ -195,14 +268,15 @@ export async function handleIngest( "INSERT INTO daily_models (" + "day, provider_kind, model_family, requests, " + "input, cache_read, cache_write, output, reasoning" + - ") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " + + ") SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9 " + + "WHERE NOT EXISTS (SELECT 1 FROM daily_installs WHERE day = ?1 AND install_day_hash = ?10) " + "ON CONFLICT(day, provider_kind, model_family) DO UPDATE SET " + - "requests = requests + excluded.requests, " + - "input = input + excluded.input, " + - "cache_read = cache_read + excluded.cache_read, " + - "cache_write = cache_write + excluded.cache_write, " + - "output = output + excluded.output, " + - "reasoning = reasoning + excluded.reasoning", + `requests = ${clampedAddSql("requests", "excluded.requests")}, ` + + `input = ${clampedAddSql("input", "excluded.input")}, ` + + `cache_read = ${clampedAddSql("cache_read", "excluded.cache_read")}, ` + + `cache_write = ${clampedAddSql("cache_write", "excluded.cache_write")}, ` + + `output = ${clampedAddSql("output", "excluded.output")}, ` + + `reasoning = ${clampedAddSql("reasoning", "excluded.reasoning")}`, ) .bind( payload.day, @@ -214,6 +288,7 @@ export async function handleIngest( row.cacheWriteTokens, row.outputTokens, row.reasoningTokens, + payload.installDayHash, ), ); } @@ -223,14 +298,38 @@ export async function handleIngest( rollupStatements.push( env.DB .prepare( - "INSERT INTO daily_errors (day, cause, count) VALUES (?1, ?2, ?3) " + - "ON CONFLICT(day, cause) DO UPDATE SET count = count + excluded.count", + "INSERT INTO daily_errors (day, cause, count) " + + "SELECT ?1, ?2, ?3 " + + "WHERE NOT EXISTS (SELECT 1 FROM daily_installs WHERE day = ?1 AND install_day_hash = ?4) " + + `ON CONFLICT(day, cause) DO UPDATE SET count = ${clampedAddSql("count", "excluded.count")}`, ) - .bind(payload.day, cause, count), + .bind(payload.day, cause, count, payload.installDayHash), ); } } - await env.DB.batch(rollupStatements); - return jsonResult({ ok: true, deduplicated: false }, 200); + rollupStatements.push( + env.DB + .prepare( + "INSERT OR IGNORE INTO daily_installs " + + "(day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind( + payload.day, + payload.installDayHash, + payload.env.version, + payload.env.releaseChannel, + payload.env.os, + payload.env.arch, + payload.env.installChannel, + payload.newInstall ? 1 : 0, + payload.activeMinutes, + ), + ); + + const batchResults = await env.DB.batch(rollupStatements); + const installInsertResult = batchResults[batchResults.length - 1]; + const insertedNewInstallDay = Number(installInsertResult?.meta?.changes ?? 0) > 0; + return jsonResult({ ok: true, deduplicated: !insertedNewInstallDay }, 200); } diff --git a/services/telemetry-collector/src/stats.ts b/services/telemetry-collector/src/stats.ts index 7c56b8f08fa..4d3b14162e2 100644 --- a/services/telemetry-collector/src/stats.ts +++ b/services/telemetry-collector/src/stats.ts @@ -1,8 +1,21 @@ import { TELEMETRY_SCHEMA_VERSION } from "../../../src/telemetry/contract"; -import type { CollectorEnv, TelemetryStatsRange, TelemetryStatsV1 } from "./types"; +import type { CollectorEnv, D1Database, TelemetryStatsRange, TelemetryStatsV1 } from "./types"; const DAY_MS = 86_400_000; const HEATMAP_DAYS = 365; +export const STATS_CACHE_TTL_MS = 300_000; +export const MAX_STATS_VERSIONS = 50; +const MAX_SAFE_SQL_INT = Number.MAX_SAFE_INTEGER; + +const SQL_CLAMPED_REQUESTS_SUM = + "MIN(9007199254740991, SUM(MIN(9007199254740991, MAX(0, requests))))"; +const SQL_CLAMPED_TOKENS_SUM = + "MIN(9007199254740991, SUM(MIN(9007199254740991, " + + "MIN(9007199254740991, MAX(0, input)) + " + + "MIN(9007199254740991, MAX(0, cache_read)) + " + + "MIN(9007199254740991, MAX(0, cache_write)) + " + + "MIN(9007199254740991, MAX(0, output)) + " + + "MIN(9007199254740991, MAX(0, reasoning)))))"; const RANGE_TO_DAYS: Readonly> = Object.freeze({ "7d": 7, @@ -11,6 +24,60 @@ const RANGE_TO_DAYS: Readonly> = Object.free "365d": 365, }); +const STATS_RESPONSE_HEADERS: Readonly> = Object.freeze({ + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=300", +}); + +interface CachedStatsEntry { + cachedAt: number; + expiresAt: number; + body: string; +} + +interface EdgeCacheLike { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} + +let isolateCacheDb: D1Database | null = null; +const isolateStatsCache = new Map(); + +export function clearStatsCache(): void { + isolateCacheDb = null; + isolateStatsCache.clear(); +} + +function getEdgeCache(): EdgeCacheLike | null { + const cacheStorage = (globalThis as unknown as { caches?: { default?: EdgeCacheLike } }).caches; + const defaultCache = cacheStorage?.default; + if ( + defaultCache + && typeof defaultCache.match === "function" + && typeof defaultCache.put === "function" + ) { + return defaultCache; + } + return null; +} + +function buildCanonicalStatsCacheRequest(requestUrl: URL, range: TelemetryStatsRange): Request { + const canonical = new URL("/v1/stats", `${requestUrl.protocol}//${requestUrl.host}`); + canonical.searchParams.set("range", range); + return new Request(canonical.toString(), { method: "GET" }); +} + +function clampSafeInt(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + if (n >= MAX_SAFE_SQL_INT) return MAX_SAFE_SQL_INT; + return Math.floor(n); +} + +function addSafeInt(a: number, b: number): number { + return clampSafeInt(a + b); +} + export function utcMidnightMs(now: number): number { const date = new Date(now); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); @@ -51,12 +118,6 @@ interface DailyProviderRow { tokens: number; } -interface ProviderRow { - provider_kind: string; - requests: number; - tokens: number; -} - interface ModelRow { model_family: string; requests: number; @@ -94,93 +155,69 @@ export async function buildStatsSnapshot( const todayMs = utcMidnightMs(now); const rangeDays = RANGE_TO_DAYS[range]; const rangeStartDay = formatUtcDay(todayMs - (rangeDays - 1) * DAY_MS); - const rangeEndDay = formatUtcDay(todayMs + DAY_MS); + const rangeEndDay = formatUtcDay(todayMs); const heatmapStartDay = formatUtcDay(todayMs - (HEATMAP_DAYS - 1) * DAY_MS); const [ dailyTotalsRes, dailyByProviderRes, - heatmapRes, - providersRes, modelsRes, versionsRes, platformsRes, channelsRes, errorsRes, - ] = await Promise.all([ + ] = await env.DB.batch>([ env.DB .prepare( "SELECT day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, " + "input, cache_read, cache_write, output, reasoning " + "FROM daily_totals WHERE day >= ?1 AND day <= ?2 ORDER BY day ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(heatmapStartDay, rangeEndDay), env.DB .prepare( - "SELECT day, provider_kind, SUM(requests) AS requests, " + - "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + + `SELECT day, provider_kind, ${SQL_CLAMPED_REQUESTS_SUM} AS requests, ` + + `${SQL_CLAMPED_TOKENS_SUM} AS tokens ` + "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + "GROUP BY day, provider_kind ORDER BY day ASC, tokens DESC, requests DESC, provider_kind ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), - env.DB - .prepare( - "SELECT day, reports FROM daily_totals WHERE day >= ?1 AND day <= ?2 ORDER BY day ASC", - ) - .bind(heatmapStartDay, rangeEndDay) - .all<{ day: string; reports: number }>(), - env.DB - .prepare( - "SELECT provider_kind, SUM(requests) AS requests, " + - "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + - "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + - "GROUP BY provider_kind ORDER BY tokens DESC, requests DESC, provider_kind ASC", - ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), env.DB .prepare( - "SELECT model_family, SUM(requests) AS requests, " + - "SUM(input + cache_read + cache_write + output + reasoning) AS tokens " + + `SELECT model_family, ${SQL_CLAMPED_REQUESTS_SUM} AS requests, ` + + `${SQL_CLAMPED_TOKENS_SUM} AS tokens ` + "FROM daily_models WHERE day >= ?1 AND day <= ?2 " + "GROUP BY model_family ORDER BY tokens DESC, requests DESC, model_family ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), env.DB .prepare( "SELECT version, COUNT(*) AS installs, COALESCE(SUM(new_install), 0) AS new_installs " + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + - "GROUP BY version ORDER BY installs DESC, version DESC", + `GROUP BY version ORDER BY installs DESC, version DESC LIMIT ${MAX_STATS_VERSIONS}`, ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), env.DB .prepare( "SELECT os, arch, COUNT(*) AS installs " + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + "GROUP BY os, arch ORDER BY installs DESC, os ASC, arch ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), env.DB .prepare( "SELECT install_channel, COUNT(*) AS installs, COALESCE(SUM(new_install), 0) AS new_installs " + "FROM daily_installs WHERE day >= ?1 AND day <= ?2 " + "GROUP BY install_channel ORDER BY installs DESC, install_channel ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), env.DB .prepare( - "SELECT cause, SUM(count) AS count " + + "SELECT cause, MIN(9007199254740991, SUM(MIN(9007199254740991, MAX(0, count)))) AS count " + "FROM daily_errors WHERE day >= ?1 AND day <= ?2 " + "GROUP BY cause ORDER BY count DESC, cause ASC", ) - .bind(rangeStartDay, rangeEndDay) - .all(), + .bind(rangeStartDay, rangeEndDay), ]); let activeInstalls = 0; @@ -196,38 +233,49 @@ export async function buildStatsSnapshot( let output = 0; let reasoning = 0; + const heatmapByDay = new Map(); const daily: TelemetryStatsV1["daily"] = []; - for (const row of dailyTotalsRes.results ?? []) { - const rowReports = Number(row.reports); - const rowNewInstalls = Number(row.new_installs); - const rowActiveMinutes = Number(row.active_minutes); - const rowCompleted = Number(row.completed); - const rowFailed = Number(row.failed); - const rowIncomplete = Number(row.incomplete); - const rowAborted = Number(row.aborted); - const rowInput = Number(row.input); - const rowCacheRead = Number(row.cache_read); - const rowCacheWrite = Number(row.cache_write); - const rowOutput = Number(row.output); - const rowReasoning = Number(row.reasoning); - const rowRequests = rowCompleted + rowFailed + rowIncomplete + rowAborted; - const rowTokens = rowInput + rowCacheRead + rowCacheWrite + rowOutput + rowReasoning; - - activeInstalls += rowReports; - newInstalls += rowNewInstalls; - activeMinutes += rowActiveMinutes; - completed += rowCompleted; - failed += rowFailed; - incomplete += rowIncomplete; - aborted += rowAborted; - input += rowInput; - cacheRead += rowCacheRead; - cacheWrite += rowCacheWrite; - output += rowOutput; - reasoning += rowReasoning; + for (const rawRow of (dailyTotalsRes?.results ?? []) as unknown as DailyTotalsRow[]) { + const rowReports = clampSafeInt(rawRow.reports); + heatmapByDay.set(rawRow.day, rowReports); + if (rawRow.day < rangeStartDay || rawRow.day > rangeEndDay) { + continue; + } + const rowNewInstalls = clampSafeInt(rawRow.new_installs); + const rowActiveMinutes = clampSafeInt(rawRow.active_minutes); + const rowCompleted = clampSafeInt(rawRow.completed); + const rowFailed = clampSafeInt(rawRow.failed); + const rowIncomplete = clampSafeInt(rawRow.incomplete); + const rowAborted = clampSafeInt(rawRow.aborted); + const rowInput = clampSafeInt(rawRow.input); + const rowCacheRead = clampSafeInt(rawRow.cache_read); + const rowCacheWrite = clampSafeInt(rawRow.cache_write); + const rowOutput = clampSafeInt(rawRow.output); + const rowReasoning = clampSafeInt(rawRow.reasoning); + const rowRequests = addSafeInt( + addSafeInt(rowCompleted, rowFailed), + addSafeInt(rowIncomplete, rowAborted), + ); + const rowTokens = addSafeInt( + addSafeInt(addSafeInt(rowInput, rowCacheRead), addSafeInt(rowCacheWrite, rowOutput)), + rowReasoning, + ); + + activeInstalls = addSafeInt(activeInstalls, rowReports); + newInstalls = addSafeInt(newInstalls, rowNewInstalls); + activeMinutes = addSafeInt(activeMinutes, rowActiveMinutes); + completed = addSafeInt(completed, rowCompleted); + failed = addSafeInt(failed, rowFailed); + incomplete = addSafeInt(incomplete, rowIncomplete); + aborted = addSafeInt(aborted, rowAborted); + input = addSafeInt(input, rowInput); + cacheRead = addSafeInt(cacheRead, rowCacheRead); + cacheWrite = addSafeInt(cacheWrite, rowCacheWrite); + output = addSafeInt(output, rowOutput); + reasoning = addSafeInt(reasoning, rowReasoning); daily.push({ - day: row.day, + day: rawRow.day, activeInstalls: rowReports, newInstalls: rowNewInstalls, activeMinutes: rowActiveMinutes, @@ -237,10 +285,6 @@ export async function buildStatsSnapshot( }); } - const heatmapByDay = new Map(); - for (const row of heatmapRes.results ?? []) { - heatmapByDay.set(row.day, Number(row.reports)); - } const heatmap: TelemetryStatsV1["heatmap"] = []; for (let offset = HEATMAP_DAYS - 1; offset >= 0; offset--) { const day = formatUtcDay(todayMs - offset * DAY_MS); @@ -250,6 +294,39 @@ export async function buildStatsSnapshot( }); } + const dailyByProvider: TelemetryStatsV1["dailyByProvider"] = []; + const providerTotals = new Map(); + for (const row of (dailyByProviderRes?.results ?? []) as unknown as DailyProviderRow[]) { + const requests = clampSafeInt(row.requests); + const tokens = clampSafeInt(row.tokens); + dailyByProvider.push({ + day: row.day, + providerKind: row.provider_kind, + requests, + tokens, + }); + const existing = providerTotals.get(row.provider_kind); + if (existing) { + existing.requests = addSafeInt(existing.requests, requests); + existing.tokens = addSafeInt(existing.tokens, tokens); + } else { + providerTotals.set(row.provider_kind, { requests, tokens }); + } + } + + const providers: TelemetryStatsV1["providers"] = Array.from( + providerTotals.entries(), + ([providerKind, agg]) => ({ + providerKind, + requests: agg.requests, + tokens: agg.tokens, + }), + ).sort((a, b) => { + if (b.tokens !== a.tokens) return b.tokens - a.tokens; + if (b.requests !== a.requests) return b.requests - a.requests; + return a.providerKind < b.providerKind ? -1 : a.providerKind > b.providerKind ? 1 : 0; + }); + return { schemaVersion: TELEMETRY_SCHEMA_VERSION, generatedAt: new Date(now).toISOString(), @@ -258,7 +335,7 @@ export async function buildStatsSnapshot( activeInstalls, newInstalls, activeMinutes, - requests: completed + failed + incomplete + aborted, + requests: addSafeInt(addSafeInt(completed, failed), addSafeInt(incomplete, aborted)), tokens: { input, cacheRead, @@ -268,37 +345,28 @@ export async function buildStatsSnapshot( }, }, daily, - dailyByProvider: (dailyByProviderRes.results ?? []).map(row => ({ - day: row.day, - providerKind: row.provider_kind, - requests: Number(row.requests), - tokens: Number(row.tokens), - })), + dailyByProvider, heatmap, - providers: (providersRes.results ?? []).map(row => ({ - providerKind: row.provider_kind, - requests: Number(row.requests), - tokens: Number(row.tokens), - })), - models: (modelsRes.results ?? []).map(row => ({ + providers, + models: ((modelsRes?.results ?? []) as unknown as ModelRow[]).map(row => ({ modelFamily: row.model_family, - requests: Number(row.requests), - tokens: Number(row.tokens), + requests: clampSafeInt(row.requests), + tokens: clampSafeInt(row.tokens), })), - versions: (versionsRes.results ?? []).map(row => ({ + versions: ((versionsRes?.results ?? []) as unknown as VersionRow[]).map(row => ({ version: row.version, - installs: Number(row.installs), - newInstalls: Number(row.new_installs), + installs: clampSafeInt(row.installs), + newInstalls: clampSafeInt(row.new_installs), })), - platforms: (platformsRes.results ?? []).map(row => ({ + platforms: ((platformsRes?.results ?? []) as unknown as PlatformRow[]).map(row => ({ os: row.os, arch: row.arch, - installs: Number(row.installs), + installs: clampSafeInt(row.installs), })), - channels: (channelsRes.results ?? []).map(row => ({ + channels: ((channelsRes?.results ?? []) as unknown as ChannelRow[]).map(row => ({ installChannel: row.install_channel, - installs: Number(row.installs), - newInstalls: Number(row.new_installs), + installs: clampSafeInt(row.installs), + newInstalls: clampSafeInt(row.new_installs), })), outcomes: { completed, @@ -306,9 +374,9 @@ export async function buildStatsSnapshot( incomplete, aborted, }, - errors: (errorsRes.results ?? []).map(row => ({ + errors: ((errorsRes?.results ?? []) as unknown as ErrorRow[]).map(row => ({ cause: row.cause, - count: Number(row.count), + count: clampSafeInt(row.count), })), }; } @@ -317,6 +385,7 @@ export async function handleStats( request: Request, env: CollectorEnv, now: number = Date.now(), + options?: { bypassCache?: boolean }, ): Promise { const url = new URL(request.url); const range = parseStatsRange(url.searchParams.get("range")); @@ -331,12 +400,68 @@ export async function handleStats( }, ); } + + const bypassCache = Boolean(options?.bypassCache); + if (isolateCacheDb !== env.DB) { + isolateStatsCache.clear(); + isolateCacheDb = env.DB; + } + + if (!bypassCache) { + const memCached = isolateStatsCache.get(range); + if (memCached) { + if (now >= memCached.cachedAt && now < memCached.expiresAt) { + return new Response(memCached.body, { + status: 200, + headers: STATS_RESPONSE_HEADERS, + }); + } + isolateStatsCache.delete(range); + } + + const edgeCache = getEdgeCache(); + if (edgeCache) { + const cacheReq = buildCanonicalStatsCacheRequest(url, range); + const edgeHit = await edgeCache.match(cacheReq); + if (edgeHit) { + const body = await edgeHit.text(); + isolateStatsCache.set(range, { + cachedAt: now, + expiresAt: now + STATS_CACHE_TTL_MS, + body, + }); + return new Response(body, { + status: 200, + headers: STATS_RESPONSE_HEADERS, + }); + } + } + } + const stats = await buildStatsSnapshot(env, range, now); - return new Response(JSON.stringify(stats), { + const body = JSON.stringify(stats); + + if (!bypassCache) { + isolateStatsCache.set(range, { + cachedAt: now, + expiresAt: now + STATS_CACHE_TTL_MS, + body, + }); + const edgeCache = getEdgeCache(); + if (edgeCache) { + const cacheReq = buildCanonicalStatsCacheRequest(url, range); + await edgeCache.put( + cacheReq, + new Response(body, { + status: 200, + headers: STATS_RESPONSE_HEADERS, + }), + ); + } + } + + return new Response(body, { status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "public, max-age=300", - }, + headers: STATS_RESPONSE_HEADERS, }); } diff --git a/services/telemetry-collector/src/types.ts b/services/telemetry-collector/src/types.ts index b7d35b97108..67edca37e94 100644 --- a/services/telemetry-collector/src/types.ts +++ b/services/telemetry-collector/src/types.ts @@ -36,7 +36,7 @@ export interface ScheduledController { export interface CollectorEnv { DB: D1Database; - RATE_LIMIT_SALT?: string; + RATE_LIMIT_SALT: string; } export type TelemetryStatsRange = "7d" | "30d" | "90d" | "365d"; diff --git a/services/telemetry-collector/tsconfig.json b/services/telemetry-collector/tsconfig.json index 5b3f9c6cdc3..03562e2d01f 100644 --- a/services/telemetry-collector/tsconfig.json +++ b/services/telemetry-collector/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], "rootDir": "../..", "noEmit": true, "declaration": false diff --git a/tests/usage/usage-telemetry-collector-typecheck.test.ts b/tests/usage/usage-telemetry-collector-typecheck.test.ts index a999a2f9e84..a346c536e18 100644 --- a/tests/usage/usage-telemetry-collector-typecheck.test.ts +++ b/tests/usage/usage-telemetry-collector-typecheck.test.ts @@ -81,4 +81,53 @@ describe("telemetry collector typecheck", () => { removeTreeWithRetry(tempRoot); } }); + + test("collector tsconfig excludes bun-types so Bun globals fail typecheck", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ocx-collector-nobun-")); + try { + mkdirSync(join(tempRoot, "src", "telemetry"), { recursive: true }); + mkdirSync(join(tempRoot, "services", "telemetry-collector"), { recursive: true }); + cpSync(repoPath("tsconfig.json"), join(tempRoot, "tsconfig.json")); + symlinkSync( + repoPath("node_modules"), + join(tempRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + cpSync( + repoPath("src", "telemetry", "contract.ts"), + join(tempRoot, "src", "telemetry", "contract.ts"), + ); + cpSync( + repoPath("services", "telemetry-collector"), + join(tempRoot, "services", "telemetry-collector"), + { recursive: true }, + ); + + writeFileSync( + join(tempRoot, "services", "telemetry-collector", "src", "injected-bun.ts"), + "export const leakedBunGlobal = Bun.version;\n", + ); + + const proc = Bun.spawnSync( + [ + process.execPath, + "check", + "--no-script", + "--noEmit", + "-p", + join(tempRoot, "services", "telemetry-collector", "tsconfig.json"), + ], + { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + }, + ); + const output = proc.stdout.toString() + proc.stderr.toString(); + expect(proc.exitCode).not.toBe(0); + expect(output).toContain("Cannot find name 'Bun'"); + } finally { + removeTreeWithRetry(tempRoot); + } + }); }); diff --git a/tests/usage/usage-telemetry-collector-worker.test.ts b/tests/usage/usage-telemetry-collector-worker.test.ts index 2d5f16e47a0..24249e8afcf 100644 --- a/tests/usage/usage-telemetry-collector-worker.test.ts +++ b/tests/usage/usage-telemetry-collector-worker.test.ts @@ -13,6 +13,7 @@ import { formatBucketHour, } from "../../services/telemetry-collector/src/ingest"; import { pruneCollectorTables } from "../../services/telemetry-collector/src/prune"; +import { clearStatsCache, handleStats } from "../../services/telemetry-collector/src/stats"; import type { CollectorEnv, D1Database, @@ -47,7 +48,7 @@ function createInMemoryD1(salt = "test-rate-limit-salt"): InMemoryD1Harness { sqlite.exec(schemaSql); const batchCalls: number[] = []; - function makeStatement(query: string, boundValues: unknown[] = []): D1PreparedStatement { + function makeStatement(query: string, boundValues: unknown[] = []): D1PreparedStatement & { isSelect: () => boolean } { const executeRun = >(): D1Result => { const stmt = sqlite.query(query); const info = stmt.run(...(boundValues as never[])); @@ -62,6 +63,9 @@ function createInMemoryD1(salt = "test-rate-limit-salt"): InMemoryD1Harness { }; return { + isSelect(): boolean { + return sqlite.query(query).columnNames.length > 0; + }, bind(...values: unknown[]): D1PreparedStatement { return makeStatement(query, values); }, @@ -99,7 +103,12 @@ function createInMemoryD1(salt = "test-rate-limit-salt"): InMemoryD1Harness { sqlite.exec("BEGIN IMMEDIATE"); try { for (const stmt of statements) { - out.push(await stmt.run()); + const inspectable = stmt as D1PreparedStatement & { isSelect?: () => boolean }; + if (typeof inspectable.isSelect === "function" && inspectable.isSelect()) { + out.push(await stmt.all()); + } else { + out.push(await stmt.run()); + } } sqlite.exec("COMMIT"); } catch (error) { @@ -265,7 +274,8 @@ describe("telemetry collector worker + D1", () => { expect(res1.status).toBe(200); expect(res1.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); expect(await res1.json()).toEqual({ ok: true, deduplicated: false }); - expect(h.batchCalls).toEqual([8]); + // 1 daily_totals + 4 daily_models + 3 daily_errors + 1 daily_installs = 9 statements in one atomic batch. + expect(h.batchCalls).toEqual([9]); // Re-send the same (day, installDayHash) with a brand-new batchId and inflated numbers. const payload2 = buildTelemetryPayloadV1({ @@ -291,8 +301,8 @@ describe("telemetry collector worker + D1", () => { ); expect(res2.status).toBe(200); expect(await res2.json()).toEqual({ ok: true, deduplicated: true }); - // Dedup must NOT execute a second rollup batch. - expect(h.batchCalls).toEqual([8]); + // Conditional batch runs atomically and leaves all rollup rows untouched. + expect(h.batchCalls).toEqual([9, 9]); const installs = h.sqlite.query("SELECT * FROM daily_installs").all() as Array>; expect(installs).toHaveLength(1); @@ -794,4 +804,487 @@ describe("telemetry collector worker + D1", () => { h.close(); } }); + + test("aborts streaming bodies exceeding 16 KiB before reading full stream and rejects malformed Content-Length", async () => { + const h = createInMemoryD1("stream-salt"); + try { + for (const badLength of ["-1", "abc", "12.5", ""]) { + const badHeaderReq = new Request("https://telemetry.opencodex.me/v1/events", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": badLength, + }, + body: "{}", + }); + const res = await handleCollectorFetch(badHeaderReq, h.env, undefined, NOW); + expect(res.status).toBe(400); + } + + let pulledChunks = 0; + let streamCancelled = false; + const chunkSize = 4096; + const totalChunks = 50; // 200 KiB total if fully buffered + const stream = new ReadableStream({ + pull(controller) { + pulledChunks += 1; + if (pulledChunks > totalChunks) { + controller.close(); + return; + } + controller.enqueue(new Uint8Array(chunkSize).fill(0x61)); + }, + cancel() { + streamCancelled = true; + }, + }); + + // Lying small Content-Length (or chunked stream) must still abort as soon as > 16 KiB is read. + const lyingReq = new Request("https://telemetry.opencodex.me/v1/events", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "64", + }, + body: stream, + duplex: "half", + } as RequestInit); + + const res413 = await handleCollectorFetch(lyingReq, h.env, undefined, NOW); + expect(res413.status).toBe(413); + expect(pulledChunks).toBeLessThanOrEqual(6); + expect(streamCancelled).toBe(true); + + // Chunked stream with no Content-Length header must also abort as soon as > 16 KiB is read. + let chunkedPulled = 0; + let chunkedCancelled = false; + const chunkedStream = new ReadableStream({ + pull(controller) { + chunkedPulled += 1; + if (chunkedPulled > totalChunks) { + controller.close(); + return; + } + controller.enqueue(new Uint8Array(chunkSize).fill(0x62)); + }, + cancel() { + chunkedCancelled = true; + }, + }); + const chunkedReq = new Request("https://telemetry.opencodex.me/v1/events", { + method: "POST", + headers: { "content-type": "application/json" }, + body: chunkedStream, + duplex: "half", + } as RequestInit); + const res413Chunked = await handleCollectorFetch(chunkedReq, h.env, undefined, NOW); + expect(res413Chunked.status).toBe(413); + expect(chunkedPulled).toBeLessThanOrEqual(6); + expect(chunkedCancelled).toBe(true); + } finally { + h.close(); + } + }); + + test("fails closed with 503 when RATE_LIMIT_SALT is missing or empty and delimits IP hash components", async () => { + const h = createInMemoryD1(""); + try { + const day = "2026-11-06"; + const payload = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("11111111-2222-4333-8444-555555555555", day), + newInstall: false, + version: "2.41.0", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + now: NOW, + }); + + const resEmpty = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload), { origin: "https://opencodex.me" }), + { DB: h.db, RATE_LIMIT_SALT: " " }, + undefined, + NOW, + ); + expect(resEmpty.status).toBe(503); + expect(resEmpty.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + expect(await resEmpty.json()).toEqual({ + ok: false, + error: "collector rate-limit salt is not configured", + }); + + const resMissing = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload)), + { DB: h.db } as unknown as CollectorEnv, + undefined, + NOW, + ); + expect(resMissing.status).toBe(503); + + const rateCount = h.sqlite.query("SELECT COUNT(*) AS n FROM rate_limits").get() as { n: number }; + const installCount = h.sqlite.query("SELECT COUNT(*) AS n FROM daily_installs").get() as { n: number }; + expect(rateCount.n).toBe(0); + expect(installCount.n).toBe(0); + + // Delimiter check: shifting characters across salt/ip/bucketHour boundaries must not collide. + const hashA = await computeHourlyIpHash("saltA", "1.2.3.4", "2026-11-07T12"); + const hashB = await computeHourlyIpHash("salt", "A1.2.3.4", "2026-11-07T12"); + const hashC = await computeHourlyIpHash("salt", "1.2.3.4", "2026-11-07T120"); + const hashD = await computeHourlyIpHash("salt", "01.2.3.4", "2026-11-07T12"); + expect(hashA).not.toBe(hashB); + expect(hashC).not.toBe(hashD); + } finally { + h.close(); + } + }); + + test("rolls back daily_installs and rollups atomically on batch failure and succeeds on retry without leaking D1 errors", async () => { + const h = createInMemoryD1("atomic-salt"); + try { + const day = "2026-11-06"; + const payload = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb", day), + newInstall: true, + version: "2.41.0", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + now: NOW, + }); + + // Inject a failure on daily_errors inside the rollup transaction. + h.sqlite.exec( + "CREATE TRIGGER fail_daily_errors BEFORE INSERT ON daily_errors " + + "BEGIN SELECT RAISE(ABORT, 'secret_internal_d1_error_detail'); END;", + ); + + const failedRes = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload), { + ip: "198.51.100.200", + origin: "https://opencodex.me", + }), + h.env, + undefined, + NOW, + ); + expect(failedRes.status).toBe(500); + expect(failedRes.headers.get("access-control-allow-origin")).toBe("https://opencodex.me"); + const failedBody = await failedRes.text(); + expect(failedBody).not.toContain("secret_internal_d1_error_detail"); + expect(JSON.parse(failedBody)).toEqual({ ok: false, error: "internal error" }); + + // Neither daily_installs nor any rollup table may have committed rows. + for (const table of ["daily_installs", "daily_totals", "daily_models", "daily_errors"] as const) { + const count = h.sqlite.query("SELECT COUNT(*) AS n FROM " + table).get() as { n: number }; + expect(count.n).toBe(0); + } + + // Remove trigger and retry the exact same report: must succeed with deduplicated: false. + h.sqlite.exec("DROP TRIGGER fail_daily_errors;"); + const retryRes = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload), { + ip: "198.51.100.200", + origin: "https://opencodex.me", + }), + h.env, + undefined, + NOW, + ); + expect(retryRes.status).toBe(200); + expect(await retryRes.json()).toEqual({ ok: true, deduplicated: false }); + + const installs = h.sqlite.query("SELECT COUNT(*) AS n FROM daily_installs").get() as { n: number }; + const totals = h.sqlite.query("SELECT reports FROM daily_totals WHERE day = ?1").get(day) as { reports: number }; + expect(installs.n).toBe(1); + expect(totals.reports).toBe(1); + } finally { + h.close(); + } + }); + + test("enforces per-report aggregate ceilings, version length cap, versions LIMIT 50, and MAX_SAFE_INTEGER clamping", async () => { + const h = createInMemoryD1("bounds-salt"); + try { + const day = "2026-11-06"; + const valid = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("cccccccc-1111-4222-8333-dddddddddddd", day), + newInstall: false, + version: "2.41.0", + os: "linux", + arch: "x64", + installChannel: "standalone", + now: NOW, + }); + + // 1. Model row with 0 requests but positive tokens -> 400 + const zeroReqWithTokens = { + ...valid, + models: [ + { + providerKind: "openai", + modelFamily: "gpt-5", + requests: 0, + inputTokens: 100, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + ], + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(zeroReqWithTokens)), h.env, undefined, NOW)).status, + ).toBe(400); + + // 2. Aggregate report tokens > 1_000_000_000_000 across buckets/rows -> 400 + const overAggregateTokens = { + ...valid, + models: [ + { + providerKind: "openai", + modelFamily: "gpt-5", + requests: 1, + inputTokens: 600_000_000_000, + cacheReadTokens: 600_000_000_000, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + ], + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(overAggregateTokens)), h.env, undefined, NOW)).status, + ).toBe(400); + + // 3. Aggregate requests > 10_000_000 -> 400 + const overAggregateRequests = { + ...valid, + requests: { + completed: 6_000_000, + failed: 5_000_000, + incomplete: 0, + aborted: 0, + }, + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(overAggregateRequests)), h.env, undefined, NOW)).status, + ).toBe(400); + + const overModelRequests = { + ...valid, + models: [ + { ...valid.models[0]!, providerKind: "openai", modelFamily: "gpt-5", requests: 6_000_000 }, + { ...valid.models[0]!, providerKind: "anthropic", modelFamily: "claude-opus", requests: 5_000_000 }, + ], + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(overModelRequests)), h.env, undefined, NOW)).status, + ).toBe(400); + + const overErrorCounts = { + ...valid, + errors: { + "rate-limit": 6_000_000, + "empty-output": 5_000_000, + }, + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(overErrorCounts)), h.env, undefined, NOW)).status, + ).toBe(400); + + // 4. env.version longer than 32 chars -> 400 + const longVersion = { + ...valid, + env: { + ...valid.env, + version: "1.0." + "9".repeat(35), + }, + }; + expect( + (await handleCollectorFetch(postEventRequest(JSON.stringify(longVersion)), h.env, undefined, NOW)).status, + ).toBe(400); + + // 5. Seed near-MAX_SAFE_INTEGER counters in daily_totals and daily_models, then ingest another report + // and verify counters clamp to Number.MAX_SAFE_INTEGER without overflowing SQLite 64-bit INTEGER. + const nearMax = Number.MAX_SAFE_INTEGER - 10; + h.sqlite + .query( + "INSERT INTO daily_totals (day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, input, cache_read, cache_write, output, reasoning) " + + "VALUES (?1, 1, 1, 1, ?2, 0, 0, 0, ?2, ?2, ?2, ?2, ?2)", + ) + .run(day, nearMax); + h.sqlite + .query( + "INSERT INTO daily_models (day, provider_kind, model_family, requests, input, cache_read, cache_write, output, reasoning) " + + "VALUES (?1, 'claude-subscription', 'claude-opus', ?2, ?2, ?2, ?2, ?2, ?2)", + ) + .run(day, nearMax); + + const clampRes = await handleCollectorFetch( + postEventRequest(JSON.stringify(valid), { ip: "198.51.100.201" }), + h.env, + undefined, + NOW, + ); + expect(clampRes.status).toBe(200); + + // Also seed 60 distinct versions in daily_installs to verify LIMIT 50 in /v1/stats. + for (let i = 0; i < 60; i++) { + h.sqlite + .query( + "INSERT INTO daily_installs (day, install_day_hash, version, release_channel, os, arch, install_channel, new_install, active_minutes) " + + "VALUES (?1, ?2, ?3, 'latest', 'linux', 'x64', 'standalone', 0, 1)", + ) + .run(day, i.toString(16).padStart(64, "0"), "2.0." + String(i)); + } + + const statsRes = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d"), + h.env, + undefined, + NOW, + ); + expect(statsRes.status).toBe(200); + const stats = (await statsRes.json()) as TelemetryStatsV1; + expect(stats.totals.tokens.input).toBe(Number.MAX_SAFE_INTEGER); + expect(stats.totals.requests).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER); + expect(stats.daily[0]?.tokens).toBe(Number.MAX_SAFE_INTEGER); + expect(stats.providers[0]?.tokens).toBe(Number.MAX_SAFE_INTEGER); + expect(stats.models[0]?.tokens).toBe(Number.MAX_SAFE_INTEGER); + expect(stats.versions).toHaveLength(50); + } finally { + h.close(); + } + }); + + test("enforces exact N-day UTC range boundaries aligned with heatmap and caches /v1/stats in one D1 batch", async () => { + const h = createInMemoryD1("range-cache-salt"); + try { + // NOW is 2026-11-07T12:30:00Z -> today = 2026-11-07. + // For 7d: [2026-11-01 (today - 6d), 2026-11-07 (today)]. + // 2026-10-31 (today - 7d) and 2026-11-08 (today + 1d) must both be excluded from 7d. + const boundaryDays = [ + { day: "2026-10-31", ip: "10.1.0.1" }, // today - 7d (outside 7d, inside 30d) + { day: "2026-11-01", ip: "10.1.0.2" }, // today - 6d (inside 7d) + { day: "2026-11-07", ip: "10.1.0.3" }, // today (inside 7d) + { day: "2026-11-08", ip: "10.1.0.4" }, // today + 1d (accepted on ingest for clock skew, excluded from stats ranges <= today) + ]; + + for (const { day, ip } of boundaryDays) { + const payload = buildTelemetryPayloadV1({ + aggregate: makeAggregate(day), + installDayHash: computeInstallDayHash("eeeeeeee-1111-4222-8333-ffffffffffff", day), + newInstall: false, + version: "2.41.0", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + now: NOW, + }); + const res = await handleCollectorFetch( + postEventRequest(JSON.stringify(payload), { ip }), + h.env, + undefined, + NOW, + ); + expect(res.status).toBe(200); + } + + const batchCountBeforeStats = h.batchCalls.length; + const stats7dRes = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d&_=1"), + h.env, + undefined, + NOW, + ); + expect(stats7dRes.status).toBe(200); + const stats7d = (await stats7dRes.json()) as TelemetryStatsV1; + expect(stats7d.daily.map(d => d.day)).toEqual(["2026-11-01", "2026-11-07"]); + expect(stats7d.totals.activeInstalls).toBe(2); + expect(stats7d.heatmap.filter(d => d.activeInstalls > 0).map(d => d.day)).toEqual([ + "2026-10-31", + "2026-11-01", + "2026-11-07", + ]); + + // Stats query must execute in a single D1 batch call. + expect(h.batchCalls.length).toBe(batchCountBeforeStats + 1); + + // Cache-busted repeat request within 300s TTL must be served from Worker cache without hitting D1. + const cachedRes = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d&_=cachebust2"), + h.env, + undefined, + NOW + 30_000, + ); + expect(cachedRes.status).toBe(200); + expect(h.batchCalls.length).toBe(batchCountBeforeStats + 1); + expect(await cachedRes.json()).toEqual(stats7d); + + // Advancing past the 300s TTL expires the cached entry and triggers a fresh D1 batch. + const expiredRes = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d&_=afterTtl"), + h.env, + undefined, + NOW + 300_001, + ); + expect(expiredRes.status).toBe(200); + expect(h.batchCalls.length).toBe(batchCountBeforeStats + 2); + + // Explicit bypassCache option skips the cache even within the TTL window. + const bypassRes = await handleStats( + new Request("https://telemetry.opencodex.me/v1/stats?range=7d"), + h.env, + NOW + 300_010, + { bypassCache: true }, + ); + expect(bypassRes.status).toBe(200); + expect(h.batchCalls.length).toBe(batchCountBeforeStats + 3); + + // When caches.default is available and in-isolate memory is cleared, edge cache serves the canonical range key. + const edgeStore = new Map(); + const prevCaches = (globalThis as { caches?: unknown }).caches; + (globalThis as { caches?: unknown }).caches = { + default: { + async match(req: Request): Promise { + const hit = edgeStore.get(req.url); + return hit ? new Response(hit) : undefined; + }, + async put(req: Request, res: Response): Promise { + edgeStore.set(req.url, await res.clone().text()); + }, + }, + }; + try { + clearStatsCache(); + const populateEdge = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=30d&utm=1"), + h.env, + undefined, + NOW, + ); + expect(populateEdge.status).toBe(200); + expect(edgeStore.has("https://telemetry.opencodex.me/v1/stats?range=30d")).toBe(true); + const batchesAfterPopulate = h.batchCalls.length; + + clearStatsCache(); + const fromEdge = await handleCollectorFetch( + new Request("https://telemetry.opencodex.me/v1/stats?range=30d&utm=2"), + h.env, + undefined, + NOW + 5_000, + ); + expect(fromEdge.status).toBe(200); + expect(h.batchCalls.length).toBe(batchesAfterPopulate); + } finally { + (globalThis as { caches?: unknown }).caches = prevCaches; + clearStatsCache(); + } + } finally { + h.close(); + } + }); }); From 16eeb10777b244d05c13d5b15a241978151129e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:15:37 +0900 Subject: [PATCH 10/63] test(cli): map the GUI telemetry routes to ocx telemetry in the headless parity table --- tests/cli/cli-headless-parity.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index a7c6a5faa6a..70170a9857e 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -636,6 +636,9 @@ describe("headless GUI parity CLI", () => { ["/api/subagent", "ocx agent"], ["/api/sync", "ocx system sync"], ["/api/system", "ocx observe/system"], + // Telemetry: status, enable and disable are `ocx telemetry`. The GUI's consent + // and notice writes are dashboard-session-only by design (an agent must not answer). + ["/api/telemetry", "ocx telemetry"], ["/api/update", "ocx system update"], ["/api/usage", "ocx observe usage"], ["/api/v2", "ocx v2/agent"], From 57108f55ccff90b2ee46ec8559fa0e79b6a9927d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:19:41 +0900 Subject: [PATCH 11/63] docs(telemetry): add telemetry privacy guide and configuration reference --- docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/telemetry.md | 242 ++++++++++++++++++ .../src/content/docs/guides/web-dashboard.md | 1 + .../docs/reference/configuration/server.md | 34 +++ structure/INDEX.md | 3 +- structure/manifest.json | 5 +- structure/telemetry.md | 14 +- 7 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 docs-site/src/content/docs/guides/telemetry.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 7c9c1558a3e..f69d696cf28 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -118,6 +118,7 @@ export default defineConfig({ { label: "Image Bridge", translations: { fr: "Pont d’images", ko: "이미지 브릿지", "zh-CN": "图像桥接", "zh-TW": "圖像橋接", ru: "Image Bridge", ja: "画像ブリッジ", tr: "Image Bridge" }, slug: "guides/image-bridge" }, { label: "Video Bridge", translations: { fr: "Pont vidéo", ko: "비디오 브릿지", "zh-CN": "视频桥接", "zh-TW": "影片橋接", ru: "Video Bridge", ja: "動画ブリッジ", tr: "Video Bridge" }, slug: "guides/video-bridge" }, { label: "Web Dashboard", translations: { fr: "Tableau de bord web", ko: "웹 대시보드", "zh-CN": "网页控制台", "zh-TW": "網頁儀表板", ru: "Веб-дашборд", ja: "ウェブダッシュボード", tr: "Web Kontrol Paneli" }, slug: "guides/web-dashboard" }, + { label: "Telemetry & Privacy", translations: { fr: "Télémétrie et confidentialité", ko: "텔레메트리 및 개인정보", "zh-CN": "遥测与隐私", "zh-TW": "遙測與隱私", ru: "Телеметрия и конфиденциальность", ja: "テレメトリとプライバシー", tr: "Telemetri ve Gizlilik" }, slug: "guides/telemetry" }, { label: "Sub-agent Surface", translations: { fr: "Interface des sous-agents", ko: "서브에이전트 서피스", "zh-CN": "子代理界面", "zh-TW": "子代理介面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス", tr: "Alt Ajan Arayüzü" }, slug: "guides/sub-agent-surface" }, { label: "Why v1 Is the Default", translations: { fr: "Pourquoi v1 est la valeur par défaut", ko: "v1이 기본값인 이유", "zh-CN": "为什么默认是 v1", "zh-TW": "為什麼預設是 v1", ru: "Почему v1 по умолчанию", ja: "v1 がデフォルトである理由", tr: "Neden varsayılan v1" }, slug: "guides/subagent-v1-default" }, ], diff --git a/docs-site/src/content/docs/guides/telemetry.md b/docs-site/src/content/docs/guides/telemetry.md new file mode 100644 index 00000000000..c540fb953cc --- /dev/null +++ b/docs-site/src/content/docs/guides/telemetry.md @@ -0,0 +1,242 @@ +--- +title: Telemetry and Privacy +description: Anonymous daily usage statistics, consent and agent boundaries, opt-out controls, payload preview, and collector retention. +--- + +OpenCodex includes anonymous, daily usage telemetry so maintainers can see which versions, +platforms, install channels, provider kinds, and coarse model families are used in practice and +where request failures occur. + +Telemetry is **on by default**, and **nothing is sent until a person has seen the notice**. + +## How consent and the default-on rule work + +A fresh install — or an existing install that updates into a release with telemetry and has no +saved decision — starts in the `awaiting-notice` state. While in `awaiting-notice`, the proxy +sends no telemetry. + +Sending becomes active only after a person has seen the disclosure or recorded a choice: + +1. **Web dashboard popup (`TelemetryConsentCard`)** — opens on first run or after an update when + no decision is recorded and no environment opt-out is set. The **Share anonymous daily usage + statistics** checkbox is pre-checked. Clicking **Continue** saves `decision: "accepted"` (or + `"declined"` if unchecked) via `PUT /api/telemetry`. Dismissing the dialog with Escape or the + backdrop records `noticeShownAt` via `POST /api/telemetry/notice` so the default-on state + applies. Merely loading the page without interacting with or dismissing the dialog records + nothing. +2. **Interactive terminal prompt** — `ocx start`, `ocx init`, and `ocx service install` ask + `Share anonymous usage statistics? [Y/n]` once when stdin and stdout are interactive TTYs and + no decision or notice timestamp is recorded. Pressing Enter or `y` records `decision: + "accepted"`; `n` records `decision: "declined"`. +3. **Explicit enable** — turning on **Share anonymous usage statistics** in the dashboard under + **Proxy details**, or running `ocx telemetry enable` in an interactive terminal, records + `decision: "accepted"`. + +`resolveTelemetryStatus` (`src/telemetry/consent.ts`) evaluates effective state in this exact +precedence order: + +| Condition (first match wins) | Effective state | Status `reason` | +| --- | --- | --- | +| `OCX_TELEMETRY` is `0`, `false`, `off`, or `no` | Disabled | `env-ocx-telemetry` | +| `DO_NOT_TRACK` is `1`, `true`, or `yes` | Disabled | `env-do-not-track` | +| `telemetry.decision === "declined"` | Disabled | `declined` | +| `telemetry.decision === "accepted"` | Enabled | `accepted` | +| `telemetry.decision` unset and `telemetry.noticeShownAt` set | Enabled | `default-on` | +| `telemetry.decision` unset and `telemetry.noticeShownAt` unset | Disabled | `awaiting-notice` | + +## Agent-driven starts never enable telemetry + +An AI coding agent or automation harness must never answer the telemetry prompt or acknowledge the +disclosure on your behalf: + +- When `ocx start`, `ocx init`, or `ocx service install` runs under an agent or CI environment + (`CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_TRACE_ID`, `CI`, and similar markers checked by + `isAgentDriven()`), in a non-TTY shell, or as a background service (`OCX_SERVICE=1`), OpenCodex + skips the prompt, prints a one-line notice at most once per version (tracked in + `/.telemetry-deferred`), and writes **neither** `noticeShownAt` nor `decision`. + The install stays in `awaiting-notice` and sends nothing until you use the dashboard or an + interactive terminal yourself. +- `PUT /api/telemetry` and `POST /api/telemetry/notice` require a live human dashboard browser + session (`hasHumanDashboardSession`). Requests authenticated only with the management admin token + or a remembered-device session are refused with HTTP `403` (`code: "agent_consent_required"`). +- `ocx telemetry enable` refuses agent-driven callers with `agent_consent_required` and exits with + status `1`. +- `ocx config set telemetry` and `ocx config unset telemetry` are refused for all callers, and an + agent-driven `ocx config import` drops any `telemetry` key rather than importing consent. +- Opting out with `ocx telemetry disable`, `OCX_TELEMETRY=0`, or `DO_NOT_TRACK=1` is always + permitted, including from scripts and agent sessions. + +## How to turn telemetry off + +You can disable telemetry at any time through any of five controls: + +1. **GUI consent popup** — uncheck **Share anonymous daily usage statistics** and click + **Continue** when the first-run or post-update dialog appears. +2. **Proxy details toggle** — open the [Web Dashboard](/guides/web-dashboard/) Overview page, go to + the **Telemetry** section under **Proxy details** (`#proxy-telemetry`), and switch off **Share + anonymous usage statistics**. +3. **CLI command**: + + ```bash + ocx telemetry disable + ``` + + This writes `"decision": "declined"` to `config.json` and immediately deletes the local + `/telemetry-id.json` file. +4. **`OCX_TELEMETRY=0`** — set `OCX_TELEMETRY=0` (or `false`, `off`, `no`, case-insensitive) in the + proxy environment. +5. **`DO_NOT_TRACK=1`** — set `DO_NOT_TRACK=1` (or `true`, `yes`, case-insensitive) in the proxy + environment. + +Both environment variables override `config.json` immediately without modifying the saved file. +While either variable is set, the dashboard toggle is disabled and names the active variable. + +## How to see the exact payload + +You can inspect the exact JSON report prepared from your latest completed UTC day before consenting +and at any time afterward: + +- **CLI (human-readable + JSON preview)**: + + ```bash + ocx telemetry status + ``` + +- **CLI (machine-readable snapshot)**: + + ```bash + ocx telemetry status --json + ``` + + This queries `GET /api/telemetry` when the proxy is running and falls back to building the same + snapshot locally from `config.json` and `usage.jsonl` when the proxy is stopped. +- **Web dashboard** — click **Preview the JSON** inside the first-run/post-update consent dialog + or beside the **Share anonymous usage statistics** switch in **Proxy details** (labeled + "What is sent" in the payload summary header). + +Before the first report has been sent — or after telemetry is disabled and `telemetry-id.json` is +removed — no local install ID exists on disk, so `installDayHash` in the preview is shown as 64 +zeros (`0000000000000000000000000000000000000000000000000000000000000000`). + +## What is collected + +Each report covers one completed UTC calendar day (`YYYY-MM-DD`) and is validated by +`validateTelemetryPayloadV1` (`src/telemetry/contract.ts`) before leaving the proxy and again on the +collector. Unknown keys and strings outside the closed vocabularies below are rejected. + +Here is a real example payload produced by `buildTelemetryPayloadV1` (`src/telemetry/payload.ts`): + +```json +{ + "schemaVersion": 1, + "batchId": "c2bf1164-d009-4111-a8a6-902d55f727c7", + "day": "2026-11-06", + "installDayHash": "456b15e3a95d8665bbd9a40ac2cae49986aec3f03b78b6dd601df49abfc406d4", + "newInstall": false, + "env": { + "version": "2.99.0", + "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": 184200, + "cacheReadTokens": 6410000, + "cacheWriteTokens": 512000, + "outputTokens": 96400, + "reasoningTokens": 28100 + }, + { + "providerKind": "chatgpt", + "modelFamily": "gpt-6", + "requests": 460, + "inputTokens": 210500, + "cacheReadTokens": 5890000, + "cacheWriteTokens": 0, + "outputTokens": 112300, + "reasoningTokens": 44800 + } + ], + "errors": { + "rate-limit": 7, + "upstream-fault": 5 + } +} +``` + +| Field | Contents | +| --- | --- | +| `schemaVersion` | Fixed integer `1`. | +| `batchId` | Random UUID v4 generated when the payload is built. | +| `day` | Completed UTC calendar date (`YYYY-MM-DD`), within the last 7 completed UTC days. | +| `installDayHash` | `sha256(installId + ":" + day)` as 64 lowercase hex characters. Rotates every UTC day. | +| `newInstall` | `true` only on the first report ever sent for the current local `telemetry-id.json`; `false` afterward. | +| `env.version` | Strict semver (`X.Y.Z` or `X.Y.Z-preview.N`) with any `+build` metadata stripped. | +| `env.releaseChannel` | `"latest"` or `"preview"`. | +| `env.os` | `"darwin"`, `"linux"`, `"win32"`, or `"other"`. | +| `env.arch` | `"x64"`, `"arm64"`, or `"other"`. | +| `env.installChannel` | `"npm-global"`, `"pnpm-global"`, `"bun-global"`, `"mise"`, `"standalone"`, `"desktop"`, `"docker"`, `"source"`, or `"unknown"`. | +| `activeMinutes` | Distinct UTC minutes (`0`–`1440`) during `day` in which at least one request was logged in `usage.jsonl`. | +| `requests` | Request counts by closed outcome class: `completed`, `failed`, `incomplete`, and `aborted`. | +| `models` | Up to 64 rows per day, grouped by unique `(providerKind, modelFamily)` pairs from closed rosters (`PROVIDER_KINDS` and `MODEL_FAMILIES`). Each row reports `requests` and five disjoint token counters — `inputTokens` (uncached input), `cacheReadTokens`, `cacheWriteTokens`, `outputTokens` (non-reasoning output), and `reasoningTokens` — whose sum matches `usageDisplayTotalTokens`. | +| `errors` | Counts of non-completed requests keyed by the closed failure-cause roster (`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`). | + +## What is never collected + +OpenCodex never includes any of the following in telemetry payloads: + +- Prompts, completions, reasoning traces, tool arguments, or any request or response bodies. +- API keys, OAuth access or refresh tokens, session cookies, or management/data-plane tokens. +- Account IDs, email addresses, organization identifiers, or billing/plan details. +- User-chosen provider names or keys, custom model IDs outside the closed `MODEL_FAMILIES` + buckets (unknown models map to `"other"`), or custom `baseUrl` and proxy URLs. +- Hostnames, local or upstream IP addresses, MAC addresses, hardware serials, working directories, + or file paths. +- Raw or free-form upstream error messages or stack traces. +- Usage from any UTC day before consent or disclosure was recorded (`decidedAt ?? noticeShownAt`). + +## Storage, retention, and collector source + +- **Local install ID and daily hash rotation** — when telemetry sends its first report, the proxy + creates `/telemetry-id.json` (mode `0600`) containing a random UUID v4. The raw + UUID never leaves your machine; each report sends only `sha256(installId + ":" + day)`. Because + the UTC date is hashed together with the UUID, an installation's hash changes every day and + cannot be linked across days on the server. Disabling telemetry deletes `telemetry-id.json` + immediately, and re-enabling later generates a new UUID. +- **Background schedule and no backfill** — an unref'd background timer (`src/telemetry/scheduler.ts`) + runs once after startup (30–60 s jitter) and every 6 hours thereafter, completely off the request + path. Each tick re-reads `config.json` from disk and sends at most **one** completed UTC day per + tick (oldest unsent day first, within the last 7 completed UTC days) to + `https://telemetry.opencodex.me/v1/events` with a 10-second timeout. Days before the UTC day of + `decidedAt ?? noticeShownAt` are never sent. Sent day markers in + `/telemetry-state.json` are pruned after 30 days. +- **One report per install per day** — the collector deduplicates on `(day, install_day_hash)` in a + single atomic conditional D1 batch across `daily_installs`, `daily_totals`, `daily_models`, and + `daily_errors`. Re-sending the same day for the same `installDayHash` is a no-op and never + double-counts. +- **No IP addresses stored** — the collector never stores raw client IP addresses. Ingest rate + limiting (30 reports per hour) hashes the client IP with a mandatory secret salt and the current + UTC hour (`sha256(RATE_LIMIT_SALT + "\0" + bucketHour + "\0" + ip)`) in `rate_limits`, and an + hourly scheduled job (`15 * * * *`) deletes `rate_limits` buckets older than 2 hours. +- **Daily aggregate retention** — `daily_installs` rows (which hold only the per-day hash, version, + release channel, OS, CPU architecture, install channel, `new_install`, and `active_minutes`) are + pruned after 400 days; `daily_totals`, `daily_models`, and `daily_errors` hold only project-wide + daily counters. +- **Auditable source code** — the client implementation lives in + [`src/telemetry/`](https://github.com/lidge-jun/opencodex/tree/main/src/telemetry) and the + Cloudflare Worker + D1 collector lives in + [`services/telemetry-collector/`](https://github.com/lidge-jun/opencodex/tree/main/services/telemetry-collector). + For the `config.json` schema and environment variables, see + [Server and Runtime Configuration](/reference/configuration/server/#telemetry-telemetry). diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 5dd7a99df8f..c358617a9e3 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -147,6 +147,7 @@ is visible) and never forces an upstream refresh. | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | +| **Telemetry** | Toggle **Share anonymous usage statistics** and inspect **Preview the JSON** under **Proxy details** (`#proxy-telemetry`). See [Telemetry and Privacy](/guides/telemetry/). | | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Refreshing quota skips paused OAuth accounts and disabled API keys; skipped rows without a cached reading remain unavailable but do not affect the refresh result. Refreshing reports failure when an unpaused OAuth account or enabled API key that supports quota reads has an unavailable reading. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 7c435e2b08e..5a1c6827489 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -63,6 +63,7 @@ passthrough, including native WebSocket output, retain their existing stop behav | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | +| `telemetry?` | `OcxTelemetryConfig` | default-on after human disclosure | Anonymous daily usage telemetry consent state (`decision`, `decidedAt`, `noticeShownAt`). Nothing is sent until a person has seen the notice or opted in. Overridden by `OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`. See [Telemetry (`telemetry`)](#telemetry-telemetry) and [Telemetry and Privacy](/guides/telemetry/). | On Linux x64/arm64 with pidfd support, service health recovery binds termination to the proxy's kernel process identity, so PID reuse cannot redirect it. The sentinel's watching log ends in @@ -487,6 +488,39 @@ block exists, because `config set` walks into existing objects and never creates whole object instead — `ocx config set privacy '{"maskEmails":false}'` — or add the block to `config.json` by hand. +## Telemetry (`telemetry`) + +Anonymous daily usage telemetry defaults to on only after a person has seen the disclosure notice +(in the GUI popup or an interactive terminal prompt). The `telemetry` block in `config.json` records +that consent state: + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | unset (undecided) | Explicit user choice. `"declined"` disables telemetry and deletes `/telemetry-id.json`; `"accepted"` enables daily reports. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | unset | UTC timestamp when `decision` was recorded. Completed UTC days before `decidedAt ?? noticeShownAt` are never backfilled. | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | unset | UTC timestamp when a human saw the disclosure in the web dashboard or an interactive terminal. When `decision` is unset, telemetry sends only if `noticeShownAt` is set (`default-on`); without it the proxy remains in `awaiting-notice`. | + +The collector endpoint (`https://telemetry.opencodex.me/v1/events`) is a code constant and cannot +be configured in `config.json`. + +Two environment variables override `config.json` and disable telemetry immediately without modifying +saved configuration: + +- `OCX_TELEMETRY=0` (also accepts `false`, `off`, or `no`, case-insensitive) +- `DO_NOT_TRACK=1` (also accepts `true` or `yes`, case-insensitive) + +`ocx config set telemetry` and `ocx config unset telemetry` are refused so automated callers cannot record consent through generic config writes; use `ocx telemetry enable`, `ocx telemetry disable`, `ocx telemetry status [--json]`, or the dashboard **Proxy details** toggle instead. See [Telemetry and Privacy](/guides/telemetry/) for the full payload schema and retention policy. + ## Storage cleanup `storageCleanupPolicy` is disabled by default. When enabled, it runs on `startup`, `daily`, `weekly`, diff --git a/structure/INDEX.md b/structure/INDEX.md index c2c508ce8a8..593d766543f 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -147,7 +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. | +| [`telemetry.md`](telemetry.md) | Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, CLI, and Cloudflare Worker collector. | ### Tier 6 — Operations and process @@ -210,6 +210,7 @@ A source area can be described by more than one doc, because these docs are orga | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | | `scripts/test-run-lock-gate.ts` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | | `scripts/test-run-lock.ts` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | +| `services/telemetry-collector/` | [`telemetry.md`](telemetry.md) | | `skills/ocx/` | [`cli-management.md`](cli-management.md) | | `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers/claude-cli-replay.md`](providers/claude-cli-replay.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | | `src/adapters/anthropic-rejection-memo.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 9252acff98c..fb7ab222e3a 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1431,12 +1431,13 @@ "path": "telemetry.md", "tier": 5, "title": "Telemetry", - "scope": "Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI.", + "scope": "Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, CLI, and Cloudflare Worker collector.", "documents": [ "src/telemetry/", "src/types/telemetry.ts", "src/server/management/telemetry-routes.ts", - "src/cli/telemetry.ts" + "src/cli/telemetry.ts", + "services/telemetry-collector/" ] } ], diff --git a/structure/telemetry.md b/structure/telemetry.md index 6adefcb16db..65c8482e0a3 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -1,7 +1,8 @@ # 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`. +`src/server/management/telemetry-routes.ts`, `src/cli/telemetry.ts`, and +`services/telemetry-collector/`, with public user documentation in `docs-site/src/content/docs/guides/telemetry.md`. ## Wire contract and vocabularies @@ -52,3 +53,14 @@ sum matches `usageDisplayTotalTokens` in `src/usage/totals.ts`. with a 10-second `signalWithTimeout`. `src/telemetry/scheduler.ts` runs an unref'd background timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on each tick and sending at most one completed post-consent day per tick. + +## Collector and public documentation + +`services/telemetry-collector/` is the Cloudflare Worker + D1 service (`services/telemetry-collector/src/index.ts`, +`services/telemetry-collector/src/ingest.ts`, `services/telemetry-collector/src/stats.ts`, +`services/telemetry-collector/src/prune.ts`, and `services/telemetry-collector/schema.sql`) that imports +`src/telemetry/contract.ts` directly, deduplicates reports on `(day, install_day_hash)` in a single +atomic conditional D1 batch, rate-limits `POST /v1/events` by salted hourly IP hashes without +storing raw IP addresses, and serves cached aggregate rollups from `GET /v1/stats`. + +`docs-site/src/content/docs/guides/telemetry.md` documents the payload schema, consent and agent boundaries, opt-out controls, payload preview, and collector retention. From e9a2aba7fbc6b140f74375c92a98e1c8b10345a3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:38:49 +0900 Subject: [PATCH 12/63] docs(telemetry): address review findings and localize dashboard/server references --- .../content/docs/fr/guides/web-dashboard.md | 1 + .../docs/fr/reference/configuration/server.md | 30 +++++++++ .../src/content/docs/guides/telemetry.md | 64 ++++++++++++------- .../content/docs/ja/guides/web-dashboard.md | 1 + .../docs/ja/reference/configuration/server.md | 30 +++++++++ .../content/docs/ko/guides/web-dashboard.md | 1 + .../docs/ko/reference/configuration/server.md | 30 +++++++++ .../docs/reference/configuration/server.md | 2 +- .../content/docs/ru/guides/web-dashboard.md | 1 + .../docs/ru/reference/configuration/server.md | 30 +++++++++ .../content/docs/tr/guides/web-dashboard.md | 1 + .../docs/tr/reference/configuration/server.md | 30 +++++++++ .../docs/zh-cn/guides/web-dashboard.md | 1 + .../zh-cn/reference/configuration/server.md | 30 +++++++++ .../docs/zh-tw/guides/web-dashboard.md | 1 + .../zh-tw/reference/configuration/server.md | 30 +++++++++ structure/telemetry.md | 9 +-- 17 files changed, 265 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 8dad87f1b94..72d9306afa5 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -81,6 +81,7 @@ force jamais d'actualisation en amont. | **Sécurité au démarrage** | Vérifiez si le routage Codex injecté résiste à un redémarrage, avec des états distincts pour le service et le lanceur intermédiaire, ainsi que les commandes de réparation exactes. | | **Zone de notification Windows** | Installez au niveau de l'utilisateur un contrôleur lancé à la connexion pour démarrer, arrêter ou redémarrer le proxy en un clic, ouvrir le tableau de bord et consulter l'état. Ce contrôleur n'est pas un service de redémarrage du proxy. | | **Démarrage automatique de Codex** | Autorisez un lanceur intermédiaire Codex déjà installé à exécuter `ocx ensure`. Ce commutateur n'installe ni lanceur ni service d'arrière-plan. | +| **Télémétrie** | Activez ou désactivez **Partager des statistiques d'utilisation anonymes** et consultez **Aperçu du JSON** dans **Détails du proxy** (`#proxy-telemetry`). Voir [Télémétrie et confidentialité](/fr/guides/telemetry/). | | **Fournisseurs** | Ajoutez, modifiez, activez, désactivez ou supprimez des fournisseurs, définissez le fournisseur par défaut parmi ceux activés et gérez, lorsqu'ils sont pris en charge, les groupes de comptes OAuth et de clés API. Si le fournisseur par défaut actuel est supprimé, le premier fournisseur activé restant prend sa place ; s'il n'en reste aucun, la suppression est refusée et le fournisseur par défaut actuel est conservé. Les réglages d'un fournisseur peuvent désactiver la découverte dynamique pour les points de terminaison dont le catalogue `/models` est absent, lent ou trop volumineux. Pour les groupes OAuth Claude (Anthropic), chaque compte connecté affiche ses propres barres de limites sur 5 heures et une semaine — l'utilisation est propre à chaque identifiant. En cas d'échec d'une sonde, les dernières barres connues sont conservées et marquées indisponibles jusqu'à la prochaine actualisation réussie. | | **Ajouter un fournisseur** | Recherchez dans les préréglages du registre une connexion par compte, un service à clé API, un serveur local ou un point de terminaison personnalisé. | | **Authentification Codex** | Ajoutez des comptes ChatGPT/Codex au groupe, sélectionnez le compte de la prochaine session, actualisez les quotas sur 5 h, une semaine et 30 jours, activez ou désactivez le changement automatique selon les quotas, réglez son seuil de 1 à 100 % et configurez le basculement en cas de défaillance transitoire. | diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 6808cfe4b49..ca79e6a556c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -31,6 +31,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | activé lorsqu'il est utilisable | Options du service auxiliaire de recherche Web. | | `visionSidecar?` | `OcxVisionSidecarConfig` | activé lorsqu'il est utilisable | Options du service auxiliaire de description d'images. | | `images?` | `OcxImagesConfig` | sélection automatique OpenAI | Options de relais d'images autonomes pour Codex `image_gen`. | +| `telemetry?` | `OcxTelemetryConfig` | activé par défaut après information d'un humain | État du consentement à la télémétrie quotidienne anonyme (`decision`, `decidedAt`, `noticeShownAt`). Rien n'est envoyé tant qu'une personne n'a pas vu l'avis ou donné son accord. Remplacé par `OCX_TELEMETRY=0` ou `DO_NOT_TRACK=1`. Voir [Télémétrie (`telemetry`)](#télémétrie-telemetry) et [Télémétrie et confidentialité](/fr/guides/telemetry/). | Si une ancienne version de développement a modifié les métadonnées de l'historique de reprise avant que la prise en charge de la sauvegarde n'existe, exécutez `ocx recover-history --legacy-openai --yes` pour forcer la récupération du fournisseur natif. @@ -155,6 +156,35 @@ La commande `ssh -L` simple écoute sur votre interface de bouclage locale et co `0.0.0.0`. Liez explicitement avec `ssh -L 127.0.0.1:20100:localhost:10100` en cas de doute. ::: +## Télémétrie (`telemetry`) + +La télémétrie d'utilisation quotidienne anonyme n'est activée par défaut qu'après qu'une personne a vu l'avis d'information (dans la fenêtre du tableau de bord ou une invite de terminal interactif). Le bloc `telemetry` dans `config.json` enregistre cet état de consentement : + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| Champ | Type | Par défaut | Signification | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | non défini (indécis) | Choix explicite de l'utilisateur. `"declined"` désactive la télémétrie et supprime `/telemetry-id.json` ; `"accepted"` active les rapports quotidiens. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | non défini | Horodatage UTC de l'enregistrement de `decision`. Les jours calendaires UTC complets antérieurs au jour UTC de `decidedAt ?? noticeShownAt` ne sont jamais envoyés rétroactivement. | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | non défini | Horodatage UTC auquel un humain a vu l'avis dans le tableau de bord web ou un terminal interactif. Lorsque `decision` n'est pas défini, la télémétrie n'envoie de rapport que si `noticeShownAt` est défini (`default-on`) ; sans lui, le proxy reste en `awaiting-notice`. | + +Le point de terminaison du collecteur (`https://telemetry.opencodex.me/v1/events`) est une constante du code et ne peut pas être configuré dans `config.json`. + +Deux variables d'environnement remplacent `config.json` et désactivent immédiatement la télémétrie sans modifier la configuration enregistrée : + +- `OCX_TELEMETRY=0` (accepte aussi `false`, `off` ou `no`, insensible à la casse) +- `DO_NOT_TRACK=1` (accepte aussi `true` ou `yes`, insensible à la casse) + +`ocx config set telemetry` et `ocx config unset telemetry` sont refusés afin que les appelants automatisés ne puissent pas enregistrer de consentement via des écritures génériques ; utilisez plutôt `ocx telemetry enable`, `ocx telemetry disable`, `ocx telemetry status [--json]` ou le commutateur **Détails du proxy** du tableau de bord. Voir [Télémétrie et confidentialité](/fr/guides/telemetry/) pour le schéma complet et la politique de conservation. + ## Nettoyage du stockage `storageCleanupPolicy` est désactivé par défaut. Lorsqu'il est activé, il s'exécute selon `startup`, `daily`, `weekly` diff --git a/docs-site/src/content/docs/guides/telemetry.md b/docs-site/src/content/docs/guides/telemetry.md index c540fb953cc..e42e09051ea 100644 --- a/docs-site/src/content/docs/guides/telemetry.md +++ b/docs-site/src/content/docs/guides/telemetry.md @@ -3,9 +3,14 @@ title: Telemetry and Privacy description: Anonymous daily usage statistics, consent and agent boundaries, opt-out controls, payload preview, and collector retention. --- -OpenCodex includes anonymous, daily usage telemetry so maintainers can see which versions, -platforms, install channels, provider kinds, and coarse model families are used in practice and -where request failures occur. +opencodex includes anonymous, daily usage telemetry so maintainers and the community can see which +versions, platforms, install channels, provider kinds, and coarse model families are used in +practice and where request failures occur. Project-wide daily aggregates (active and new install +counts, a 365-day active-install heatmap, active minutes, request outcomes, five-bucket token +totals, daily and range provider-kind and model-family shares, top-50 version counts, OS/CPU +platform and install-channel counts, and error-cause counts) are served publicly by the +collector's read-only `GET /v1/stats` endpoint for the public project dashboard; per-day install +hashes (`installDayHash`) and rate-limit hashes (`ip_hash`) are never returned by `/v1/stats`. Telemetry is **on by default**, and **nothing is sent until a person has seen the notice**. @@ -17,19 +22,22 @@ sends no telemetry. Sending becomes active only after a person has seen the disclosure or recorded a choice: -1. **Web dashboard popup (`TelemetryConsentCard`)** — opens on first run or after an update when - no decision is recorded and no environment opt-out is set. The **Share anonymous daily usage - statistics** checkbox is pre-checked. Clicking **Continue** saves `decision: "accepted"` (or +1. **Web dashboard popup (`TelemetryConsentCard`)** — opens on a standalone or Home proxy on first + run or after an update when no decision is recorded and no environment opt-out is set (connected + Remote Link Child proxies defer telemetry management to their Home proxy). The **Share anonymous + daily usage statistics** checkbox is pre-checked. Clicking **Continue** saves `decision: "accepted"` (or `"declined"` if unchecked) via `PUT /api/telemetry`. Dismissing the dialog with Escape or the backdrop records `noticeShownAt` via `POST /api/telemetry/notice` so the default-on state - applies. Merely loading the page without interacting with or dismissing the dialog records - nothing. + applies; because `decision` remains unset, the dialog will appear again on the next dashboard + load until you click **Continue** or set a choice in **Proxy details**. Merely loading the page + without interacting with or dismissing the dialog records nothing. 2. **Interactive terminal prompt** — `ocx start`, `ocx init`, and `ocx service install` ask `Share anonymous usage statistics? [Y/n]` once when stdin and stdout are interactive TTYs and no decision or notice timestamp is recorded. Pressing Enter or `y` records `decision: "accepted"`; `n` records `decision: "declined"`. 3. **Explicit enable** — turning on **Share anonymous usage statistics** in the dashboard under - **Proxy details**, or running `ocx telemetry enable` in an interactive terminal, records + **Proxy details**, or running `ocx telemetry enable [--json]` outside an agent-driven session, + records `decision: "accepted"`. `resolveTelemetryStatus` (`src/telemetry/consent.ts`) evaluates effective state in this exact @@ -51,11 +59,11 @@ disclosure on your behalf: - When `ocx start`, `ocx init`, or `ocx service install` runs under an agent or CI environment (`CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_TRACE_ID`, `CI`, and similar markers checked by - `isAgentDriven()`), in a non-TTY shell, or as a background service (`OCX_SERVICE=1`), OpenCodex + `isAgentDriven()`), in a non-TTY shell, or as a background service (`OCX_SERVICE=1`), opencodex skips the prompt, prints a one-line notice at most once per version (tracked in `/.telemetry-deferred`), and writes **neither** `noticeShownAt` nor `decision`. The install stays in `awaiting-notice` and sends nothing until you use the dashboard or an - interactive terminal yourself. + outside-agent terminal session yourself. - `PUT /api/telemetry` and `POST /api/telemetry/notice` require a live human dashboard browser session (`hasHumanDashboardSession`). Requests authenticated only with the management admin token or a remembered-device session are refused with HTTP `403` (`code: "agent_consent_required"`). @@ -66,6 +74,11 @@ disclosure on your behalf: - Opting out with `ocx telemetry disable`, `OCX_TELEMETRY=0`, or `DO_NOT_TRACK=1` is always permitted, including from scripts and agent sessions. +As with GitHub starring, these checks guard the standard CLI and management API paths against +agent harnesses (`AGENT_ENV_VARS`) and admin-token callers; they are paired with the policy rule +that agents running as your local user must never edit `config.json` or mint a loopback session to +record consent on your behalf. + ## How to turn telemetry off You can disable telemetry at any time through any of five controls: @@ -111,8 +124,8 @@ and at any time afterward: This queries `GET /api/telemetry` when the proxy is running and falls back to building the same snapshot locally from `config.json` and `usage.jsonl` when the proxy is stopped. - **Web dashboard** — click **Preview the JSON** inside the first-run/post-update consent dialog - or beside the **Share anonymous usage statistics** switch in **Proxy details** (labeled - "What is sent" in the payload summary header). + or beside the **Share anonymous usage statistics** switch in **Proxy details** to view the + **What is sent** / **Never sent** summary and the exact JSON payload. Before the first report has been sent — or after telemetry is disabled and `telemetry-id.json` is removed — no local install ID exists on disk, so `installDayHash` in the preview is shown as 64 @@ -190,22 +203,26 @@ Here is a real example payload produced by `buildTelemetryPayloadV1` (`src/telem | `env.installChannel` | `"npm-global"`, `"pnpm-global"`, `"bun-global"`, `"mise"`, `"standalone"`, `"desktop"`, `"docker"`, `"source"`, or `"unknown"`. | | `activeMinutes` | Distinct UTC minutes (`0`–`1440`) during `day` in which at least one request was logged in `usage.jsonl`. | | `requests` | Request counts by closed outcome class: `completed`, `failed`, `incomplete`, and `aborted`. | -| `models` | Up to 64 rows per day, grouped by unique `(providerKind, modelFamily)` pairs from closed rosters (`PROVIDER_KINDS` and `MODEL_FAMILIES`). Each row reports `requests` and five disjoint token counters — `inputTokens` (uncached input), `cacheReadTokens`, `cacheWriteTokens`, `outputTokens` (non-reasoning output), and `reasoningTokens` — whose sum matches `usageDisplayTotalTokens`. | +| `models` | Up to 64 rows per day, grouped by unique `(providerKind, modelFamily)` pairs from the closed rosters below (if more than 64 pairs occur in a day, the top 63 rows by requests and total tokens are kept and the remaining rows are merged into `"other"` / `"other"`). Multi-attempt `combo` requests attribute each attempt to the underlying provider and model, so `models[].requests` can sum to more than top-level `requests`. Each row reports `requests` and five disjoint token counters — `inputTokens` (uncached input), `cacheReadTokens`, `cacheWriteTokens`, `outputTokens` (non-reasoning output), and `reasoningTokens` — whose sum equals the request's displayed total tokens (`usageDisplayTotalTokens`). | | `errors` | Counts of non-completed requests keyed by the closed failure-cause roster (`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`). | +Closed `providerKind` values (26): `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`. + +Closed `modelFamily` values (19): `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`. + ## What is never collected -OpenCodex never includes any of the following in telemetry payloads: +opencodex never includes any of the following in telemetry payloads: - Prompts, completions, reasoning traces, tool arguments, or any request or response bodies. - API keys, OAuth access or refresh tokens, session cookies, or management/data-plane tokens. - Account IDs, email addresses, organization identifiers, or billing/plan details. -- User-chosen provider names or keys, custom model IDs outside the closed `MODEL_FAMILIES` - buckets (unknown models map to `"other"`), or custom `baseUrl` and proxy URLs. +- User-chosen provider names or keys, custom model IDs outside the closed `modelFamily` + buckets above (unknown models map to `"other"`), or custom `baseUrl` and proxy URLs. - Hostnames, local or upstream IP addresses, MAC addresses, hardware serials, working directories, or file paths. - Raw or free-form upstream error messages or stack traces. -- Usage from any UTC day before consent or disclosure was recorded (`decidedAt ?? noticeShownAt`). +- Usage from any UTC calendar day prior to the UTC day on which consent or disclosure was recorded (`formatUtcDay(decidedAt ?? noticeShownAt)`). (The UTC day on which consent or disclosure is recorded is aggregated across the full `00:00`–`24:00` UTC day once that day completes.) ## Storage, retention, and collector source @@ -219,9 +236,12 @@ OpenCodex never includes any of the following in telemetry payloads: runs once after startup (30–60 s jitter) and every 6 hours thereafter, completely off the request path. Each tick re-reads `config.json` from disk and sends at most **one** completed UTC day per tick (oldest unsent day first, within the last 7 completed UTC days) to - `https://telemetry.opencodex.me/v1/events` with a 10-second timeout. Days before the UTC day of - `decidedAt ?? noticeShownAt` are never sent. Sent day markers in - `/telemetry-state.json` are pruned after 30 days. + `https://telemetry.opencodex.me/v1/events` with a 10-second timeout. Earlier UTC calendar days + (`day < formatUtcDay(decidedAt ?? noticeShownAt)`) are never sent, while the full UTC calendar day + on which consent or disclosure is recorded becomes eligible once that day completes. Sent day + markers in `/telemetry-state.json` are pruned after 30 days. If the collector is + not yet deployed or is unreachable, the 10-second send attempt fails quietly in the background + without affecting proxy traffic or advancing `telemetry-state.json` (marking the day as sent). - **One report per install per day** — the collector deduplicates on `(day, install_day_hash)` in a single atomic conditional D1 batch across `daily_installs`, `daily_totals`, `daily_models`, and `daily_errors`. Re-sending the same day for the same `installDayHash` is a no-op and never @@ -233,7 +253,7 @@ OpenCodex never includes any of the following in telemetry payloads: - **Daily aggregate retention** — `daily_installs` rows (which hold only the per-day hash, version, release channel, OS, CPU architecture, install channel, `new_install`, and `active_minutes`) are pruned after 400 days; `daily_totals`, `daily_models`, and `daily_errors` hold only project-wide - daily counters. + daily counters with no per-install rows and are retained indefinitely. - **Auditable source code** — the client implementation lives in [`src/telemetry/`](https://github.com/lidge-jun/opencodex/tree/main/src/telemetry) and the Cloudflare Worker + D1 collector lives in diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 8bc23e1faae..20936124e3c 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -57,6 +57,7 @@ bun run dev:gui | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | +| **テレメトリ** | **プロキシ詳細**(`#proxy-telemetry`)で **匿名の利用統計を共有する** を切り替えたり、**JSONをプレビュー** を確認したりします。[テレメトリとプライバシー](/ja/guides/telemetry/)を参照してください。 | | **プロバイダー** | プロバイダーを追加、編集、既定に設定(有効なプロバイダーのみ)、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。現在の既定を削除すると、残っている最初の有効なプロバイダーに切り替わります(存在する場合)。なければ削除は拒否され、現在の既定は保持されます。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index e4143c5323f..0cdba59c474 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -32,6 +32,7 @@ description: リスナー、リモート アクセス、アドミッション | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` |使用可能な場合はオン | Web 検索サイドカー オプション。 | | `visionSidecar?` | `OcxVisionSidecarConfig` |使用可能な場合はオン |画像説明サイドカー オプション。 | | `images?` | `OcxImagesConfig` | OpenAI の自動選択 | Codex `image_gen` のスタンドアロン イメージ リレー オプション。 | +| `telemetry?` | `OcxTelemetryConfig` | 人間への通知後に既定でオン | 匿名の1日ごとの利用統計テレメトリの同意状態(`decision`、`decidedAt`、`noticeShownAt`)。人が通知を見るか同意するまで何も送信されません。`OCX_TELEMETRY=0` または `DO_NOT_TRACK=1` で上書きされます。[テレメトリ (`telemetry`)](#テレメトリ-telemetry) と [テレメトリとプライバシー](/ja/guides/telemetry/) を参照してください。 | バックアップ サポートが存在する前に古い開発ビルドで再開履歴メタデータが変更された場合は、`ocx recover-history --legacy-openai --yes` を実行してネイティブ プロバイダーの回復を強制します。 このコマンドは、正当な専用プロバイダー履歴を含む、ユーザーメッセージを持つすべての `opencodex` 行を再ラベル付けします。実行前にライフサイクル リファレンスの全範囲に関する警告を確認してください。 @@ -94,6 +95,35 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote プレーン `ssh -L` はローカル ループバックでリッスンし、デフォルトの非認証バインドに対して安全です。 `ssh -g -L`、ブロードコンテナパブリッシング、または `0.0.0.0` でクライアント側を公開する転送モードを使用しないでください。不明な場合は、`ssh -L 127.0.0.1:20100:localhost:10100` と明示的にバインドします。 ::: +## テレメトリ (`telemetry`) + +匿名の1日ごとの利用統計テレメトリは、人が通知(GUI ポップアップまたは対話型ターミナルのプロンプト)を見た後にのみ既定でオンになります。`config.json` の `telemetry` ブロックはこの同意状態を記録します。 + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| フィールド | 型 | 既定値 | 説明 | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | 未設定(未決定) | 明示的なユーザーの選択。`"declined"` はテレメトリを無効化し `/telemetry-id.json` を削除します。`"accepted"` は日次レポートを有効化します。 | +| `telemetry.decidedAt?` | `string` (ISO 8601) | 未設定 | `decision` が記録された UTC タイムスタンプ。`decidedAt ?? noticeShownAt` の UTC 日より前の完了済み UTC 暦日は遡って送信されません。 | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | 未設定 | ウェブダッシュボードまたは対話型ターミナルで人間が開示通知を見た UTC タイムスタンプ。`decision` が未設定の場合、`noticeShownAt` が設定されているときのみ送信され(`default-on`)、未設定なら `awaiting-notice` のまま待機します。 | + +コレクターのエンドポイント(`https://telemetry.opencodex.me/v1/events`)はコード定数であり、`config.json` では変更できません。 + +2 つの環境変数が `config.json` より優先され、保存済み設定を変更せずにテレメトリを即座に無効化します。 + +- `OCX_TELEMETRY=0`(大文字・小文字を問わず `false`、`off`、`no` も可) +- `DO_NOT_TRACK=1`(大文字・小文字を問わず `true`、`yes` も可) + +自動化ツールが汎用設定コマンドで同意を記録できないよう、`ocx config set telemetry` と `ocx config unset telemetry` は拒否されます。代わりに `ocx telemetry enable`、`ocx telemetry disable`、`ocx telemetry status [--json]`、またはダッシュボードの **プロキシ詳細** トグルを使用してください。ペイロードスキーマと保持ポリシーの詳細は [テレメトリとプライバシー](/ja/guides/telemetry/) を参照してください。 + ## ストレージのクリーンアップ `storageCleanupPolicy` はデフォルトでは無効になっています。有効にすると、アーカイブされたバイト数が `trigger.archivedBytesOver` を超えた後、`startup`、`daily`、`weekly`、または `manual` で実行されます。 `target.reduceToBytes` または `target.removeOldestPercent` のいずれかに向かって最も古いアーカイブが選択されます。 `mode` のデフォルトは `quarantine` です。 `permanent` は、明示的な破壊的な選択としてのみ使用してください。ポリシーは `lastRun` および `nextRun` を維持します。 [ストレージ] ページまたは `GET`/`PUT /api/storage/cleanup-policy` で設定します。 `POST /api/storage/cleanup-policy/run` を使用して手動実行をトリガーします。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index a96306024ca..4c5eb7dda86 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -70,6 +70,7 @@ bun run dev:gui | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | +| **텔레메트리** | **Proxy details**(`#proxy-telemetry`)에서 **익명 사용 통계 공유**를 켜거나 끄고 **JSON 미리보기**를 확인합니다. [텔레메트리 및 개인정보](/ko/guides/telemetry/)를 참고하세요. | | **Providers** | 프로바이더를 추가, 편집, 기본으로 설정(활성만), 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. 현재 기본 프로바이더를 제거하면 남아 있는 첫 번째 활성 프로바이더로 전환됩니다(있는 경우); 없으면 삭제가 거부되고 현재 기본이 유지됩니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. 프로바이더를 선택하지 않았을 때 보이는 Provider Overview에는 **전체 할당량 갱신** 버튼이 있어 설정된 모든 프로바이더를 서버에서 한 번에 다시 읽습니다. 조회에 실패한 프로바이더는 마지막 값을 유지하므로, 상태 문구는 모든 값이 새로 왔다고 말하지 않고 조회가 끝났다고만 알리며, 각 행의 확인 시각이 프로바이더별 신선도를 보여 줍니다. | | **Add provider** | 탭 위의 검색창 하나가 계정·무료·로컬·유료 네 탭을 한 번에 찾습니다. 검색 중에는 선택한 탭이 움직이지 않고, 결과를 탭별로 묶어 개수와 함께 보여 줍니다. 로컬 런타임(Ollama, vLLM, LM Studio, LiteLLM)은 별도 탭을 쓰며, 긴 설명은 두 줄로 줄고 클릭하면 전체가 열립니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index bedee7af881..241949a233f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -32,6 +32,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | 웹 검색 사이드카 옵션입니다. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | 이미지 설명 사이드카 옵션입니다. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Codex `image_gen`용 독립형 Images 릴레이 옵션입니다. | +| `telemetry?` | `OcxTelemetryConfig` | 사용자 고지 후 기본 켜짐 | 익명 일일 사용 텔레메트리 동의 상태(`decision`, `decidedAt`, `noticeShownAt`)입니다. 사람이 고지를 보거나 동의하기 전에는 아무것도 전송하지 않습니다. `OCX_TELEMETRY=0` 또는 `DO_NOT_TRACK=1`로 재정의할 수 있습니다. [Telemetry (`telemetry`)](#telemetry-telemetry) 및 [텔레메트리 및 개인정보](/ko/guides/telemetry/)를 참고하세요. | 오래된 개발 빌드가 백업 지원이 생기기 전에 resume-history 메타데이터를 바꿨다면, native-provider 복구를 강제로 수행하려면 `ocx recover-history --legacy-openai --yes`를 실행합니다. 이 명령은 정상적인 dedicated-provider history를 포함해 사용자 메시지가 있는 모든 `opencodex` row를 재태깅합니다. 실행하기 전에 lifecycle reference의 전체 범위 경고를 확인하세요. @@ -142,6 +143,35 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote 일반 `ssh -L`은 로컬 루프백에서만 수신하므로 기본 비인증 바인드에는 안전합니다. `ssh -g -L`, 광범위한 컨테이너 퍼블리싱, 또는 클라이언트 쪽을 `0.0.0.0`에 노출하는 포워딩 모드는 사용하지 마십시오. 확신이 없으면 `ssh -L 127.0.0.1:20100:localhost:10100`처럼 명시적으로 바인드합니다. ::: +## Telemetry (`telemetry`) + +익명 일일 사용 텔레메트리는 사람이 고지 안내(GUI 팝업 또는 대화형 터미널 프롬프트)를 확인한 뒤에만 기본으로 켜집니다. `config.json`의 `telemetry` 블록은 이 동의 상태를 기록합니다. + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| 키 | 형식 | 기본값 | 설명 | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | 미설정 (미결정) | 사용자의 명시적 선택입니다. `"declined"`는 텔레메트리를 끄고 `/telemetry-id.json`을 즉시 삭제하며, `"accepted"`는 일일 보고서를 활성화합니다. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | 미설정 | `decision`이 기록된 UTC 시각입니다. `decidedAt ?? noticeShownAt`의 UTC 날짜보다 이전의 완료된 UTC 달력 날짜는 소급 전송하지 않습니다. | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | 미설정 | 웹 대시보드나 대화형 터미널에서 사람이 고지를 확인한 UTC 시각입니다. `decision`이 미설정일 때는 `noticeShownAt`이 있어야만 전송되며(`default-on`), 없으면 `awaiting-notice` 상태로 유지됩니다. | + +수집 서버 엔드포인트(`https://telemetry.opencodex.me/v1/events`)는 코드 상수이며 `config.json`에서 변경할 수 없습니다. + +두 환경 변수는 저장된 `config.json`을 수정하지 않고 즉시 텔레메트리를 비활성화합니다. + +- `OCX_TELEMETRY=0` (대소문자 구분 없이 `false`, `off`, `no`도 허용) +- `DO_NOT_TRACK=1` (대소문자 구분 없이 `true`, `yes`도 허용) + +자동화된 호출자가 일반 설정 쓰기로 동의를 기록하지 못하도록 `ocx config set telemetry`와 `ocx config unset telemetry`는 거부됩니다. 대신 `ocx telemetry enable`, `ocx telemetry disable`, `ocx telemetry status [--json]` 또는 대시보드 **Proxy details** 토글을 사용하세요. 전체 페이로드 스키마와 보관 정책은 [텔레메트리 및 개인정보](/ko/guides/telemetry/)를 참고하세요. + ## Storage cleanup `storageCleanupPolicy`는 기본적으로 비활성화되어 있습니다. 활성화하면 아카이브된 바이트가 `trigger.archivedBytesOver`를 넘은 뒤 `startup`, `daily`, `weekly`, 또는 `manual` 시점에 실행됩니다. 가장 오래된 아카이브를 골라 `target.reduceToBytes` 또는 `target.removeOldestPercent` 방향으로 줄입니다. `mode`의 기본값은 `quarantine`이며, `permanent`는 명시적인 파괴적 선택으로만 사용합니다. 이 정책은 `lastRun`과 `nextRun`을 저장합니다. Storage 페이지에서 또는 `GET`/`PUT /api/storage/cleanup-policy`로 설정할 수 있으며, 수동 실행은 `POST /api/storage/cleanup-policy/run`으로 트리거합니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 5a1c6827489..9dc86670b32 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -507,7 +507,7 @@ that consent state: | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `telemetry.decision?` | `"accepted" \| "declined"` | unset (undecided) | Explicit user choice. `"declined"` disables telemetry and deletes `/telemetry-id.json`; `"accepted"` enables daily reports. | -| `telemetry.decidedAt?` | `string` (ISO 8601) | unset | UTC timestamp when `decision` was recorded. Completed UTC days before `decidedAt ?? noticeShownAt` are never backfilled. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | unset | UTC timestamp when `decision` was recorded. Completed UTC calendar days prior to the UTC day of `decidedAt ?? noticeShownAt` are never backfilled. | | `telemetry.noticeShownAt?` | `string` (ISO 8601) | unset | UTC timestamp when a human saw the disclosure in the web dashboard or an interactive terminal. When `decision` is unset, telemetry sends only if `noticeShownAt` is set (`default-on`); without it the proxy remains in `awaiting-notice`. | The collector endpoint (`https://telemetry.opencodex.me/v1/events`) is a code constant and cannot diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 284f65a5ecd..2e99b6f10bb 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -60,6 +60,7 @@ bun run dev:gui | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | +| **Телеметрия** | Переключайте **Делиться анонимной статистикой использования** и открывайте **Предпросмотр JSON** в разделе **Proxy details** (`#proxy-telemetry`). См. [Телеметрия и конфиденциальность](/ru/guides/telemetry/). | | **Providers** | Добавление, редактирование, назначение провайдера по умолчанию (только включённые), включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. При удалении текущего провайдера по умолчанию выбирается первый оставшийся включённый провайдер, если он есть; иначе удаление отклоняется и текущий default сохраняется. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. Обзор Providers, который показывается, когда провайдер не выбран, содержит кнопку **Обновить все квоты**: она принудительно перечитывает на сервере все настроенные провайдеры за один раз. Провайдер с неудачным опросом сохраняет последнее известное значение, поэтому статус сообщает лишь о завершении проверки, а не о том, что все значения свежие; время проверки в каждой строке остаётся признаком актуальности для конкретного провайдера. | | **Add provider** | Одно поле поиска над вкладками ищет сразу по всем четырём — Аккаунты, Бесплатные, Локальные, Платные. Во время поиска выбранная вкладка не переключается, а результаты группируются по вкладкам с количеством. У локальных сред выполнения (Ollama, vLLM, LM Studio, LiteLLM) своя вкладка, а длинное описание сворачивается до двух строк и открывается целиком по клику. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index a492fceeac0..6118fe5e247 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -33,6 +33,7 @@ description: Listener, удалённый доступ, admission key, тайм | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Настройки sidecar'а web-search. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Настройки sidecar'а описания изображений. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Настройки standalone Images relay для Codex `image_gen`. | +| `telemetry?` | `OcxTelemetryConfig` | включено по умолчанию после уведомления человека | Состояние согласия на ежедневную анонимную телеметрию (`decision`, `decidedAt`, `noticeShownAt`). Ничего не отправляется, пока человек не увидит уведомление или не даст согласие. Переопределяется `OCX_TELEMETRY=0` или `DO_NOT_TRACK=1`. См. [Телеметрия (`telemetry`)](#телеметрия-telemetry) и [Телеметрия и конфиденциальность](/ru/guides/telemetry/). | Если более старая development-сборка изменила metadata resume-history до появления резервного backup'а, выполните `ocx recover-history --legacy-openai --yes`, чтобы принудительно вернуть @@ -110,6 +111,35 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote `ssh -L 127.0.0.1:20100:localhost:10100`. ::: +## Телеметрия (`telemetry`) + +Ежедневная анонимная телеметрия использования включается по умолчанию только после того, как человек увидел уведомление (во всплывающем окне GUI или в интерактивном терминале). Блок `telemetry` в `config.json` хранит это состояние согласия: + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| Поле | Тип | По умолчанию | Значение | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | не задано (не решено) | Явный выбор пользователя. `"declined"` отключает телеметрию и удаляет `/telemetry-id.json`; `"accepted"` включает ежедневные отчёты. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | не задано | Временная метка UTC, когда было записано `decision`. Завершённые календарные сутки UTC, предшествующие суткам UTC `decidedAt ?? noticeShownAt`, никогда не досылаются задним числом. | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | не задано | Временная метка UTC, когда человек увидел уведомление в веб-дашборде или интерактивном терминале. Когда `decision` не задано, телеметрия отправляется только при наличии `noticeShownAt` (`default-on`); без него прокси остаётся в состоянии `awaiting-notice`. | + +Адрес коллектора (`https://telemetry.opencodex.me/v1/events`) задан константой в коде и не настраивается через `config.json`. + +Две переменные окружения переопределяют `config.json` и немедленно отключают телеметрию без изменения сохранённого файла: + +- `OCX_TELEMETRY=0` (также принимает `false`, `off` или `no` без учёта регистра) +- `DO_NOT_TRACK=1` (также принимает `true` или `yes` без учёта регистра) + +Команды `ocx config set telemetry` и `ocx config unset telemetry` отклоняются, чтобы автоматизированные клиенты не могли записать согласие через общие команды конфигурации; используйте `ocx telemetry enable`, `ocx telemetry disable`, `ocx telemetry status [--json]` или переключатель в разделе **Proxy details** веб-дашборда. Полная схема пакета и правила хранения описаны в разделе [Телеметрия и конфиденциальность](/ru/guides/telemetry/). + ## Очистка storage `storageCleanupPolicy` по умолчанию отключена. Когда её включают, она запускается на `startup`, diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index e366cd8fa5f..e59f4b74c73 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -82,6 +82,7 @@ zaman yukarı akışta yenilemeye zorlamaz. | **Başlangıç güvenliği** | Ayrı servis ve başlatıcı dolgu sağlığı artı tam onarım komutlarıyla enjekte edilen Codex yönlendirmesinin yeniden başlatmada hayatta kalıp kalmadığını gösterin. | | **Windows sistem tepsisi** | Tek tıklamayla proxy başlatma, durdurma, yeniden başlatma, kontrol paneli erişimi ve durum için kullanıcı başına bir oturum açma tepsisi yükleyin. Tepsi bir kontrolördür, bir proxy yeniden başlatma servisi değildir. | | **Codex otomatik başlatma** | Zaten kurulu bir Codex başlatıcı dolgusunun `ocx ensure` çalıştırmasına izin verin. Bu anahtar bir dolgu veya arka plan servisi kurmaz. | +| **Telemetri** | **Proxy ayrıntıları** (`#proxy-telemetry`) altında **Anonim kullanım istatistiklerini paylaş** anahtarını değiştirin ve **JSON önizlemesi** görünümünü inceleyin. Bkz. [Telemetri ve Gizlilik](/tr/guides/telemetry/). | | **Sağlayıcılar** | Sağlayıcıları ekleyin, düzenleyin, varsayılanı ayarlayın (yalnızca etkin sağlayıcılar), etkinleştirin/devre dışı bırakın ve kaldırın; desteklendiğinde OAuth hesap havuzlarını ve API anahtarı havuzlarını yönetin. Geçerli varsayılanı kaldırmak, bir tane varsa kalan ilk etkin sağlayıcıya geçer; aksi takdirde silme reddedilir ve geçerli varsayılan tutulur. Sağlayıcı Ayarları, eksik, yavaş veya aşırı büyük `/models` kataloglarına sahip uç noktalar için canlı model keşfini devre dışı bırakabilir. Claude (Anthropic) OAuth havuzları için oturum açmış her hesap kendi 5 saatlik ve haftalık hız sınırı çubuklarını gösterir (kullanım kimlik bilgisi başınadır); başarısız bir araştırma bilinen son çubukları tutar ve bir sonraki başarılı yenilemeye kadar bunları kullanılamaz olarak işaretler. | | **Sağlayıcı ekle** | Hesap girişi, API anahtarı hizmetleri, yerel sunucular veya özel bir uç nokta için kayıt defteri destekli önayarları arayın. | | **Codex Auth** | ChatGPT/Codex havuz hesapları ekleyin, sonraki oturum hesabını seçin, 5 saatlik / haftalık / 30 günlük kotaları yenileyin, kota otomatik geçişini etkinleştirin veya devre dışı bırakın, %1–100 eşiğini ayarlayın ve geçici arıza yük devretmesini yapılandırın. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 4a20ef617ad..7923d7516c1 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -32,6 +32,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | kullanılabilir olduğunda açık | Web arama sidecar seçenekleri. | | `visionSidecar?` | `OcxVisionSidecarConfig` | kullanılabilir olduğunda açık | Görsel açıklama sidecar seçenekleri. | | `images?` | `OcxImagesConfig` | otomatik OpenAI seçimi | Codex `image_gen` için bağımsız Görseller aktarma seçenekleri. | +| `telemetry?` | `OcxTelemetryConfig` | insan bildiriminden sonra varsayılan olarak açık | Anonim günlük kullanım telemetrisi onay durumu (`decision`, `decidedAt`, `noticeShownAt`). Bir kişi bildirimi görene veya onay verene kadar hiçbir şey gönderilmez. `OCX_TELEMETRY=0` veya `DO_NOT_TRACK=1` ile geçersiz kılınır. Bkz. [Telemetri (`telemetry`)](#telemetri-telemetry) ve [Telemetri ve Gizlilik](/tr/guides/telemetry/). | Daha eski bir geliştirme derlemesi yedekleme desteği var olmadan önce devam geçmişi meta verilerini değiştirdiyse yerel sağlayıcı kurtarmasını zorlamak için @@ -170,6 +171,35 @@ tarafını `0.0.0.0` üzerinde açığa çıkaran yönlendirme modlarını kulla Emin olmadığınızda `ssh -L 127.0.0.1:20100:localhost:10100` ile açıkça bağlayın. ::: +## Telemetri (`telemetry`) + +Anonim günlük kullanım telemetrisi yalnızca bir kişi açıklama bildirimini (GUI açılır penceresinde veya etkileşimli bir terminal isteminde) gördükten sonra varsayılan olarak açık hale gelir. `config.json` içindeki `telemetry` bloğu bu onay durumunu kaydeder: + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| Alan | Tip | Varsayılan | Anlamı | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | ayarlanmamış (kararsız) | Açık kullanıcı seçimi. `"declined"` telemetriyi devre dışı bırakır ve `/telemetry-id.json` dosyasını siler; `"accepted"` günlük raporları etkinleştirir. | +| `telemetry.decidedAt?` | `string` (ISO 8601) | ayarlanmamış | `decision` değerinin kaydedildiği UTC zaman damgası. `decidedAt ?? noticeShownAt` UTC gününden önceki tamamlanmış UTC takvim günleri geriye dönük olarak gönderilmez. | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | ayarlanmamış | Bir insanın web kontrol panelinde veya etkileşimli bir terminalde bildirimi gördüğü UTC zaman damgası. `decision` ayarlanmadığında telemetri yalnızca `noticeShownAt` ayarlanmışsa (`default-on`) gönderim yapar; aksi halde proxy `awaiting-notice` durumunda kalır. | + +Toplayıcı uç noktası (`https://telemetry.opencodex.me/v1/events`) bir kod sabitidir ve `config.json` içinde yapılandırılamaz. + +İki ortam değişkeni `config.json` dosyasını geçersiz kılar ve kaydedilmiş yapılandırmayı değiştirmeden telemetriyi hemen devre dışı bırakır: + +- `OCX_TELEMETRY=0` (büyük/küçük harf duyarsız olarak `false`, `off` veya `no` da kabul eder) +- `DO_NOT_TRACK=1` (büyük/küçük harf duyarsız olarak `true` veya `yes` de kabul eder) + +Otomatik arayanların genel yapılandırma yazmalarıyla onay kaydedememesi için `ocx config set telemetry` ve `ocx config unset telemetry` reddedilir; bunun yerine `ocx telemetry enable`, `ocx telemetry disable`, `ocx telemetry status [--json]` veya kontrol panelindeki **Proxy ayrıntıları** anahtarını kullanın. Tam yük şeması ve saklama politikası için [Telemetri ve Gizlilik](/tr/guides/telemetry/) sayfasına bakın. + ## Depolama temizliği `storageCleanupPolicy` varsayılan olarak devre dışıdır. Etkinleştirildiğinde diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 9767c159b8d..e0e95b8ef4a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -57,6 +57,7 @@ bun run dev:gui | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | +| **遥测** | 在 **Proxy details**(`#proxy-telemetry`)下切换**共享匿名使用统计**并查看**预览 JSON**。参见[遥测与隐私](/zh-cn/guides/telemetry/)。 | | **Providers** | 添加、编辑、设为默认(仅已启用)、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。删除当前默认时,会切换到剩余的第一个已启用 provider(若存在);否则拒绝删除并保留当前默认。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 未选中任何 provider 时显示的 Providers 概览带有**刷新全部额度**按钮,会在服务端一次性重新读取所有已配置的 provider;上游探测失败的 provider 会保留上次已知数值,因此状态文案只表示检查已完成,而不声称每个数值都是最新的,各行自身的检查时间仍是该 provider 的新鲜度信号。 | | **Add provider** | 标签页上方的单个搜索框可同时搜索账号、免费、本地、付费四个标签页。搜索时选中的标签页不会跳转,结果按标签页分组并显示数量。本地运行时(Ollama、vLLM、LM Studio、LiteLLM)拥有独立标签页,过长的说明会截断为两行,点击即可查看全文。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index edc4a3a55ac..3ffaabc4ea5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -33,6 +33,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 在可用时启用 | Web 搜索侧车选项。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 在可用时启用 | 图像描述侧车选项。 | | `images?` | `OcxImagesConfig` | 自动选择 OpenAI | 用于 Codex `image_gen` 的独立 Images 转发选项。 | +| `telemetry?` | `OcxTelemetryConfig` | 人工知悉提示后默认开启 | 匿名每日使用遥测的同意状态(`decision`、`decidedAt`、`noticeShownAt`)。在人工看到提示或主动同意之前不会发送任何数据。可被 `OCX_TELEMETRY=0` 或 `DO_NOT_TRACK=1` 覆盖。参见[遥测 (`telemetry`)](#遥测-telemetry)与[遥测与隐私](/zh-cn/guides/telemetry/)。 | 如果较旧的开发版本在尚未提供备份支持之前修改过了 resume-history 元数据,请运行 `ocx recover-history --legacy-openai --yes` 强制使用原生提供方恢复。 @@ -100,6 +101,35 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote 普通 `ssh -L` 会监听在你本地的回环地址上,适用于默认的未认证绑定。不要使用 `ssh -g -L`、宽泛的容器端口发布,或会把客户端侧暴露到 `0.0.0.0` 的转发模式。在不确定时,请显式绑定为 `ssh -L 127.0.0.1:20100:localhost:10100`。 ::: +## 遥测 (`telemetry`) + +匿名每日使用遥测仅在用户看到告知提示(通过 GUI 弹窗或交互式终端提示)后才默认开启。`config.json` 中的 `telemetry` 块记录该同意状态: + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| 键 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | 未设置(未决定) | 用户的明确选择。`"declined"` 会禁用遥测并立即删除 `/telemetry-id.json`;`"accepted"` 会启用每日报告。 | +| `telemetry.decidedAt?` | `string` (ISO 8601) | 未设置 | 记录 `decision` 的 UTC 时间戳。早于 `decidedAt ?? noticeShownAt` 所在 UTC 日期的已完成 UTC 日历日绝不会补发。 | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | 未设置 | 人类用户在网页控制台或交互式终端中看到告知提示的 UTC 时间戳。当 `decision` 未设置时,仅在已设置 `noticeShownAt` 时才会发送遥测(`default-on`);否则代理保持在 `awaiting-notice` 状态。 | + +收集器端点(`https://telemetry.opencodex.me/v1/events`)是代码常量,无法在 `config.json` 中配置。 + +两个环境变量会覆盖 `config.json` 并立即禁用遥测,且不会修改已保存的配置文件: + +- `OCX_TELEMETRY=0`(不区分大小写,也接受 `false`、`off` 或 `no`) +- `DO_NOT_TRACK=1`(不区分大小写,也接受 `true` 或 `yes`) + +`ocx config set telemetry` 和 `ocx config unset telemetry` 会被拒绝,以防自动化调用方通过通用配置写入记录同意;请改用 `ocx telemetry enable`、`ocx telemetry disable`、`ocx telemetry status [--json]` 或控制台 **Proxy details** 中的开关。完整载荷结构与保留策略请参见[遥测与隐私](/zh-cn/guides/telemetry/)。 + ## 存储清理 `storageCleanupPolicy` 默认禁用。启用后,它会在 `startup`、`daily`、`weekly` 或 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index f175c90e21e..11cc6180a89 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -67,6 +67,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **啟動安全** | 顯示注入的 Codex 路由能否在重啟後繼續工作,並分別顯示服務、launcher shim 狀態和準確的修復命令。 | | **Windows 托盤** | 安裝使用者登入托盤,一鍵控制代理啟動、停止、重啟、面板和狀態。托盤不是代理重啟服務。 | | **Codex 自動啟動** | 允許已安裝的 Codex launcher shim 執行 `ocx ensure`。此開關不會安裝 shim 或後臺服務。 | +| **遙測** | 在 **Proxy details**(`#proxy-telemetry`)下切換**共享匿名使用統計**並檢視**預覽 JSON**。請參閱[遙測與隱私](/zh-tw/guides/telemetry/)。 | | **Providers** | 新增、編輯、啟用/停用、刪除 provider,並在支援時管理 OAuth 帳號池和 API key 池。額度重新整理會略過暫停的 OAuth 帳號與停用的 API key;這些被略過的列即使沒有快取讀值,仍會顯示不可用,但不影響重新整理結果。當未暫停的 OAuth 帳號或已啟用且支援額度讀取的 API key 讀值不可用時,重新整理會回報失敗。 | | **Add provider** | 分頁上方的單一搜尋框可同時搜尋帳號、免費、本機、付費四個分頁。搜尋時選取的分頁不會跳轉,結果依分頁分組並顯示數量。本機執行環境(Ollama、vLLM、LM Studio、LiteLLM)有專屬分頁,過長的說明會截斷為兩行,點擊即可查看全文。 | | **Codex Auth** | 新增 ChatGPT/Codex 池帳號,選擇下一 session 的帳號,重新整理 5h / 每週 / 30d 配額,啟用或停用配額自動切換,設定其 1–100% 閾值和臨時故障 failover。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 65dabac2f1e..654fd7746c3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -30,6 +30,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 可用時開啟 | 網頁搜尋 sidecar 選項。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 可用時開啟 | 圖片描述 sidecar 選項。 | | `images?` | `OcxImagesConfig` | 自動 OpenAI 選擇 | Codex `image_gen` 的獨立 Images 中繼選項。 | +| `telemetry?` | `OcxTelemetryConfig` | 人工知悉提示後預設開啟 | 匿名每日使用遙測的同意狀態(`decision`、`decidedAt`、`noticeShownAt`)。在人工看到提示或主動同意之前不會傳送任何資料。可被 `OCX_TELEMETRY=0` 或 `DO_NOT_TRACK=1` 覆寫。請見[遙測(`telemetry`)](#遙測telemetry)與[遙測與隱私](/zh-tw/guides/telemetry/)。 | 若較舊的開發組建在備份支援存在前變更了 resume-history 中繼資料,請執行 `ocx recover-history --legacy-openai --yes` 以強制原生供應商復原。 此命令會重新標記所有含有使用者訊息的 `opencodex` row,其中也包含正常的專用 provider 歷史;執行前請查看 lifecycle reference 中的完整範圍警告。 @@ -127,6 +128,35 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote 普通 `ssh -L` 在你的本機回送上監聽,對預設的未認證綁定是安全的。請勿使用 `ssh -g -L`、廣泛的容器發布,或將客戶端暴露在 `0.0.0.0` 上的轉發模式。不確定時請用 `ssh -L 127.0.0.1:20100:localhost:10100` 明確綁定。 ::: +## 遙測(`telemetry`) + +匿名每日使用遙測僅在使用者看到告知提示(透過 GUI 彈窗或互動式終端機提示)後才預設開啟。`config.json` 中的 `telemetry` 區塊記錄該同意狀態: + +```json +{ + "telemetry": { + "decision": "accepted", + "decidedAt": "2026-11-07T09:00:00.000Z", + "noticeShownAt": "2026-11-07T09:00:00.000Z" + } +} +``` + +| 欄位 | 型別 | 預設值 | 意義 | +| --- | --- | --- | --- | +| `telemetry.decision?` | `"accepted" \| "declined"` | 未設定(未決定) | 使用者的明確選擇。`"declined"` 會停用遙測並立即刪除 `/telemetry-id.json`;`"accepted"` 會啟用每日報告。 | +| `telemetry.decidedAt?` | `string` (ISO 8601) | 未設定 | 記錄 `decision` 的 UTC 時間戳記。早於 `decidedAt ?? noticeShownAt` 所在 UTC 日期的已完成 UTC 日曆日絕不會補送。 | +| `telemetry.noticeShownAt?` | `string` (ISO 8601) | 未設定 | 人類使用者在網頁儀表板或互動式終端機中看到告知提示的 UTC 時間戳記。當 `decision` 未設定時,僅在已設定 `noticeShownAt` 時才會傳送遙測(`default-on`);否則代理保持在 `awaiting-notice` 狀態。 | + +收集器端點(`https://telemetry.opencodex.me/v1/events`)是程式碼常數,無法在 `config.json` 中設定。 + +兩個環境變數會覆寫 `config.json` 並立即停用遙測,且不會修改已儲存的設定檔: + +- `OCX_TELEMETRY=0`(不分大小寫,也接受 `false`、`off` 或 `no`) +- `DO_NOT_TRACK=1`(不分大小寫,也接受 `true` 或 `yes`) + +`ocx config set telemetry` 與 `ocx config unset telemetry` 會被拒絕,以防自動化呼叫方透過通用設定寫入記錄同意;請改用 `ocx telemetry enable`、`ocx telemetry disable`、`ocx telemetry status [--json]` 或儀表板 **Proxy details** 中的開關。完整負載結構與保留政策請參閱[遙測與隱私](/zh-tw/guides/telemetry/)。 + ## 儲存清理 `storageCleanupPolicy` 預設停用。啟用時,它在已封存位元組超過 `trigger.archivedBytesOver` 後於 `startup`、`daily`、`weekly` 或 `manual` 執行。它朝 `target.reduceToBytes` 或 `target.removeOldestPercent` 選擇最舊的封存。`mode` 預設為 `quarantine`;僅將 `permanent` 作為明確的破壞性選擇。政策持久化 `lastRun` 與 `nextRun`。在 Storage 頁面或以 `GET`/`PUT /api/storage/cleanup-policy` 設定它;以 `POST /api/storage/cleanup-policy/run` 觸發手動執行。 diff --git a/structure/telemetry.md b/structure/telemetry.md index 65c8482e0a3..1449a1975a5 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -24,14 +24,15 @@ via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. `decision === "declined"`, `decision === "accepted"`, undecided with `noticeShownAt` (`default-on`), and undecided without notice (`awaiting-notice`). -Only a human can transition an undecided install to active sending: +Casual-path consent guards require human interaction before transitioning an undecided install to active sending: - `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. -- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in - `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always - permitted. +- `ocx telemetry enable` in `src/cli/telemetry.ts` refuses agent-driven callers (`isAgentDriven()`); + `ocx config set telemetry` and `ocx config unset telemetry` in `src/cli/config-command.ts` refuse + all callers, while agent-driven `ocx config import` strips incoming `telemetry` keys and preserves + persisted consent state. `ocx telemetry disable` is always permitted. - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. From a1198ee78d8efab60c8e126486e45d74c0332a33 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:44:17 +0900 Subject: [PATCH 13/63] feat(docs-site): add public telemetry usage dashboard at /dashboard --- docs-site/src/components/Header.astro | 10 + .../src/components/TelemetryDashboard.astro | 1476 +++++++++++++++++ .../components/telemetry-dashboard/client.ts | 589 +++++++ .../telemetry-dashboard/constants.ts | 183 ++ .../components/telemetry-dashboard/format.ts | 225 +++ .../components/telemetry-dashboard/heatmap.ts | 227 +++ .../components/telemetry-dashboard/index.ts | 6 + .../components/telemetry-dashboard/state.ts | 183 ++ .../components/telemetry-dashboard/tables.ts | 329 ++++ .../components/telemetry-dashboard/types.ts | 234 +++ docs-site/src/content/docs/dashboard.mdx | 10 + .../src/data/telemetry-dashboard-i18n.ts | 942 +++++++++++ scripts/test-layout/layout-usage.json | 3 +- .../docs-telemetry-dashboard.test.ts | 546 ++++++ tests/fixtures/test-layout-expected.json | 2 +- 15 files changed, 4963 insertions(+), 2 deletions(-) create mode 100644 docs-site/src/components/TelemetryDashboard.astro create mode 100644 docs-site/src/components/telemetry-dashboard/client.ts create mode 100644 docs-site/src/components/telemetry-dashboard/constants.ts create mode 100644 docs-site/src/components/telemetry-dashboard/format.ts create mode 100644 docs-site/src/components/telemetry-dashboard/heatmap.ts create mode 100644 docs-site/src/components/telemetry-dashboard/index.ts create mode 100644 docs-site/src/components/telemetry-dashboard/state.ts create mode 100644 docs-site/src/components/telemetry-dashboard/tables.ts create mode 100644 docs-site/src/components/telemetry-dashboard/types.ts create mode 100644 docs-site/src/content/docs/dashboard.mdx create mode 100644 docs-site/src/data/telemetry-dashboard-i18n.ts create mode 100644 tests/ci-workflows/docs-telemetry-dashboard.test.ts diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 1e6c9e2d582..1c52dac9d84 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -47,6 +47,7 @@ const fr = { 'Architecture': 'Architecture', 'Proxy API Formats': 'Formats de l’API proxy', 'Management API': 'API de gestion', + 'Dashboard': 'Tableau de bord', 'Contributing': 'Contribuer', 'Main': 'Navigation principale', 'Select language': 'Choisir la langue', @@ -145,6 +146,11 @@ const groups: NavGroup[] = [ }, ]; +const dashboard: NavLink = { + label: t('Dashboard', '대시보드', '仪表盘', 'Дашборд', 'ダッシュボード', '儀表板', 'Panel'), + href: `${prefix}dashboard/`, +}; + const contributing: NavLink = { label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート', '貢獻', 'Katkıda Bulunma'), href: `${prefix}contributing/`, @@ -178,6 +184,9 @@ const contributing: NavLink = { )) } + @@ -280,6 +289,7 @@ const contributing: NavLink = { ))} + {dashboard.label} {contributing.label} diff --git a/docs-site/src/components/TelemetryDashboard.astro b/docs-site/src/components/TelemetryDashboard.astro new file mode 100644 index 00000000000..29ba2fd7c6f --- /dev/null +++ b/docs-site/src/components/TelemetryDashboard.astro @@ -0,0 +1,1476 @@ +--- +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + getTelemetryDashboardCopy, + resolveTelemetryDashboardLocale, + TELEMETRY_DASHBOARD_STRINGS, + type TelemetryDashboardLocale, +} from "../data/telemetry-dashboard-i18n"; +import { + DEFAULT_TELEMETRY_RANGE, + GITHUB_RELEASES_URL, + GITHUB_REPO_SLUG, + NPM_DOWNLOADS_MONTHLY_URL, + NPM_DOWNLOADS_WEEKLY_URL, + NPM_DOWNLOADS_YEARLY_URL, + NPM_PACKAGE_NAME, + OUTCOME_COLORS, + TELEMETRY_GUIDE_LINK_ENABLED, + TELEMETRY_GUIDE_PATH, + TELEMETRY_RANGES, + TELEMETRY_STATS_ENDPOINT, + TOKEN_MIX_COLORS, + TOKSCALE_REPO_URL, +} from "./telemetry-dashboard/index"; + +interface Props { + locale?: TelemetryDashboardLocale; +} + +const routeLocale = Astro.locals?.starlightRoute?.locale; +const locale = resolveTelemetryDashboardLocale(Astro.props.locale ?? routeLocale); +const copy = getTelemetryDashboardCopy(locale); + +const base = import.meta.env.BASE_URL.replace(/\/?$/, "/"); +const prefix = locale === "en" ? base : `${base}${locale}/`; + +const guideFileUrl = new URL("../content/docs/guides/telemetry.md", import.meta.url); +const showGuideLink = + TELEMETRY_GUIDE_LINK_ENABLED || existsSync(fileURLToPath(guideFileUrl)); +const guideHref = `${prefix}${TELEMETRY_GUIDE_PATH}`; + +const rangeLabels: Record<(typeof TELEMETRY_RANGES)[number], string> = { + "7d": copy.range7d, + "30d": copy.range30d, + "90d": copy.range90d, + "365d": copy.range365d, +}; + +const kpiCards = [ + { id: "activeInstalls", label: copy.kpiActiveInstalls, initial: "0", title: "0" }, + { id: "newInstalls", label: copy.kpiNewInstalls, initial: "0", title: "0" }, + { id: "requests", label: copy.kpiRequests, initial: "0", title: "0" }, + { id: "tokens", label: copy.kpiTokens, initial: "0", title: "0" }, + { id: "activeHours", label: copy.kpiActiveHours, initial: "0h", title: "0h (0 min)" }, +] as const; + +const tokenBuckets = [ + { id: "input", label: copy.tokenInput, color: TOKEN_MIX_COLORS.input }, + { id: "cacheRead", label: copy.tokenCacheRead, color: TOKEN_MIX_COLORS.cacheRead }, + { id: "cacheWrite", label: copy.tokenCacheWrite, color: TOKEN_MIX_COLORS.cacheWrite }, + { id: "output", label: copy.tokenOutput, color: TOKEN_MIX_COLORS.output }, + { id: "reasoning", label: copy.tokenReasoning, color: TOKEN_MIX_COLORS.reasoning }, +] as const; + +const outcomeItems = [ + { id: "completed", label: copy.outcomeCompleted, color: OUTCOME_COLORS.completed }, + { id: "incomplete", label: copy.outcomeIncomplete, color: OUTCOME_COLORS.incomplete }, + { id: "aborted", label: copy.outcomeAborted, color: OUTCOME_COLORS.aborted }, + { id: "failed", label: copy.outcomeFailed, color: OUTCOME_COLORS.failed }, +] as const; + +const skeletonCells = Array.from({ length: 53 * 7 }, (_, idx) => idx); +--- + +
+ +
+
+
+
+

{copy.title}

+ {copy.privacyBadge} +
+

{copy.subtitle}

+
+ + {copy.updatedPrefix}: {copy.updatedPending} + + + GET /v1/stats +
+
+ +
+
+ {TELEMETRY_RANGES.map((range) => ( + + ))} +
+
+
+ +
+

+ {copy.privacyBody}{" "} + {copy.privacyOptOutHint} + {showGuideLink && ( + <> + {" "} + + {copy.privacyGuideLink} → + + + )} +

+
+ + +
+
+ +
+ {copy.stateLoadingTitle} + {copy.stateLoadingBody} +
+
+ + + + +
+ + +
+ {kpiCards.map((card) => ( +
+
{card.label}
+
+ {card.initial} +
+
+ ))} +
+
+ + +
+
+
+

+ {copy.tokenMixTitle} +

+

{copy.tokenMixSubtitle}

+
+
+ {copy.kpiTokens} + + 0 + +
+
+
+ + +
+ {tokenBuckets.map((bucket) => ( +
+
+
+
+ 0 + 0.0% +
+
+ ))} +
+
+
+ + +
+ +
+
+
+

+ {copy.heatmapTitle} +

+

{copy.heatmapSubtitle}

+
+
+ + {copy.heatmapActiveDays.replace("{count}", "0")} + + +
+
+ +
+
+ +
+ +
+ {skeletonCells.map(() => ( +
+
+
+ + + +
+ + 365d UTC + +
+ {copy.heatmapLess} +
+ {([0, 1, 2, 3, 4] as const).map((grade) => ( +
+ {copy.heatmapMore} +
+
+

+ {copy.heatmapKeyboardHint} +

+
+
+ + +
+
+
+

+ {copy.dailyChartTitle} +

+

{copy.dailyChartSubtitle}

+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+

+ {copy.providersTitle} +

+

{copy.providersSubtitle}

+
+
+
+ + + + + + + + + + + + + + +
{copy.colProvider}{copy.colRequests}{copy.colTokens}{copy.colShare}
{copy.noDataInTable}
+
+
+ +
+
+
+

+ {copy.modelsTitle} +

+

{copy.modelsSubtitle}

+
+
+
+ + + + + + + + + + + + + + +
{copy.colModel}{copy.colRequests}{copy.colTokens}{copy.colShare}
{copy.noDataInTable}
+
+
+
+ + +
+
+
+
+

+ {copy.versionsTitle} +

+

{copy.versionsSubtitle}

+
+
+
+ + + + + + + + + + + + + + +
{copy.colVersion}{copy.colInstalls}{copy.colNewInstalls}{copy.colInstallShare}
{copy.noDataInTable}
+
+
+ +
+
+
+

+ {copy.platformsTitle} +

+

{copy.platformsSubtitle}

+
+
+
+ + + + + + + + + + + + + +
{copy.colPlatform}{copy.colInstalls}{copy.colInstallShare}
{copy.noDataInTable}
+
+
+ +
+
+
+

+ {copy.channelsTitle} +

+

{copy.channelsSubtitle}

+
+
+
+ + + + + + + + + + + + + + +
{copy.colChannel}{copy.colInstalls}{copy.colNewInstalls}{copy.colInstallShare}
{copy.noDataInTable}
+
+
+
+ + +
+
+
+
+

+ {copy.reliabilityTitle} +

+

{copy.reliabilitySubtitle}

+
+
+ {copy.completionRateLabel} + + 0.0% + +
+
+ +
+ + +
+ {outcomeItems.map((item) => ( +
+
+
+
+ 0 + 0.0% +
+
+ ))} +
+ +

{copy.topErrorsTitle}

+
+ + + + + + + + + + + + + +
{copy.colErrorCause}{copy.colErrorCount}{copy.colInstallShare}
{copy.noErrorsRecorded}
+
+
+
+ +
+
+
+

+ {copy.downloadsTitle} +

+

{copy.downloadsSubtitle}

+
+
+ +
+
+
+
{copy.npmWeeklyLabel}
+
+ — +
+ {NPM_PACKAGE_NAME} +
+
+
{copy.npmMonthlyLabel}
+
+ — +
+ {NPM_PACKAGE_NAME} +
+
+
{copy.npmYearlyLabel}
+
+ — +
+ {NPM_PACKAGE_NAME} +
+
+
{copy.githubTotalLabel}
+
+ — +
+ {GITHUB_REPO_SLUG} +
+
+
+ {copy.githubLatestLabel}{" "} + +
+
+ — +
+ +
+
+
+
+
+ + + +
+ + + + diff --git a/docs-site/src/components/telemetry-dashboard/client.ts b/docs-site/src/components/telemetry-dashboard/client.ts new file mode 100644 index 00000000000..14ea0295a4d --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/client.ts @@ -0,0 +1,589 @@ +import * as echarts from "echarts"; +import type { TelemetryDashboardCopy } from "../../data/telemetry-dashboard-i18n"; +import { + buildChannelRows, + buildDailyProviderChart, + buildHeatmapCalendar, + buildKpiStrip, + buildModelShareRows, + buildPlatformRows, + buildProviderShareRows, + buildReliabilitySummary, + buildStatsUrl, + buildTokenMix, + buildVersionRows, + classifyStatsResponse, + formatCompactNumber, + formatExactNumber, + formatGeneratedTimestamp, + formatHeatmapCellAriaLabel, + navigateHeatmapDate, + parseDownloadsPayload, + parseStatsRangeParam, + type DailyChartMetric, + type HeatmapCell, + type HeatmapNavigationKey, + type TelemetryStatsRange, + type TelemetryStatsV1, +} from "./index"; + +export function mountTelemetryDashboard( + root: HTMLElement | null = document.querySelector("[data-telemetry-dashboard]"), +): void { + if (!root) return; + + const locale = root.dataset.locale ?? "en"; + const statsEndpoint = + root.dataset.statsEndpoint ?? "https://telemetry.opencodex.me/v1/stats"; + const copy = JSON.parse(root.dataset.copy ?? "{}") as TelemetryDashboardCopy; + + let activeRange: TelemetryStatsRange = parseStatsRangeParam( + new URLSearchParams(window.location.search).get("range") ?? + root.dataset.defaultRange, + ); + let activeChartMetric: DailyChartMetric = "tokens"; + let latestStats: TelemetryStatsV1 | null = null; + let chartInstance: echarts.ECharts | null = null; + let currentHeatmapCells: HeatmapCell[] = []; + let focusedHeatmapDate: string | null = null; + + const setDashboardState = ( + state: "loading" | "ready" | "empty" | "error", + detail?: string, + ) => { + root.dataset.dashboardState = state; + for (const panel of root.querySelectorAll("[data-state-panel]")) { + panel.hidden = panel.dataset.statePanel !== state; + } + if (state === "error" && detail) { + const errBody = root.querySelector("[data-error-body]"); + if (errBody) { + errBody.textContent = `${copy.stateErrorBody} (${detail})`; + } + } + }; + + const syncRangePills = () => { + for (const btn of root.querySelectorAll("[data-range-pill]")) { + const isCurrent = btn.dataset.rangePill === activeRange; + btn.setAttribute("aria-pressed", isCurrent ? "true" : "false"); + } + }; + + const syncChartMetricPills = () => { + for (const btn of root.querySelectorAll("[data-chart-metric]")) { + const isCurrent = btn.dataset.chartMetric === activeChartMetric; + btn.setAttribute("aria-pressed", isCurrent ? "true" : "false"); + } + }; + + const renderDailyChart = () => { + const chartEl = root.querySelector("[data-daily-chart]"); + if (!chartEl) return; + const textColor = getComputedStyle(document.body).color; + const borderColor = + getComputedStyle(document.documentElement) + .getPropertyValue("--sl-color-gray-5") + .trim() || "rgba(128,128,128,0.25)"; + + const model = buildDailyProviderChart( + latestStats?.dailyByProvider, + latestStats?.daily, + activeChartMetric, + ); + if (!chartInstance) { + chartInstance = echarts.init(chartEl); + } + chartInstance.setOption( + { + textStyle: { color: textColor }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + }, + legend: { + top: 0, + textStyle: { color: textColor, fontSize: 11 }, + }, + grid: { left: 52, right: 16, top: 34, bottom: 28 }, + xAxis: { + type: "category", + data: model.days, + axisLabel: { color: textColor, fontSize: 11 }, + axisLine: { lineStyle: { color: borderColor } }, + }, + yAxis: { + type: "value", + axisLabel: { + color: textColor, + fontSize: 11, + formatter: (val: number) => formatCompactNumber(val), + }, + splitLine: { lineStyle: { color: borderColor } }, + }, + series: model.series.map((s) => ({ + name: s.providerKind, + type: "bar", + stack: "daily", + itemStyle: { color: s.color }, + emphasis: { focus: "series" }, + data: s.values, + })), + }, + { notMerge: true }, + ); + }; + + const renderHeatmap = (stats: TelemetryStatsV1 | null) => { + const gridEl = root.querySelector("[data-heatmap-grid]"); + const monthsEl = root.querySelector("[data-heatmap-months]"); + const activeDaysEl = root.querySelector("[data-heatmap-active-days]"); + const peakEl = root.querySelector("[data-heatmap-peak]"); + const rangeLabelEl = root.querySelector("[data-heatmap-range-label]"); + const tooltipEl = root.querySelector("[data-heatmap-tooltip]"); + if (!gridEl || !monthsEl) return; + + const calendar = buildHeatmapCalendar(stats?.heatmap); + currentHeatmapCells = calendar.cells; + + if (activeDaysEl) { + activeDaysEl.textContent = copy.heatmapActiveDays.replace( + "{count}", + formatExactNumber(calendar.activeDays, locale), + ); + } + if (peakEl) { + if (calendar.peakDay) { + peakEl.hidden = false; + peakEl.textContent = copy.heatmapPeakDay + .replace("{date}", calendar.peakDay.date) + .replace("{count}", formatExactNumber(calendar.peakDay.activeInstalls, locale)); + } else { + peakEl.hidden = true; + } + } + if (rangeLabelEl && calendar.startDate && calendar.endDate) { + rangeLabelEl.textContent = `${calendar.startDate} → ${calendar.endDate} (UTC)`; + } + + if (calendar.weekCount === 0) return; + + const weeks = String(calendar.weekCount); + gridEl.style.setProperty("--heatmap-weeks", weeks); + monthsEl.style.setProperty("--heatmap-weeks", weeks); + + monthsEl.replaceChildren( + ...calendar.monthMarkers.map((m) => { + const span = document.createElement("span"); + span.style.gridColumnStart = String(m.weekIndex + 1); + span.textContent = m.label; + return span; + }), + ); + + const selectableCells = calendar.cells.filter((c) => c.selectable); + focusedHeatmapDate = + selectableCells[selectableCells.length - 1]?.date ?? null; + + const showCellTooltip = (cell: HeatmapCell) => { + if (!tooltipEl || !cell.selectable) return; + tooltipEl.hidden = false; + tooltipEl.textContent = `${cell.date} · ${formatExactNumber(cell.activeInstalls, locale)} ${copy.heatmapTooltipUnit}`; + }; + + const hideCellTooltip = () => { + if (tooltipEl) tooltipEl.hidden = true; + }; + + const buttons = calendar.cells.map((cell) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "td-heatmap-cell"; + btn.dataset.grade = String(cell.grade); + if (cell.selectable) { + btn.dataset.date = cell.date; + btn.tabIndex = cell.date === focusedHeatmapDate ? 0 : -1; + btn.setAttribute("aria-label", formatHeatmapCellAriaLabel(cell, locale)); + btn.addEventListener("mouseenter", () => showCellTooltip(cell)); + btn.addEventListener("mouseleave", hideCellTooltip); + btn.addEventListener("focus", () => { + focusedHeatmapDate = cell.date; + showCellTooltip(cell); + }); + btn.addEventListener("blur", hideCellTooltip); + btn.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + hideCellTooltip(); + return; + } + if ( + ![ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + ].includes(event.key) + ) { + return; + } + event.preventDefault(); + const nextDate = navigateHeatmapDate( + currentHeatmapCells, + cell.date, + event.key as HeatmapNavigationKey, + ); + if (!nextDate) return; + focusedHeatmapDate = nextDate; + for (const b of gridEl.querySelectorAll("button[data-date]")) { + const isTarget = b.dataset.date === nextDate; + b.tabIndex = isTarget ? 0 : -1; + if (isTarget) b.focus(); + } + }); + } else { + btn.disabled = true; + btn.tabIndex = -1; + btn.setAttribute("aria-hidden", "true"); + } + return btn; + }); + + gridEl.replaceChildren(...buttons); + }; + + const makeEmptyRow = (colSpan: number, message: string): HTMLTableRowElement => { + const tr = document.createElement("tr"); + tr.className = "td-empty-row"; + const td = document.createElement("td"); + td.colSpan = colSpan; + td.textContent = message; + tr.appendChild(td); + return tr; + }; + + const renderShareTable = ( + tbodyName: "providers" | "models", + rows: ReturnType, + ) => { + const tbody = root.querySelector( + `[data-tbody="${tbodyName}"]`, + ); + if (!tbody) return; + if (rows.length === 0) { + tbody.replaceChildren(makeEmptyRow(4, copy.noDataInTable)); + return; + } + tbody.replaceChildren( + ...rows.map((row) => { + const tr = document.createElement("tr"); + + const tdName = document.createElement("td"); + const identity = document.createElement("span"); + identity.className = "td-row-identity"; + const dot = document.createElement("span"); + dot.className = "td-row-dot"; + dot.style.backgroundColor = row.color; + dot.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.textContent = row.label; + identity.append(dot, label); + tdName.appendChild(identity); + + const tdReq = document.createElement("td"); + tdReq.className = "td-num"; + tdReq.title = row.requestsExact; + tdReq.textContent = row.requestsCompact; + + const tdTok = document.createElement("td"); + tdTok.className = "td-num"; + tdTok.title = row.tokensExact; + tdTok.textContent = row.tokensCompact; + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = row.tokenShareLabel; + + tr.append(tdName, tdReq, tdTok, tdShare); + return tr; + }), + ); + }; + + const renderInstallTable = ( + tbodyName: "versions" | "platforms" | "channels", + rows: ReturnType, + includeNewInstalls: boolean, + ) => { + const tbody = root.querySelector( + `[data-tbody="${tbodyName}"]`, + ); + if (!tbody) return; + const cols = includeNewInstalls ? 4 : 3; + if (rows.length === 0) { + tbody.replaceChildren(makeEmptyRow(cols, copy.noDataInTable)); + return; + } + tbody.replaceChildren( + ...rows.map((row) => { + const tr = document.createElement("tr"); + const tdLabel = document.createElement("td"); + tdLabel.className = "td-mono"; + tdLabel.textContent = row.label; + + const tdInstalls = document.createElement("td"); + tdInstalls.className = "td-num"; + tdInstalls.title = row.installsExact; + tdInstalls.textContent = row.installsCompact; + + tr.append(tdLabel, tdInstalls); + + if (includeNewInstalls) { + const tdNew = document.createElement("td"); + tdNew.className = "td-num"; + tdNew.title = row.newInstallsExact ?? "0"; + tdNew.textContent = row.newInstallsCompact ?? "0"; + tr.appendChild(tdNew); + } + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = row.shareLabel; + tr.appendChild(tdShare); + return tr; + }), + ); + }; + + const applyStatsSnapshot = (stats: TelemetryStatsV1) => { + latestStats = stats; + + const updatedEl = root.querySelector("[data-updated-label]"); + const formattedTime = formatGeneratedTimestamp(stats.generatedAt, locale); + if (updatedEl && formattedTime) { + updatedEl.textContent = `${copy.updatedPrefix}: ${formattedTime}`; + } + + for (const kpi of buildKpiStrip(stats, locale)) { + const el = root.querySelector(`[data-kpi-value="${kpi.id}"]`); + if (el) { + el.textContent = kpi.compact; + el.title = kpi.exactTitle; + } + } + + const tokenMix = buildTokenMix(stats.totals.tokens, locale); + const totalMixEl = root.querySelector("[data-token-mix-total]"); + if (totalMixEl) { + totalMixEl.textContent = tokenMix.totalCompact; + totalMixEl.title = tokenMix.totalExactTitle; + } + for (const seg of tokenMix.segments) { + const barSeg = root.querySelector( + `[data-token-segment="${seg.id}"]`, + ); + if (barSeg) { + barSeg.style.flex = `${Math.max(seg.value, 0)} 1 0`; + barSeg.style.minWidth = seg.value > 0 ? "3px" : "0"; + barSeg.title = `${seg.id}: ${seg.exactTitle} (${seg.percentLabel})`; + } + const valEl = root.querySelector(`[data-token-value="${seg.id}"]`); + if (valEl) { + valEl.textContent = seg.compact; + valEl.title = seg.exactTitle; + } + const pctEl = root.querySelector(`[data-token-pct="${seg.id}"]`); + if (pctEl) { + pctEl.textContent = seg.percentLabel; + } + } + + renderHeatmap(stats); + renderDailyChart(); + + renderShareTable("providers", buildProviderShareRows(stats.providers, locale)); + renderShareTable("models", buildModelShareRows(stats.models, locale)); + + renderInstallTable("versions", buildVersionRows(stats.versions, locale), true); + renderInstallTable("platforms", buildPlatformRows(stats.platforms, locale), false); + renderInstallTable("channels", buildChannelRows(stats.channels, locale), true); + + const reliability = buildReliabilitySummary(stats.outcomes, stats.errors, locale); + const rateEl = root.querySelector("[data-completion-rate]"); + if (rateEl) { + rateEl.textContent = reliability.completedRatioLabel; + } + for (const out of reliability.outcomes) { + const segEl = root.querySelector( + `[data-outcome-segment="${out.key}"]`, + ); + if (segEl) { + segEl.style.flex = `${Math.max(out.count, 0)} 1 0`; + segEl.style.minWidth = out.count > 0 ? "3px" : "0"; + segEl.title = `${out.key}: ${out.exact} (${out.shareLabel})`; + } + const cntEl = root.querySelector( + `[data-outcome-count="${out.key}"]`, + ); + if (cntEl) { + cntEl.textContent = out.compact; + cntEl.title = out.exact; + } + const pctEl = root.querySelector( + `[data-outcome-pct="${out.key}"]`, + ); + if (pctEl) { + pctEl.textContent = out.shareLabel; + } + } + + const errorsTbody = root.querySelector( + '[data-tbody="errors"]', + ); + if (errorsTbody) { + if (reliability.errors.length === 0) { + errorsTbody.replaceChildren(makeEmptyRow(3, copy.noErrorsRecorded)); + } else { + errorsTbody.replaceChildren( + ...reliability.errors.map((err) => { + const tr = document.createElement("tr"); + const tdCause = document.createElement("td"); + tdCause.className = "td-mono"; + tdCause.textContent = err.cause; + + const tdCount = document.createElement("td"); + tdCount.className = "td-num"; + tdCount.title = err.exact; + tdCount.textContent = err.compact; + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = err.shareLabel; + + tr.append(tdCause, tdCount, tdShare); + return tr; + }), + ); + } + } + }; + + const loadTelemetryStats = async (range: TelemetryStatsRange) => { + activeRange = range; + syncRangePills(); + setDashboardState("loading"); + try { + const url = buildStatsUrl(range, statsEndpoint); + const response = await fetch(url, { + headers: { accept: "application/json" }, + }); + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + const classified = classifyStatsResponse({ + ok: response.ok, + status: response.status, + body, + }); + if (classified.stats) { + applyStatsSnapshot(classified.stats); + } + setDashboardState(classified.state, classified.detail); + } catch (error) { + const classified = classifyStatsResponse({ + networkError: true, + errorMessage: error instanceof Error ? error.message : String(error), + }); + setDashboardState(classified.state, classified.detail); + } + }; + + const loadDownloads = async () => { + const fetchJsonSafe = async (url: string | undefined): Promise => { + if (!url) return null; + try { + const res = await fetch(url, { headers: { accept: "application/json" } }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } + }; + + const [npmWeekly, npmMonthly, npmYearly, githubReleases] = await Promise.all([ + fetchJsonSafe(root.dataset.npmWeeklyUrl), + fetchJsonSafe(root.dataset.npmMonthlyUrl), + fetchJsonSafe(root.dataset.npmYearlyUrl), + fetchJsonSafe(root.dataset.githubReleasesUrl), + ]); + + const dl = parseDownloadsPayload({ + npmWeekly, + npmMonthly, + npmYearly, + githubReleases, + }); + + const setDlValue = (selector: string, value: number | null) => { + const el = root.querySelector(selector); + if (!el) return; + if (value === null) { + el.textContent = "—"; + el.title = copy.downloadsUnavailable; + } else { + el.textContent = formatCompactNumber(value); + el.title = formatExactNumber(value, locale); + } + }; + + setDlValue("[data-dl-npm-weekly]", dl.npmWeekly); + setDlValue("[data-dl-npm-monthly]", dl.npmMonthly); + setDlValue("[data-dl-npm-yearly]", dl.npmYearly); + setDlValue("[data-dl-gh-total]", dl.githubTotalDownloads); + setDlValue("[data-dl-gh-latest]", dl.githubLatestDownloads); + + const latestTagEl = root.querySelector("[data-dl-gh-latest-tag]"); + if (latestTagEl && dl.githubLatestTag) { + latestTagEl.textContent = `(${dl.githubLatestTag})`; + } + }; + + for (const btn of root.querySelectorAll("[data-range-pill]")) { + btn.addEventListener("click", () => { + const nextRange = parseStatsRangeParam(btn.dataset.rangePill); + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set("range", nextRange); + window.history.replaceState(null, "", nextUrl.toString()); + void loadTelemetryStats(nextRange); + }); + } + + for (const btn of root.querySelectorAll("[data-chart-metric]")) { + btn.addEventListener("click", () => { + activeChartMetric = + btn.dataset.chartMetric === "requests" ? "requests" : "tokens"; + syncChartMetricPills(); + renderDailyChart(); + }); + } + + const retryBtn = root.querySelector("[data-retry-btn]"); + retryBtn?.addEventListener("click", () => { + void loadTelemetryStats(activeRange); + }); + + window.addEventListener("resize", () => chartInstance?.resize()); + new MutationObserver(() => renderDailyChart()).observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + renderDailyChart(); + void loadTelemetryStats(activeRange); + void loadDownloads(); +} diff --git a/docs-site/src/components/telemetry-dashboard/constants.ts b/docs-site/src/components/telemetry-dashboard/constants.ts new file mode 100644 index 00000000000..765cfb4b057 --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/constants.ts @@ -0,0 +1,183 @@ +import type { + HeatmapGrade, + OutcomeClassId, + TelemetryStatsRange, + TokenBucketId, +} from "./types"; + +export const TELEMETRY_STATS_ENDPOINT = "https://telemetry.opencodex.me/v1/stats"; + +export const TELEMETRY_RANGES: readonly TelemetryStatsRange[] = Object.freeze([ + "7d", + "30d", + "90d", + "365d", +]); + +export const DEFAULT_TELEMETRY_RANGE: TelemetryStatsRange = "30d"; + +export const NPM_PACKAGE_NAME = "@bitkyc08/opencodex"; +export const GITHUB_REPO_SLUG = "lidge-jun/opencodex"; + +export const NPM_DOWNLOADS_WEEKLY_URL = + "https://api.npmjs.org/downloads/point/last-week/@bitkyc08/opencodex"; +export const NPM_DOWNLOADS_MONTHLY_URL = + "https://api.npmjs.org/downloads/point/last-month/@bitkyc08/opencodex"; +export const NPM_DOWNLOADS_YEARLY_URL = + "https://api.npmjs.org/downloads/point/last-year/@bitkyc08/opencodex"; +export const GITHUB_RELEASES_URL = + "https://api.github.com/repos/lidge-jun/opencodex/releases?per_page=30"; + +export const TOKSCALE_REPO_URL = "https://github.com/junhoyeo/tokscale"; + +/** + * Relative docs path for the telemetry privacy guide added by stack layer 5 + * (`docs-site/src/content/docs/guides/telemetry.md`). + * + * Keep `TELEMETRY_GUIDE_LINK_ENABLED = false` until layer 5 is present in the + * tree (or let `TelemetryDashboard.astro` auto-detect the file at build time) + * so `internal-links.mjs` never sees a dangling `/guides/telemetry/` link. + */ +export const TELEMETRY_GUIDE_PATH = "guides/telemetry/"; +export const TELEMETRY_GUIDE_LINK_ENABLED = false; + +/** + * Disjoint token mix palette adapted from junhoyeo/tokscale `TokenBreakdown.tsx` (MIT). + */ +export const TOKEN_MIX_COLORS: Readonly> = Object.freeze({ + input: "#56b4e9", + cacheRead: "#cc79a7", + cacheWrite: "#e69f00", + output: "#009e73", + reasoning: "#8b5cf6", +}); + +export const TOKEN_BUCKET_ORDER: readonly TokenBucketId[] = Object.freeze([ + "input", + "cacheRead", + "cacheWrite", + "output", + "reasoning", +]); + +export const OUTCOME_COLORS: Readonly> = Object.freeze({ + completed: "#10b981", + incomplete: "#f59e0b", + aborted: "#6b7280", + failed: "#ef4444", +}); + +/** + * 5-grade contribution heatmap ramps adapted from junhoyeo/tokscale `src/lib/themes.ts` (MIT). + */ +export const HEATMAP_GRADE_COLORS: Readonly<{ + light: readonly [string, string, string, string, string]; + dark: readonly [string, string, string, string, string]; +}> = Object.freeze({ + light: ["#eff2f5", "#79b8ff", "#388bfd", "#1f6feb", "#0d419d"], + dark: ["#191f2b", "#1d4ed8", "#2563eb", "#4b91ff", "#8cc0ff"], +}); + +export function getHeatmapGradeColor( + theme: "light" | "dark", + grade: HeatmapGrade, +): string { + const palette = theme === "light" ? HEATMAP_GRADE_COLORS.light : HEATMAP_GRADE_COLORS.dark; + return palette[grade] ?? palette[0]; +} + +const PROVIDER_COLOR_MAP: Readonly> = Object.freeze({ + openai: "#10a37f", + chatgpt: "#14b8a6", + anthropic: "#d97757", + "claude-subscription": "#e39980", + google: "#4285f4", + vertex: "#3b82f6", + xai: "#1da1f2", + deepseek: "#06b6d4", + moonshot: "#8b5cf6", + qwen: "#6366f1", + zai: "#0ea5e9", + minimax: "#ec4899", + mistral: "#f97316", + openrouter: "#a855f7", + groq: "#ef4444", + cerebras: "#eab308", + together: "#22c55e", + fireworks: "#f59e0b", + cursor: "#f59e0b", + kiro: "#14b8a6", + copilot: "#38bdf8", + devin: "#84cc16", + ollama: "#94a3b8", + lmstudio: "#64748b", + "local-other": "#78716c", + other: "#6b7280", +}); + +const MODEL_COLOR_MAP: Readonly> = Object.freeze({ + "gpt-6": "#059669", + "gpt-5": "#10b981", + "gpt-4": "#34d399", + "o-series": "#6366f1", + codex: "#f59e0b", + "claude-opus": "#da7756", + "claude-sonnet": "#e39980", + "claude-haiku": "#ecb8a6", + "gemini-3": "#2563eb", + "gemini-2": "#60a5fa", + grok: "#1da1f2", + deepseek: "#06b6d4", + qwen: "#1a73e8", + kimi: "#a855f7", + glm: "#0ea5e9", + minimax: "#ec4899", + mistral: "#f97316", + llama: "#84cc16", + other: "#6b7280", +}); + +const FALLBACK_SERIES_PALETTE = [ + "#3b82f6", + "#10b981", + "#d97757", + "#8b5cf6", + "#f59e0b", + "#06b6d4", + "#ec4899", + "#84cc16", +] as const; + +function hashColor(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) | 0; + } + return FALLBACK_SERIES_PALETTE[Math.abs(hash) % FALLBACK_SERIES_PALETTE.length]!; +} + +export function getProviderColor(providerKind: string): string { + const normalized = providerKind.trim().toLowerCase(); + return PROVIDER_COLOR_MAP[normalized] ?? hashColor(normalized); +} + +/** + * Model family color resolver adapted from junhoyeo/tokscale `modelColors.ts` (MIT). + */ +export function getModelFamilyColor(modelFamily: string): string { + const normalized = modelFamily.trim().toLowerCase(); + if (MODEL_COLOR_MAP[normalized]) { + return MODEL_COLOR_MAP[normalized]!; + } + const tokens = normalized.split(/[^a-z0-9]+/); + for (const token of tokens) { + if (token === "opus") return MODEL_COLOR_MAP["claude-opus"]!; + if (token === "sonnet") return MODEL_COLOR_MAP["claude-sonnet"]!; + if (token === "haiku") return MODEL_COLOR_MAP["claude-haiku"]!; + if (token.startsWith("claude")) return MODEL_COLOR_MAP["claude-sonnet"]!; + if (token.startsWith("gpt")) return MODEL_COLOR_MAP["gpt-5"]!; + if (token.startsWith("gemini")) return MODEL_COLOR_MAP["gemini-3"]!; + if (MODEL_COLOR_MAP[token]) return MODEL_COLOR_MAP[token]!; + } + return hashColor(normalized); +} diff --git a/docs-site/src/components/telemetry-dashboard/format.ts b/docs-site/src/components/telemetry-dashboard/format.ts new file mode 100644 index 00000000000..55307323e6b --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/format.ts @@ -0,0 +1,225 @@ +import { + DEFAULT_TELEMETRY_RANGE, + TELEMETRY_RANGES, + TELEMETRY_STATS_ENDPOINT, + TOKEN_BUCKET_ORDER, + TOKEN_MIX_COLORS, +} from "./constants"; +import type { + KpiCardItem, + TelemetryStatsRange, + TelemetryStatsV1, + TokenMixSegment, + TokenMixSummary, +} from "./types"; + +const MAX_SAFE = Number.MAX_SAFE_INTEGER; + +export function finiteNonNegativeInt(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + if (n >= MAX_SAFE) return MAX_SAFE; + return Math.floor(n); +} + +export function safeAddInt(a: number, b: number): number { + return finiteNonNegativeInt(finiteNonNegativeInt(a) + finiteNonNegativeInt(b)); +} + +/** + * Compact K/M/B/T formatter adapted from junhoyeo/tokscale `formatTokenCount` (MIT). + * Promotes boundary values (>= 999.95 of a unit) to the next unit so `999,960` + * renders as `"1.0M"` instead of `"1000.0K"`. + */ +export function formatCompactNumber(value: number): string { + const count = finiteNonNegativeInt(value); + if (count >= 1_000_000_000_000) { + const val = (count / 1_000_000_000_000).toFixed(2).replace(/\.?0+$/, ""); + return `${val}T`; + } + if (count >= 1_000_000_000) { + const val = count / 1_000_000_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}T` : `${val.toFixed(1)}B`; + } + if (count >= 1_000_000) { + const val = count / 1_000_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}B` : `${val.toFixed(1)}M`; + } + if (count >= 1_000) { + const val = count / 1_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}M` : `${val.toFixed(1)}K`; + } + return count.toLocaleString("en-US"); +} + +export function formatExactNumber(value: number, locale = "en-US"): string { + return finiteNonNegativeInt(value).toLocaleString(locale); +} + +export function computePercentage(part: number, total: number): number { + const safePart = finiteNonNegativeInt(part); + const safeTotal = finiteNonNegativeInt(total); + if (safeTotal <= 0 || safePart <= 0) return 0; + return Math.min(100, (safePart / safeTotal) * 100); +} + +export function formatPercent(part: number, total: number, digits = 1): string { + return `${computePercentage(part, total).toFixed(digits)}%`; +} + +export function formatActiveHours( + activeMinutes: number, + locale = "en-US", +): { hours: number; compact: string; exactTitle: string } { + const minutes = finiteNonNegativeInt(activeMinutes); + const hours = minutes / 60; + const exactMinutes = minutes.toLocaleString(locale); + if (minutes === 0) { + return { + hours: 0, + compact: "0h", + exactTitle: `0h (${exactMinutes} min)`, + }; + } + const roundedHours = Number(hours.toFixed(1)); + const exactHours = roundedHours.toLocaleString(locale, { + minimumFractionDigits: roundedHours % 1 === 0 ? 0 : 1, + maximumFractionDigits: 1, + }); + const compactBase = + hours >= 1_000 + ? formatCompactNumber(Math.round(hours)) + : hours >= 100 + ? String(Math.round(hours)) + : hours.toFixed(1).replace(/\.0$/, ""); + return { + hours: roundedHours, + compact: `${compactBase}h`, + exactTitle: `${exactHours}h (${exactMinutes} min)`, + }; +} + +export function sumDisjointTokens( + tokens: TelemetryStatsV1["totals"]["tokens"] | null | undefined, +): number { + if (!tokens) return 0; + return safeAddInt( + safeAddInt( + safeAddInt(tokens.input, tokens.cacheRead), + safeAddInt(tokens.cacheWrite, tokens.output), + ), + tokens.reasoning, + ); +} + +export function buildKpiStrip( + stats: Pick, + locale = "en-US", +): KpiCardItem[] { + const activeInstalls = finiteNonNegativeInt(stats.totals.activeInstalls); + const newInstalls = finiteNonNegativeInt(stats.totals.newInstalls); + const requests = finiteNonNegativeInt(stats.totals.requests); + const totalTokens = sumDisjointTokens(stats.totals.tokens); + const activeHours = formatActiveHours(stats.totals.activeMinutes, locale); + + return [ + { + id: "activeInstalls", + rawValue: activeInstalls, + compact: formatCompactNumber(activeInstalls), + exactTitle: formatExactNumber(activeInstalls, locale), + }, + { + id: "newInstalls", + rawValue: newInstalls, + compact: formatCompactNumber(newInstalls), + exactTitle: formatExactNumber(newInstalls, locale), + }, + { + id: "requests", + rawValue: requests, + compact: formatCompactNumber(requests), + exactTitle: formatExactNumber(requests, locale), + }, + { + id: "tokens", + rawValue: totalTokens, + compact: formatCompactNumber(totalTokens), + exactTitle: formatExactNumber(totalTokens, locale), + }, + { + id: "activeHours", + rawValue: finiteNonNegativeInt(stats.totals.activeMinutes), + compact: activeHours.compact, + exactTitle: activeHours.exactTitle, + }, + ]; +} + +export function buildTokenMix( + tokens: TelemetryStatsV1["totals"]["tokens"] | null | undefined, + locale = "en-US", +): TokenMixSummary { + const totalTokens = sumDisjointTokens(tokens); + const segments: TokenMixSegment[] = TOKEN_BUCKET_ORDER.map((id) => { + const value = finiteNonNegativeInt(tokens?.[id]); + const percent = computePercentage(value, totalTokens); + return { + id, + value, + compact: formatCompactNumber(value), + exactTitle: formatExactNumber(value, locale), + percent, + percentLabel: `${percent.toFixed(1)}%`, + color: TOKEN_MIX_COLORS[id], + }; + }); + + return { + totalTokens, + totalCompact: formatCompactNumber(totalTokens), + totalExactTitle: formatExactNumber(totalTokens, locale), + segments, + }; +} + +export function isTelemetryStatsRange(value: unknown): value is TelemetryStatsRange { + return ( + typeof value === "string" && + (TELEMETRY_RANGES as readonly string[]).includes(value) + ); +} + +export function parseStatsRangeParam( + value: string | null | undefined, +): TelemetryStatsRange { + if (isTelemetryStatsRange(value)) return value; + return DEFAULT_TELEMETRY_RANGE; +} + +export function buildStatsUrl( + range: TelemetryStatsRange, + endpoint: string = TELEMETRY_STATS_ENDPOINT, +): string { + const url = new URL(endpoint); + url.searchParams.set("range", range); + return url.toString(); +} + +export function formatGeneratedTimestamp( + iso: string | null | undefined, + locale = "en-US", +): string | null { + if (!iso || typeof iso !== "string") return null; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }); +} diff --git a/docs-site/src/components/telemetry-dashboard/heatmap.ts b/docs-site/src/components/telemetry-dashboard/heatmap.ts new file mode 100644 index 00000000000..c0f13d74c3a --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/heatmap.ts @@ -0,0 +1,227 @@ +import { finiteNonNegativeInt, formatExactNumber, safeAddInt } from "./format"; +import type { + HeatmapCalendar, + HeatmapCell, + HeatmapGrade, + HeatmapMonthMarker, + TelemetryStatsV1, +} from "./types"; + +const DAY_MS = 86_400_000; +const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const SHORT_MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +export function parseUtcDayMs(day: string): number | null { + const match = DAY_KEY_RE.exec(day); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const date = Number(match[3]); + const ms = Date.UTC(year, month, date); + const check = new Date(ms); + if ( + check.getUTCFullYear() !== year || + check.getUTCMonth() !== month || + check.getUTCDate() !== date + ) { + return null; + } + return ms; +} + +export function formatUtcDayKey(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +/** + * 5-grade intensity bucket adapted from junhoyeo/tokscale `getContributionIntensity` (MIT). + */ +export function computeHeatmapGrade( + activeInstalls: number, + maxActiveInstalls: number, +): HeatmapGrade { + const value = finiteNonNegativeInt(activeInstalls); + const max = finiteNonNegativeInt(maxActiveInstalls); + if (value <= 0 || max <= 0) return 0; + const ratio = value / max; + if (ratio >= 0.75) return 4; + if (ratio >= 0.5) return 3; + if (ratio >= 0.25) return 2; + return 1; +} + +export function buildHeatmapCalendar( + heatmap: TelemetryStatsV1["heatmap"] | null | undefined, +): HeatmapCalendar { + const byDayMs = new Map(); + for (const row of heatmap ?? []) { + if (!row || typeof row.day !== "string") continue; + const ms = parseUtcDayMs(row.day); + if (ms === null) continue; + const count = finiteNonNegativeInt(row.activeInstalls); + const prev = byDayMs.get(ms); + byDayMs.set(ms, { + date: row.day, + activeInstalls: prev ? safeAddInt(prev.activeInstalls, count) : count, + }); + } + + const sortedMs = [...byDayMs.keys()].sort((a, b) => a - b); + if (sortedMs.length === 0) { + return { + cells: [], + weekCount: 0, + activeDays: 0, + totalActiveInstalls: 0, + maxActiveInstalls: 0, + peakDay: null, + startDate: null, + endDate: null, + monthMarkers: [], + }; + } + + const firstMs = sortedMs[0]!; + const lastMs = sortedMs[sortedMs.length - 1]!; + + let maxActiveInstalls = 0; + let activeDays = 0; + let totalActiveInstalls = 0; + let peakDay: { date: string; activeInstalls: number } | null = null; + + for (const ms of sortedMs) { + const entry = byDayMs.get(ms)!; + totalActiveInstalls = safeAddInt(totalActiveInstalls, entry.activeInstalls); + if (entry.activeInstalls > 0) { + activeDays += 1; + if (entry.activeInstalls > maxActiveInstalls) { + maxActiveInstalls = entry.activeInstalls; + } + if ( + !peakDay || + entry.activeInstalls > peakDay.activeInstalls || + (entry.activeInstalls === peakDay.activeInstalls && entry.date < peakDay.date) + ) { + peakDay = { date: entry.date, activeInstalls: entry.activeInstalls }; + } + } + } + + const calendarStartMs = firstMs - new Date(firstMs).getUTCDay() * DAY_MS; + const calendarEndMs = lastMs + (6 - new Date(lastMs).getUTCDay()) * DAY_MS; + const totalGridDays = Math.round((calendarEndMs - calendarStartMs) / DAY_MS) + 1; + const weekCount = Math.round(totalGridDays / 7); + + const cells: HeatmapCell[] = []; + for (let offset = 0; offset < totalGridDays; offset++) { + const ms = calendarStartMs + offset * DAY_MS; + const date = formatUtcDayKey(ms); + const inRange = ms >= firstMs && ms <= lastMs; + const selectable = inRange; + const activeInstalls = selectable ? (byDayMs.get(ms)?.activeInstalls ?? 0) : 0; + const grade = selectable + ? computeHeatmapGrade(activeInstalls, maxActiveInstalls) + : 0; + cells.push({ + date, + activeInstalls, + grade, + inRange, + selectable, + weekIndex: Math.floor(offset / 7), + dayOfWeek: offset % 7, + }); + } + + const monthMarkers: HeatmapMonthMarker[] = []; + const seenWeeks = new Set(); + let cursorMs = firstMs; + while (cursorMs <= lastMs) { + const d = new Date(cursorMs); + const monthIndex = d.getUTCMonth(); + const weekIndex = Math.floor((cursorMs - calendarStartMs) / (DAY_MS * 7)); + if (!seenWeeks.has(weekIndex)) { + const marker: HeatmapMonthMarker = { + label: SHORT_MONTHS[monthIndex] ?? "Jan", + monthIndex, + weekIndex, + }; + const prev = monthMarkers[monthMarkers.length - 1]; + if (prev && weekIndex - prev.weekIndex < 3) { + if (prev.weekIndex === 0) { + monthMarkers[monthMarkers.length - 1] = marker; + } + } else { + monthMarkers.push(marker); + } + seenWeeks.add(weekIndex); + } + cursorMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); + } + + return { + cells, + weekCount, + activeDays, + totalActiveInstalls, + maxActiveInstalls, + peakDay, + startDate: formatUtcDayKey(firstMs), + endDate: formatUtcDayKey(lastMs), + monthMarkers, + }; +} + +export type HeatmapNavigationKey = + | "ArrowUp" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + | "Home" + | "End"; + +export function navigateHeatmapDate( + cells: readonly HeatmapCell[], + currentDate: string, + key: HeatmapNavigationKey, +): string | null { + const selectable = cells.filter((cell) => cell.selectable); + if (selectable.length === 0) return null; + if (key === "Home") return selectable[0]!.date; + if (key === "End") return selectable[selectable.length - 1]!.date; + + const idx = selectable.findIndex((cell) => cell.date === currentDate); + if (idx === -1) return selectable[selectable.length - 1]!.date; + + const delta = + key === "ArrowUp" + ? -1 + : key === "ArrowDown" + ? 1 + : key === "ArrowLeft" + ? -7 + : 7; + const nextIdx = Math.max(0, Math.min(selectable.length - 1, idx + delta)); + return selectable[nextIdx]!.date; +} + +export function formatHeatmapCellAriaLabel( + cell: Pick, + locale = "en-US", +): string { + const count = formatExactNumber(cell.activeInstalls, locale); + return `${cell.date}: ${count} active installs (grade ${cell.grade})`; +} diff --git a/docs-site/src/components/telemetry-dashboard/index.ts b/docs-site/src/components/telemetry-dashboard/index.ts new file mode 100644 index 00000000000..1b2e34dd38b --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./constants"; +export * from "./format"; +export * from "./heatmap"; +export * from "./tables"; +export * from "./state"; diff --git a/docs-site/src/components/telemetry-dashboard/state.ts b/docs-site/src/components/telemetry-dashboard/state.ts new file mode 100644 index 00000000000..946f4a7507b --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/state.ts @@ -0,0 +1,183 @@ +import { + finiteNonNegativeInt, + isTelemetryStatsRange, + safeAddInt, + sumDisjointTokens, +} from "./format"; +import type { + DashboardFetchState, + DownloadStats, + TelemetryStatsV1, +} from "./types"; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function isValidTelemetryStatsV1(value: unknown): value is TelemetryStatsV1 { + if (!isPlainObject(value)) return false; + if (value.schemaVersion !== 1) return false; + if (typeof value.generatedAt !== "string") return false; + if (!isTelemetryStatsRange(value.range)) return false; + if (!isPlainObject(value.totals)) return false; + if (!isPlainObject(value.totals.tokens)) return false; + if (!isPlainObject(value.outcomes)) return false; + if ( + !Array.isArray(value.daily) || + !Array.isArray(value.dailyByProvider) || + !Array.isArray(value.heatmap) || + !Array.isArray(value.providers) || + !Array.isArray(value.models) || + !Array.isArray(value.versions) || + !Array.isArray(value.platforms) || + !Array.isArray(value.channels) || + !Array.isArray(value.errors) + ) { + return false; + } + return true; +} + +export function isStatsSnapshotEmpty(stats: TelemetryStatsV1): boolean { + const totalTokens = sumDisjointTokens(stats.totals.tokens); + const hasHeatmapActivity = stats.heatmap.some( + (d) => finiteNonNegativeInt(d.activeInstalls) > 0, + ); + return ( + finiteNonNegativeInt(stats.totals.activeInstalls) === 0 && + finiteNonNegativeInt(stats.totals.newInstalls) === 0 && + finiteNonNegativeInt(stats.totals.requests) === 0 && + totalTokens === 0 && + !hasHeatmapActivity + ); +} + +export interface ClassifyStatsInput { + ok?: boolean; + status?: number; + body?: unknown; + networkError?: boolean; + errorMessage?: string; +} + +export interface ClassifiedStatsResult { + state: Exclude; + stats: TelemetryStatsV1 | null; + reason: + | "ok" + | "collector-not-deployed" + | "no-reports-yet" + | "http-error" + | "invalid-payload" + | "network-error"; + detail?: string; +} + +export function classifyStatsResponse( + input: ClassifyStatsInput, +): ClassifiedStatsResult { + if (input.networkError) { + return { + state: "error", + stats: null, + reason: "network-error", + detail: input.errorMessage ?? "Network request failed", + }; + } + + const status = input.status ?? (input.ok ? 200 : 500); + if (status === 404 || status === 502 || status === 503) { + return { + state: "empty", + stats: null, + reason: "collector-not-deployed", + detail: `HTTP ${status}`, + }; + } + + if (status < 200 || status >= 300 || input.ok === false) { + return { + state: "error", + stats: null, + reason: "http-error", + detail: input.errorMessage ?? `HTTP ${status}`, + }; + } + + if (!isValidTelemetryStatsV1(input.body)) { + return { + state: "error", + stats: null, + reason: "invalid-payload", + detail: "Malformed TelemetryStatsV1 payload", + }; + } + + if (isStatsSnapshotEmpty(input.body)) { + return { + state: "empty", + stats: input.body, + reason: "no-reports-yet", + }; + } + + return { + state: "ready", + stats: input.body, + reason: "ok", + }; +} + +function parseNpmDownloadsCount(payload: unknown): number | null { + if (!isPlainObject(payload)) return null; + const raw = payload.downloads; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return null; + return Math.floor(raw); +} + +export function parseDownloadsPayload(input: { + npmWeekly?: unknown; + npmMonthly?: unknown; + npmYearly?: unknown; + githubReleases?: unknown; +}): DownloadStats { + const npmWeekly = parseNpmDownloadsCount(input.npmWeekly); + const npmMonthly = parseNpmDownloadsCount(input.npmMonthly); + const npmYearly = parseNpmDownloadsCount(input.npmYearly); + + let githubTotalDownloads: number | null = null; + let githubLatestTag: string | null = null; + let githubLatestDownloads: number | null = null; + let githubReleaseCount = 0; + + if (Array.isArray(input.githubReleases)) { + let total = 0; + for (const rel of input.githubReleases) { + if (!isPlainObject(rel) || rel.draft === true) continue; + githubReleaseCount += 1; + const assets = Array.isArray(rel.assets) ? rel.assets : []; + let releaseTotal = 0; + for (const asset of assets) { + if (!isPlainObject(asset)) continue; + const count = finiteNonNegativeInt(asset.download_count); + releaseTotal = safeAddInt(releaseTotal, count); + } + total = safeAddInt(total, releaseTotal); + if (githubLatestTag === null && typeof rel.tag_name === "string" && rel.tag_name.trim()) { + githubLatestTag = rel.tag_name.trim(); + githubLatestDownloads = releaseTotal; + } + } + githubTotalDownloads = total; + } + + return { + npmWeekly, + npmMonthly, + npmYearly, + githubTotalDownloads, + githubLatestTag, + githubLatestDownloads, + githubReleaseCount, + }; +} diff --git a/docs-site/src/components/telemetry-dashboard/tables.ts b/docs-site/src/components/telemetry-dashboard/tables.ts new file mode 100644 index 00000000000..381da1cb214 --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/tables.ts @@ -0,0 +1,329 @@ +import { + OUTCOME_COLORS, + getModelFamilyColor, + getProviderColor, +} from "./constants"; +import { + computePercentage, + finiteNonNegativeInt, + formatCompactNumber, + formatExactNumber, + formatPercent, + safeAddInt, +} from "./format"; +import type { + DailyChartMetric, + DailyProviderSeries, + ErrorCauseRow, + InstallRow, + OutcomeClassId, + OutcomeRow, + ReliabilitySummary, + ShareRow, + StackedDailyChartModel, + TelemetryStatsV1, +} from "./types"; + +const DEFAULT_MAX_CHART_SERIES = 6; + +export function buildDailyProviderChart( + dailyByProvider: TelemetryStatsV1["dailyByProvider"] | null | undefined, + daily: TelemetryStatsV1["daily"] | null | undefined, + metric: DailyChartMetric = "tokens", + maxSeries = DEFAULT_MAX_CHART_SERIES, +): StackedDailyChartModel { + const daySet = new Set(); + for (const d of daily ?? []) { + if (d && typeof d.day === "string") daySet.add(d.day); + } + for (const row of dailyByProvider ?? []) { + if (row && typeof row.day === "string") daySet.add(row.day); + } + const days = [...daySet].sort(); + const dayIndexByKey = new Map(days.map((d, i) => [d, i])); + + const byProvider = new Map(); + for (const row of dailyByProvider ?? []) { + if (!row || typeof row.day !== "string" || typeof row.providerKind !== "string") { + continue; + } + const dayIdx = dayIndexByKey.get(row.day); + if (dayIdx === undefined) continue; + const rawVal = metric === "requests" ? row.requests : row.tokens; + const val = finiteNonNegativeInt(rawVal); + if (val <= 0) continue; + let entry = byProvider.get(row.providerKind); + if (!entry) { + entry = { total: 0, values: new Array(days.length).fill(0) }; + byProvider.set(row.providerKind, entry); + } + entry.values[dayIdx] = safeAddInt(entry.values[dayIdx] ?? 0, val); + entry.total = safeAddInt(entry.total, val); + } + + const ranked = [...byProvider.entries()].sort((a, b) => { + if (b[1].total !== a[1].total) return b[1].total - a[1].total; + return a[0].localeCompare(b[0]); + }); + + const limit = Math.max(1, Math.floor(maxSeries)); + const top = ranked.slice(0, limit); + const overflow = ranked.slice(limit); + + const series: DailyProviderSeries[] = top.map(([providerKind, data]) => ({ + providerKind, + color: getProviderColor(providerKind), + total: data.total, + values: data.values, + })); + + if (overflow.length > 0) { + const otherValues = new Array(days.length).fill(0); + let otherTotal = 0; + for (const [, data] of overflow) { + otherTotal = safeAddInt(otherTotal, data.total); + for (let i = 0; i < days.length; i++) { + otherValues[i] = safeAddInt(otherValues[i] ?? 0, data.values[i] ?? 0); + } + } + const existingOther = series.find((s) => s.providerKind === "other"); + if (existingOther) { + existingOther.total = safeAddInt(existingOther.total, otherTotal); + existingOther.values = existingOther.values.map((v, idx) => + safeAddInt(v, otherValues[idx] ?? 0), + ); + } else if (otherTotal > 0) { + series.push({ + providerKind: "other", + color: getProviderColor("other"), + total: otherTotal, + values: otherValues, + }); + } + } + + const total = series.reduce((acc, item) => safeAddInt(acc, item.total), 0); + return { days, metric, total, series }; +} + +export function buildProviderShareRows( + providers: TelemetryStatsV1["providers"] | null | undefined, + locale = "en-US", +): ShareRow[] { + const rows = (providers ?? []).map((p) => ({ + key: p.providerKind, + requests: finiteNonNegativeInt(p.requests), + tokens: finiteNonNegativeInt(p.tokens), + })); + rows.sort((a, b) => { + if (b.tokens !== a.tokens) return b.tokens - a.tokens; + if (b.requests !== a.requests) return b.requests - a.requests; + return a.key.localeCompare(b.key); + }); + const totalTokens = rows.reduce((acc, r) => safeAddInt(acc, r.tokens), 0); + const totalRequests = rows.reduce((acc, r) => safeAddInt(acc, r.requests), 0); + + return rows.map((r) => { + const tokenSharePercent = computePercentage(r.tokens, totalTokens); + const requestSharePercent = computePercentage(r.requests, totalRequests); + return { + key: r.key, + label: r.key, + color: getProviderColor(r.key), + requests: r.requests, + requestsCompact: formatCompactNumber(r.requests), + requestsExact: formatExactNumber(r.requests, locale), + requestSharePercent, + requestShareLabel: formatPercent(r.requests, totalRequests), + tokens: r.tokens, + tokensCompact: formatCompactNumber(r.tokens), + tokensExact: formatExactNumber(r.tokens, locale), + tokenSharePercent, + tokenShareLabel: formatPercent(r.tokens, totalTokens), + }; + }); +} + +export function buildModelShareRows( + models: TelemetryStatsV1["models"] | null | undefined, + locale = "en-US", +): ShareRow[] { + const rows = (models ?? []).map((m) => ({ + key: m.modelFamily, + requests: finiteNonNegativeInt(m.requests), + tokens: finiteNonNegativeInt(m.tokens), + })); + rows.sort((a, b) => { + if (b.tokens !== a.tokens) return b.tokens - a.tokens; + if (b.requests !== a.requests) return b.requests - a.requests; + return a.key.localeCompare(b.key); + }); + const totalTokens = rows.reduce((acc, r) => safeAddInt(acc, r.tokens), 0); + const totalRequests = rows.reduce((acc, r) => safeAddInt(acc, r.requests), 0); + + return rows.map((r) => { + const tokenSharePercent = computePercentage(r.tokens, totalTokens); + const requestSharePercent = computePercentage(r.requests, totalRequests); + return { + key: r.key, + label: r.key, + color: getModelFamilyColor(r.key), + requests: r.requests, + requestsCompact: formatCompactNumber(r.requests), + requestsExact: formatExactNumber(r.requests, locale), + requestSharePercent, + requestShareLabel: formatPercent(r.requests, totalRequests), + tokens: r.tokens, + tokensCompact: formatCompactNumber(r.tokens), + tokensExact: formatExactNumber(r.tokens, locale), + tokenSharePercent, + tokenShareLabel: formatPercent(r.tokens, totalTokens), + }; + }); +} + +export function buildVersionRows( + versions: TelemetryStatsV1["versions"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const rows = (versions ?? []).map((v) => ({ + version: v.version, + installs: finiteNonNegativeInt(v.installs), + newInstalls: finiteNonNegativeInt(v.newInstalls), + })); + rows.sort((a, b) => { + if (b.installs !== a.installs) return b.installs - a.installs; + return b.version.localeCompare(a.version); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.version, + label: `v${r.version.replace(/^v/i, "")}`, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + newInstalls: r.newInstalls, + newInstallsCompact: formatCompactNumber(r.newInstalls), + newInstallsExact: formatExactNumber(r.newInstalls, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +export function buildPlatformRows( + platforms: TelemetryStatsV1["platforms"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const rows = (platforms ?? []).map((p) => ({ + key: `${p.os}/${p.arch}`, + installs: finiteNonNegativeInt(p.installs), + })); + rows.sort((a, b) => { + if (b.installs !== a.installs) return b.installs - a.installs; + return a.key.localeCompare(b.key); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.key, + label: r.key, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +export function buildChannelRows( + channels: TelemetryStatsV1["channels"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const rows = (channels ?? []).map((c) => ({ + channel: c.installChannel, + installs: finiteNonNegativeInt(c.installs), + newInstalls: finiteNonNegativeInt(c.newInstalls), + })); + rows.sort((a, b) => { + if (b.installs !== a.installs) return b.installs - a.installs; + return a.channel.localeCompare(b.channel); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.channel, + label: r.channel, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + newInstalls: r.newInstalls, + newInstallsCompact: formatCompactNumber(r.newInstalls), + newInstallsExact: formatExactNumber(r.newInstalls, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +const OUTCOME_KEYS: readonly OutcomeClassId[] = [ + "completed", + "incomplete", + "aborted", + "failed", +]; + +export function buildReliabilitySummary( + outcomes: TelemetryStatsV1["outcomes"] | null | undefined, + errors: TelemetryStatsV1["errors"] | null | undefined, + locale = "en-US", +): ReliabilitySummary { + const counts: Record = { + completed: finiteNonNegativeInt(outcomes?.completed), + incomplete: finiteNonNegativeInt(outcomes?.incomplete), + aborted: finiteNonNegativeInt(outcomes?.aborted), + failed: finiteNonNegativeInt(outcomes?.failed), + }; + const totalRequests = safeAddInt( + safeAddInt(counts.completed, counts.incomplete), + safeAddInt(counts.aborted, counts.failed), + ); + const completedRatioPercent = computePercentage(counts.completed, totalRequests); + const completedRatioLabel = formatPercent(counts.completed, totalRequests); + + const outcomeRows: OutcomeRow[] = OUTCOME_KEYS.map((key) => ({ + key, + count: counts[key], + compact: formatCompactNumber(counts[key]), + exact: formatExactNumber(counts[key], locale), + sharePercent: computePercentage(counts[key], totalRequests), + shareLabel: formatPercent(counts[key], totalRequests), + color: OUTCOME_COLORS[key], + })); + + const rawErrors = (errors ?? []) + .map((e) => ({ + cause: e.cause, + count: finiteNonNegativeInt(e.count), + })) + .filter((e) => e.count > 0) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return a.cause.localeCompare(b.cause); + }); + + const totalErrors = rawErrors.reduce((acc, e) => safeAddInt(acc, e.count), 0); + const errorRows: ErrorCauseRow[] = rawErrors.map((e) => ({ + cause: e.cause, + count: e.count, + compact: formatCompactNumber(e.count), + exact: formatExactNumber(e.count, locale), + sharePercent: computePercentage(e.count, totalErrors), + shareLabel: formatPercent(e.count, totalErrors), + })); + + return { + totalRequests, + completedRatioPercent, + completedRatioLabel, + outcomes: outcomeRows, + errors: errorRows, + totalErrors, + }; +} diff --git a/docs-site/src/components/telemetry-dashboard/types.ts b/docs-site/src/components/telemetry-dashboard/types.ts new file mode 100644 index 00000000000..00fe58e4172 --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/types.ts @@ -0,0 +1,234 @@ +export type TelemetryStatsRange = "7d" | "30d" | "90d" | "365d"; + +/** + * Public response contract of `GET https://telemetry.opencodex.me/v1/stats`. + * Kept in lockstep with `services/telemetry-collector/src/types.ts` and + * verified by `tests/ci-workflows/docs-telemetry-dashboard.test.ts`. + */ +export type TelemetryStatsV1 = { + schemaVersion: 1; + generatedAt: string; + range: TelemetryStatsRange; + totals: { + activeInstalls: number; + newInstalls: number; + activeMinutes: number; + requests: number; + tokens: { + input: number; + cacheRead: number; + cacheWrite: number; + output: number; + reasoning: number; + }; + }; + daily: Array<{ + day: string; + activeInstalls: number; + newInstalls: number; + activeMinutes: number; + requests: number; + failed: number; + tokens: number; + }>; + dailyByProvider: Array<{ + day: string; + providerKind: string; + requests: number; + tokens: number; + }>; + heatmap: Array<{ + day: string; + activeInstalls: number; + }>; + providers: Array<{ + providerKind: string; + requests: number; + tokens: number; + }>; + models: Array<{ + modelFamily: string; + requests: number; + tokens: number; + }>; + versions: Array<{ + version: string; + installs: number; + newInstalls: number; + }>; + platforms: Array<{ + os: string; + arch: string; + installs: number; + }>; + channels: Array<{ + installChannel: string; + installs: number; + newInstalls: number; + }>; + outcomes: { + completed: number; + failed: number; + incomplete: number; + aborted: number; + }; + errors: Array<{ + cause: string; + count: number; + }>; +}; + +export type HeatmapGrade = 0 | 1 | 2 | 3 | 4; + +export type KpiMetricId = + | "activeInstalls" + | "newInstalls" + | "requests" + | "tokens" + | "activeHours"; + +export interface KpiCardItem { + id: KpiMetricId; + rawValue: number; + compact: string; + exactTitle: string; +} + +export type TokenBucketId = + | "input" + | "cacheRead" + | "cacheWrite" + | "output" + | "reasoning"; + +export interface TokenMixSegment { + id: TokenBucketId; + value: number; + compact: string; + exactTitle: string; + percent: number; + percentLabel: string; + color: string; +} + +export interface TokenMixSummary { + totalTokens: number; + totalCompact: string; + totalExactTitle: string; + segments: TokenMixSegment[]; +} + +export interface HeatmapCell { + date: string; + activeInstalls: number; + grade: HeatmapGrade; + inRange: boolean; + selectable: boolean; + weekIndex: number; + dayOfWeek: number; +} + +export interface HeatmapMonthMarker { + label: string; + monthIndex: number; + weekIndex: number; +} + +export interface HeatmapCalendar { + cells: HeatmapCell[]; + weekCount: number; + activeDays: number; + totalActiveInstalls: number; + maxActiveInstalls: number; + peakDay: { date: string; activeInstalls: number } | null; + startDate: string | null; + endDate: string | null; + monthMarkers: HeatmapMonthMarker[]; +} + +export type DailyChartMetric = "tokens" | "requests"; + +export interface DailyProviderSeries { + providerKind: string; + color: string; + total: number; + values: number[]; +} + +export interface StackedDailyChartModel { + days: string[]; + metric: DailyChartMetric; + total: number; + series: DailyProviderSeries[]; +} + +export interface ShareRow { + key: string; + label: string; + color: string; + requests: number; + requestsCompact: string; + requestsExact: string; + requestSharePercent: number; + requestShareLabel: string; + tokens: number; + tokensCompact: string; + tokensExact: string; + tokenSharePercent: number; + tokenShareLabel: string; +} + +export interface InstallRow { + key: string; + label: string; + installs: number; + installsCompact: string; + installsExact: string; + newInstalls?: number; + newInstallsCompact?: string; + newInstallsExact?: string; + sharePercent: number; + shareLabel: string; +} + +export type OutcomeClassId = "completed" | "failed" | "incomplete" | "aborted"; + +export interface OutcomeRow { + key: OutcomeClassId; + count: number; + compact: string; + exact: string; + sharePercent: number; + shareLabel: string; + color: string; +} + +export interface ErrorCauseRow { + cause: string; + count: number; + compact: string; + exact: string; + sharePercent: number; + shareLabel: string; +} + +export interface ReliabilitySummary { + totalRequests: number; + completedRatioPercent: number; + completedRatioLabel: string; + outcomes: OutcomeRow[]; + errors: ErrorCauseRow[]; + totalErrors: number; +} + +export interface DownloadStats { + npmWeekly: number | null; + npmMonthly: number | null; + npmYearly: number | null; + githubTotalDownloads: number | null; + githubLatestTag: string | null; + githubLatestDownloads: number | null; + githubReleaseCount: number; +} + +export type DashboardFetchState = "loading" | "ready" | "empty" | "error"; diff --git a/docs-site/src/content/docs/dashboard.mdx b/docs-site/src/content/docs/dashboard.mdx new file mode 100644 index 00000000000..b13cab42213 --- /dev/null +++ b/docs-site/src/content/docs/dashboard.mdx @@ -0,0 +1,10 @@ +--- +title: "Usage Dashboard" +description: "Project-wide anonymous usage statistics, contribution activity, provider and model breakdowns, and download metrics for opencodex." +template: splash +tableOfContents: false +--- + +import TelemetryDashboard from '../../components/TelemetryDashboard.astro'; + + diff --git a/docs-site/src/data/telemetry-dashboard-i18n.ts b/docs-site/src/data/telemetry-dashboard-i18n.ts new file mode 100644 index 00000000000..882799438d9 --- /dev/null +++ b/docs-site/src/data/telemetry-dashboard-i18n.ts @@ -0,0 +1,942 @@ +export const TELEMETRY_DASHBOARD_LOCALES = [ + "en", + "fr", + "ko", + "zh-cn", + "zh-tw", + "ru", + "ja", + "tr", +] as const; + +export type TelemetryDashboardLocale = (typeof TELEMETRY_DASHBOARD_LOCALES)[number]; + +export interface TelemetryDashboardCopy { + title: string; + subtitle: string; + rangeAria: string; + range7d: string; + range30d: string; + range90d: string; + range365d: string; + updatedPrefix: string; + updatedPending: string; + privacyBadge: string; + privacyBody: string; + privacyOptOutHint: string; + privacyGuideLink: string; + stateLoadingTitle: string; + stateLoadingBody: string; + stateEmptyTitle: string; + stateEmptyBody: string; + stateErrorTitle: string; + stateErrorBody: string; + retryButton: string; + kpiSectionAria: string; + kpiActiveInstalls: string; + kpiNewInstalls: string; + kpiRequests: string; + kpiTokens: string; + kpiActiveHours: string; + tokenMixTitle: string; + tokenMixSubtitle: string; + tokenInput: string; + tokenCacheRead: string; + tokenCacheWrite: string; + tokenOutput: string; + tokenReasoning: string; + heatmapTitle: string; + heatmapSubtitle: string; + heatmapActiveDays: string; + heatmapPeakDay: string; + heatmapLess: string; + heatmapMore: string; + heatmapTooltipUnit: string; + heatmapKeyboardHint: string; + dailyChartTitle: string; + dailyChartSubtitle: string; + metricTokens: string; + metricRequests: string; + providersTitle: string; + providersSubtitle: string; + modelsTitle: string; + modelsSubtitle: string; + colProvider: string; + colModel: string; + colRequests: string; + colTokens: string; + colShare: string; + versionsTitle: string; + versionsSubtitle: string; + platformsTitle: string; + platformsSubtitle: string; + channelsTitle: string; + channelsSubtitle: string; + colVersion: string; + colPlatform: string; + colChannel: string; + colInstalls: string; + colNewInstalls: string; + colInstallShare: string; + reliabilityTitle: string; + reliabilitySubtitle: string; + completionRateLabel: string; + outcomeCompleted: string; + outcomeIncomplete: string; + outcomeAborted: string; + outcomeFailed: string; + topErrorsTitle: string; + colErrorCause: string; + colErrorCount: string; + noErrorsRecorded: string; + downloadsTitle: string; + downloadsSubtitle: string; + npmWeeklyLabel: string; + npmMonthlyLabel: string; + npmYearlyLabel: string; + githubTotalLabel: string; + githubLatestLabel: string; + downloadsUnavailable: string; + noDataInTable: string; + creditPrefix: string; + creditSuffix: string; +} + +export const TELEMETRY_DASHBOARD_STRINGS: Readonly< + Record +> = { + en: { + title: "Usage Dashboard", + subtitle: + "Project-wide anonymous daily aggregates from opt-in opencodex installs.", + rangeAria: "Reporting window", + range7d: "7d", + range30d: "30d", + range90d: "90d", + range365d: "365d", + updatedPrefix: "Updated", + updatedPending: "Awaiting snapshot", + privacyBadge: "Anonymous aggregates only", + privacyBody: + "Reports carry closed-vocabulary daily counts with a rotating daily install hash — never prompts, model outputs, API keys, account identifiers, file paths, or IP addresses.", + privacyOptOutHint: + "Disable anytime with ocx telemetry disable, OCX_TELEMETRY=0, or DO_NOT_TRACK=1.", + privacyGuideLink: "Telemetry & privacy guide", + stateLoadingTitle: "Loading project telemetry…", + stateLoadingBody: + "Fetching aggregate usage snapshot from telemetry.opencodex.me.", + stateEmptyTitle: "Collector not deployed yet", + stateEmptyBody: + "The public collector at telemetry.opencodex.me is not deployed yet or has not recorded daily aggregates for this window.", + stateErrorTitle: "Could not load live telemetry", + stateErrorBody: + "The request to telemetry.opencodex.me did not complete (the collector may not be deployed yet or is temporarily unreachable).", + retryButton: "Retry", + kpiSectionAria: "Key usage metrics", + kpiActiveInstalls: "Active install-days", + kpiNewInstalls: "New installs", + kpiRequests: "Requests", + kpiTokens: "Total tokens", + kpiActiveHours: "Active hours", + tokenMixTitle: "Token mix", + tokenMixSubtitle: + "Disjoint token buckets across all reported model families — segments sum to 100% of total tokens.", + tokenInput: "Input", + tokenCacheRead: "Cache read", + tokenCacheWrite: "Cache write", + tokenOutput: "Output", + tokenReasoning: "Reasoning", + heatmapTitle: "365-day activity heatmap", + heatmapSubtitle: + "Daily reporting installs over the trailing 52 weeks (UTC).", + heatmapActiveDays: "{count} active days", + heatmapPeakDay: "Peak: {date} ({count})", + heatmapLess: "Low", + heatmapMore: "High", + heatmapTooltipUnit: "active installs", + heatmapKeyboardHint: + "Use arrow keys to inspect adjacent days, Home and End to jump to range boundaries, and Escape to dismiss the tooltip.", + dailyChartTitle: "Daily usage by provider kind", + dailyChartSubtitle: + "Stacked daily volume across the selected reporting window.", + metricTokens: "Tokens", + metricRequests: "Requests", + providersTitle: "Provider kinds", + providersSubtitle: + "Closed vendor roster share by tokens and requests.", + modelsTitle: "Model families", + modelsSubtitle: + "Coarse model family share by tokens and requests.", + colProvider: "Provider kind", + colModel: "Model family", + colRequests: "Requests", + colTokens: "Tokens", + colShare: "Token share", + versionsTitle: "Versions", + versionsSubtitle: + "Active and newly reporting installs by release version.", + platformsTitle: "Platforms", + platformsSubtitle: "OS and CPU architecture distribution.", + channelsTitle: "Install channels", + channelsSubtitle: "How reporting proxies were installed.", + colVersion: "Version", + colPlatform: "Platform", + colChannel: "Channel", + colInstalls: "Installs", + colNewInstalls: "New", + colInstallShare: "Share", + reliabilityTitle: "Reliability & outcomes", + reliabilitySubtitle: + "Request outcome ratio and top closed-vocabulary failure causes.", + completionRateLabel: "Completion rate", + outcomeCompleted: "Completed", + outcomeIncomplete: "Incomplete", + outcomeAborted: "Aborted", + outcomeFailed: "Failed", + topErrorsTitle: "Top failure causes", + colErrorCause: "Failure cause", + colErrorCount: "Count", + noErrorsRecorded: "No failure causes recorded in this window.", + downloadsTitle: "Package & release downloads", + downloadsSubtitle: + "Live download counts from npm (@bitkyc08/opencodex) and GitHub release assets (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (last 7 days)", + npmMonthlyLabel: "npm (last 30 days)", + npmYearlyLabel: "npm (last 365 days)", + githubTotalLabel: "GitHub release assets (total)", + githubLatestLabel: "Latest release assets", + downloadsUnavailable: "Unavailable", + noDataInTable: "No data reported for this window.", + creditPrefix: + "Visual design and contribution heatmap patterns adapted from", + creditSuffix: "(MIT License).", + }, + fr: { + title: "Tableau de bord d’utilisation", + subtitle: + "Agrégats quotidiens anonymes à l’échelle du projet provenant des installations opencodex participantes.", + rangeAria: "Période d’analyse", + range7d: "7j", + range30d: "30j", + range90d: "90j", + range365d: "365j", + updatedPrefix: "Mis à jour", + updatedPending: "En attente d’instantané", + privacyBadge: "Agrégats anonymes uniquement", + privacyBody: + "Les rapports ne contiennent que des compteurs quotidiens à vocabulaire fermé avec un hachage journalier rotatif — jamais de prompts, de réponses, de clés API, d’identifiants de compte, de chemins ou d’adresses IP.", + privacyOptOutHint: + "Désactivez à tout moment avec ocx telemetry disable, OCX_TELEMETRY=0 ou DO_NOT_TRACK=1.", + privacyGuideLink: "Guide de télémétrie et confidentialité", + stateLoadingTitle: "Chargement de la télémétrie du projet…", + stateLoadingBody: + "Récupération des statistiques agrégées depuis telemetry.opencodex.me.", + stateEmptyTitle: "Collecteur non encore déployé", + stateEmptyBody: + "Le collecteur public sur telemetry.opencodex.me n’est pas encore déployé ou n’a pas encore enregistré d’agrégats pour cette période.", + stateErrorTitle: "Impossible de charger la télémétrie en direct", + stateErrorBody: + "La requête vers telemetry.opencodex.me a échoué (le collecteur n’est peut-être pas encore déployé ou est temporairement injoignable).", + retryButton: "Réessayer", + kpiSectionAria: "Indicateurs clés d’utilisation", + kpiActiveInstalls: "Jours-installations actifs", + kpiNewInstalls: "Nouvelles installations", + kpiRequests: "Requêtes", + kpiTokens: "Jetons totaux", + kpiActiveHours: "Heures actives", + tokenMixTitle: "Répartition des jetons", + tokenMixSubtitle: + "Catégories de jetons disjointes sur toutes les familles de modèles — la somme correspond à 100 % des jetons.", + tokenInput: "Entrée", + tokenCacheRead: "Lecture cache", + tokenCacheWrite: "Écriture cache", + tokenOutput: "Sortie", + tokenReasoning: "Raisonnement", + heatmapTitle: "Carte d’activité sur 365 jours", + heatmapSubtitle: + "Installations actives quotidiennes sur les 52 dernières semaines (UTC).", + heatmapActiveDays: "{count} jours actifs", + heatmapPeakDay: "Pic : {date} ({count})", + heatmapLess: "Faible", + heatmapMore: "Élevé", + heatmapTooltipUnit: "installations actives", + heatmapKeyboardHint: + "Utilisez les flèches pour parcourir les jours, Début/Fin pour aller aux extrémités et Échap pour fermer l’infobulle.", + dailyChartTitle: "Utilisation quotidienne par type de fournisseur", + dailyChartSubtitle: + "Volume quotidien empilé sur la période sélectionnée.", + metricTokens: "Jetons", + metricRequests: "Requêtes", + providersTitle: "Types de fournisseurs", + providersSubtitle: "Part des fournisseurs par jetons et requêtes.", + modelsTitle: "Familles de modèles", + modelsSubtitle: "Part des familles de modèles par jetons et requêtes.", + colProvider: "Fournisseur", + colModel: "Famille de modèles", + colRequests: "Requêtes", + colTokens: "Jetons", + colShare: "Part (jetons)", + versionsTitle: "Versions", + versionsSubtitle: + "Installations actives et nouvelles par version publiée.", + platformsTitle: "Plateformes", + platformsSubtitle: "Répartition par système et architecture CPU.", + channelsTitle: "Canaux d’installation", + channelsSubtitle: "Mode d’installation des proxys déclarants.", + colVersion: "Version", + colPlatform: "Plateforme", + colChannel: "Canal", + colInstalls: "Installations", + colNewInstalls: "Nouvelles", + colInstallShare: "Part", + reliabilityTitle: "Fiabilité et résultats", + reliabilitySubtitle: + "Taux d’aboutissement des requêtes et principales causes d’échec.", + completionRateLabel: "Taux de réussite", + outcomeCompleted: "Terminées", + outcomeIncomplete: "Incomplètes", + outcomeAborted: "Interrompues", + outcomeFailed: "Échouées", + topErrorsTitle: "Principales causes d’échec", + colErrorCause: "Cause d’échec", + colErrorCount: "Nombre", + noErrorsRecorded: "Aucune cause d’échec enregistrée sur cette période.", + downloadsTitle: "Téléchargements de paquets et versions", + downloadsSubtitle: + "Compteurs en direct depuis npm (@bitkyc08/opencodex) et les binaires GitHub Releases (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (7 derniers jours)", + npmMonthlyLabel: "npm (30 derniers jours)", + npmYearlyLabel: "npm (365 derniers jours)", + githubTotalLabel: "GitHub Releases (total)", + githubLatestLabel: "Dernière version GitHub", + downloadsUnavailable: "Indisponible", + noDataInTable: "Aucune donnée déclarée pour cette période.", + creditPrefix: + "Design visuel et carte d’activité adaptés de", + creditSuffix: "(licence MIT).", + }, + ko: { + title: "사용량 대시보드", + subtitle: + "참여에 동의한 opencodex 설치에서 집계된 프로젝트 전체 익명 일일 통계입니다.", + rangeAria: "집계 기간", + range7d: "7일", + range30d: "30일", + range90d: "90일", + range365d: "365일", + updatedPrefix: "업데이트", + updatedPending: "스냅샷 대기 중", + privacyBadge: "익명 집계 전용", + privacyBody: + "보고서에는 매일 회전하는 설치 해시와 고정 어휘 일일 카운터만 포함되며 프롬프트, 모델 출력, API 키, 계정 식별자, 파일 경로, IP 주소는 절대 전송되지 않습니다.", + privacyOptOutHint: + "언제든 ocx telemetry disable, OCX_TELEMETRY=0 또는 DO_NOT_TRACK=1로 끌 수 있습니다.", + privacyGuideLink: "텔레메트리 및 개인정보 가이드", + stateLoadingTitle: "프로젝트 텔레메트리 불러오는 중…", + stateLoadingBody: + "telemetry.opencodex.me에서 집계 스냅샷을 가져오고 있습니다.", + stateEmptyTitle: "수집기가 아직 배포되지 않았습니다 (Collector not deployed yet)", + stateEmptyBody: + "telemetry.opencodex.me 공개 수집기가 아직 배포되지 않았거나 선택한 기간에 기록된 일일 집계가 없습니다.", + stateErrorTitle: "실시간 텔레메트리를 불러오지 못했습니다", + stateErrorBody: + "telemetry.opencodex.me 요청을 완료하지 못했습니다(수집기가 아직 배포 전이거나 일시적으로 연결할 수 없습니다).", + retryButton: "다시 시도", + kpiSectionAria: "주요 사용 지표", + kpiActiveInstalls: "활성 설치-일수", + kpiNewInstalls: "신규 설치", + kpiRequests: "요청 수", + kpiTokens: "총 토큰", + kpiActiveHours: "활성 시간", + tokenMixTitle: "토큰 구성비", + tokenMixSubtitle: + "입력·캐시 읽기·캐시 쓰기·출력·추론의 서로소(disjoint) 토큰 버킷 분포로 합계는 100%입니다.", + tokenInput: "입력", + tokenCacheRead: "캐시 읽기", + tokenCacheWrite: "캐시 쓰기", + tokenOutput: "출력", + tokenReasoning: "추론", + heatmapTitle: "365일 활동 히트맵", + heatmapSubtitle: + "최근 52주간 일별 활성 설치 추이(UTC 기준).", + heatmapActiveDays: "활성 일수 {count}일", + heatmapPeakDay: "최고치: {date} ({count})", + heatmapLess: "낮음", + heatmapMore: "높음", + heatmapTooltipUnit: "활성 설치", + heatmapKeyboardHint: + "방향키로 인접 날짜를 이동하고 Home/End로 시작·끝 날짜로 이동하며 Escape로 툴팁을 닫습니다.", + dailyChartTitle: "프로바이더 종류별 일일 사용량", + dailyChartSubtitle: + "선택한 기간의 프로바이더별 일일 누적 추이입니다.", + metricTokens: "토큰", + metricRequests: "요청 수", + providersTitle: "프로바이더 종류", + providersSubtitle: "토큰 및 요청 기준 프로바이더 점유율.", + modelsTitle: "모델 계열", + modelsSubtitle: "토큰 및 요청 기준 모델 계열 점유율.", + colProvider: "프로바이더", + colModel: "모델 계열", + colRequests: "요청 수", + colTokens: "토큰", + colShare: "토큰 점유율", + versionsTitle: "버전 분포", + versionsSubtitle: "릴리스 버전별 활성 설치 및 신규 설치 수.", + platformsTitle: "플랫폼 분포", + platformsSubtitle: "OS 및 CPU 아키텍처 비율.", + channelsTitle: "설치 채널", + channelsSubtitle: "프록시가 설치된 경로별 비율.", + colVersion: "버전", + colPlatform: "플랫폼", + colChannel: "설치 채널", + colInstalls: "설치 수", + colNewInstalls: "신규", + colInstallShare: "비율", + reliabilityTitle: "신뢰성 및 요청 결과", + reliabilitySubtitle: + "요청 완료 비율과 주요 실패 원인(고정 어휘 코드).", + completionRateLabel: "완료율", + outcomeCompleted: "완료", + outcomeIncomplete: "미완료", + outcomeAborted: "중단됨", + outcomeFailed: "실패", + topErrorsTitle: "주요 실패 원인", + colErrorCause: "실패 원인", + colErrorCount: "건수", + noErrorsRecorded: "이 기간에 기록된 실패 원인이 없습니다.", + downloadsTitle: "패키지 및 릴리스 다운로드", + downloadsSubtitle: + "npm(@bitkyc08/opencodex)과 GitHub 릴리스 에셋(lidge-jun/opencodex) 실시간 다운로드 집계.", + npmWeeklyLabel: "npm (최근 7일)", + npmMonthlyLabel: "npm (최근 30일)", + npmYearlyLabel: "npm (최근 365일)", + githubTotalLabel: "GitHub 릴리스 에셋 (누적)", + githubLatestLabel: "최신 릴리스 에셋", + downloadsUnavailable: "확인 불가", + noDataInTable: "이 기간에 보고된 데이터가 없습니다.", + creditPrefix: + "시각 디자인 및 기여도 히트맵 패턴 참고:", + creditSuffix: "(MIT 라이선스).", + }, + "zh-cn": { + title: "使用量仪表盘", + subtitle: + "来自参与遥测的 opencodex 安装实例的全项目匿名每日汇总。", + rangeAria: "统计时间范围", + range7d: "7天", + range30d: "30天", + range90d: "90天", + range365d: "365天", + updatedPrefix: "更新于", + updatedPending: "等待快照", + privacyBadge: "仅匿名汇总数据", + privacyBody: + "上报数据仅包含固定词汇的每日计数与每日轮换的安装哈希——绝不包含提示词、模型输出、API 密钥、账户标识、文件路径或 IP 地址。", + privacyOptOutHint: + "可随时通过 ocx telemetry disable、OCX_TELEMETRY=0 或 DO_NOT_TRACK=1 关闭。", + privacyGuideLink: "遥测与隐私指南", + stateLoadingTitle: "正在加载项目遥测数据…", + stateLoadingBody: "正在从 telemetry.opencodex.me 获取汇总快照。", + stateEmptyTitle: "收集器尚未部署 (Collector not deployed yet)", + stateEmptyBody: + "位于 telemetry.opencodex.me 的公共收集器尚未部署,或该时间范围内尚无每日汇总记录。", + stateErrorTitle: "无法加载实时遥测数据", + stateErrorBody: + "对 telemetry.opencodex.me 的请求未能完成(收集器可能尚未部署或暂时无法访问)。", + retryButton: "重试", + kpiSectionAria: "核心使用指标", + kpiActiveInstalls: "活跃安装日", + kpiNewInstalls: "新增安装", + kpiRequests: "请求数", + kpiTokens: "总 Token 数", + kpiActiveHours: "活跃小时数", + tokenMixTitle: "Token 构成", + tokenMixSubtitle: + "输入、缓存读取、缓存写入、输出与推理五个互不重叠的 Token 桶,总和为 100%。", + tokenInput: "输入", + tokenCacheRead: "缓存读取", + tokenCacheWrite: "缓存写入", + tokenOutput: "输出", + tokenReasoning: "推理", + heatmapTitle: "365 天活跃热力图", + heatmapSubtitle: "过去 52 周每日活跃安装量(UTC)。", + heatmapActiveDays: "{count} 个活跃日", + heatmapPeakDay: "峰值:{date}({count})", + heatmapLess: "低", + heatmapMore: "高", + heatmapTooltipUnit: "活跃安装", + heatmapKeyboardHint: + "使用方向键浏览相邻日期,Home/End 跳至起止日期,Escape 关闭提示框。", + dailyChartTitle: "按提供商类型的每日使用量", + dailyChartSubtitle: "所选时间窗口内的每日堆叠趋势。", + metricTokens: "Token", + metricRequests: "请求数", + providersTitle: "提供商类型", + providersSubtitle: "按 Token 与请求数统计的提供商占比。", + modelsTitle: "模型系列", + modelsSubtitle: "按 Token 与请求数统计的模型系列占比。", + colProvider: "提供商类型", + colModel: "模型系列", + colRequests: "请求数", + colTokens: "Token", + colShare: "Token 占比", + versionsTitle: "版本分布", + versionsSubtitle: "各发布版本的活跃安装与新增安装。", + platformsTitle: "平台分布", + platformsSubtitle: "操作系统与 CPU 架构分布。", + channelsTitle: "安装渠道", + channelsSubtitle: "上报代理的安装方式分布。", + colVersion: "版本", + colPlatform: "平台", + colChannel: "渠道", + colInstalls: "安装量", + colNewInstalls: "新增", + colInstallShare: "占比", + reliabilityTitle: "可靠性与请求结果", + reliabilitySubtitle: "请求完成率与主要失败原因(固定错误码)。", + completionRateLabel: "完成率", + outcomeCompleted: "已完成", + outcomeIncomplete: "未完成", + outcomeAborted: "已中止", + outcomeFailed: "失败", + topErrorsTitle: "主要失败原因", + colErrorCause: "失败原因", + colErrorCount: "次数", + noErrorsRecorded: "该时间窗口内无失败记录。", + downloadsTitle: "软件包与发行版下载量", + downloadsSubtitle: + "来自 npm (@bitkyc08/opencodex) 与 GitHub Release 资产 (lidge-jun/opencodex) 的实时下载统计。", + npmWeeklyLabel: "npm(最近 7 天)", + npmMonthlyLabel: "npm(最近 30 天)", + npmYearlyLabel: "npm(最近 365 天)", + githubTotalLabel: "GitHub 发行版资产(累计)", + githubLatestLabel: "最新发行版资产", + downloadsUnavailable: "暂不可用", + noDataInTable: "该时间窗口内暂无上报数据。", + creditPrefix: "视觉设计与贡献热力图模式改编自", + creditSuffix: "(MIT 许可证)。", + }, + "zh-tw": { + title: "使用量儀表板", + subtitle: + "來自參與遙測的 opencodex 安裝實例之全專案匿名每日彙總。", + rangeAria: "統計時間範圍", + range7d: "7天", + range30d: "30天", + range90d: "90天", + range365d: "365天", + updatedPrefix: "更新於", + updatedPending: "等待快照", + privacyBadge: "僅限匿名彙總資料", + privacyBody: + "回報資料僅包含固定詞彙的每日計數與每日輪替的安裝雜湊——絕不包含提示詞、模型輸出、API 金鑰、帳號識別碼、檔案路徑或 IP 位址。", + privacyOptOutHint: + "可隨時透過 ocx telemetry disable、OCX_TELEMETRY=0 或 DO_NOT_TRACK=1 關閉。", + privacyGuideLink: "遙測與隱私指南", + stateLoadingTitle: "正在載入專案遙測資料…", + stateLoadingBody: "正在從 telemetry.opencodex.me 取得彙總快照。", + stateEmptyTitle: "收集器尚未部署 (Collector not deployed yet)", + stateEmptyBody: + "位於 telemetry.opencodex.me 的公開收集器尚未部署,或此時間範圍內尚無每日彙總紀錄。", + stateErrorTitle: "無法載入即時遙測資料", + stateErrorBody: + "對 telemetry.opencodex.me 的請求未能完成(收集器可能尚未部署或暫時無法連線)。", + retryButton: "重試", + kpiSectionAria: "核心使用指標", + kpiActiveInstalls: "活躍安裝日", + kpiNewInstalls: "新增安裝", + kpiRequests: "請求數", + kpiTokens: "總 Token 數", + kpiActiveHours: "活躍小時數", + tokenMixTitle: "Token 構成", + tokenMixSubtitle: + "輸入、快取讀取、快取寫入、輸出與推理五個互不重疊的 Token 桶,總和為 100%。", + tokenInput: "輸入", + tokenCacheRead: "快取讀取", + tokenCacheWrite: "快取寫入", + tokenOutput: "輸出", + tokenReasoning: "推理", + heatmapTitle: "365 天活躍熱力圖", + heatmapSubtitle: "過去 52 週每日活躍安裝量(UTC)。", + heatmapActiveDays: "{count} 個活躍日", + heatmapPeakDay: "峰值:{date}({count})", + heatmapLess: "低", + heatmapMore: "高", + heatmapTooltipUnit: "活躍安裝", + heatmapKeyboardHint: + "使用方向鍵瀏覽相鄰日期,Home/End 跳至起訖日期,Escape 關閉提示框。", + dailyChartTitle: "依供應商類型的每日使用量", + dailyChartSubtitle: "所選時間視窗內的每日堆疊趨勢。", + metricTokens: "Token", + metricRequests: "請求數", + providersTitle: "供應商類型", + providersSubtitle: "依 Token 與請求數統計的供應商佔比。", + modelsTitle: "模型系列", + modelsSubtitle: "依 Token 與請求數統計的模型系列佔比。", + colProvider: "供應商類型", + colModel: "模型系列", + colRequests: "請求數", + colTokens: "Token", + colShare: "Token 佔比", + versionsTitle: "版本分佈", + versionsSubtitle: "各發佈版本的活躍安裝與新增安裝。", + platformsTitle: "平台分佈", + platformsSubtitle: "作業系統與 CPU 架構分佈。", + channelsTitle: "安裝管道", + channelsSubtitle: "回報代理的安裝方式分佈。", + colVersion: "版本", + colPlatform: "平台", + colChannel: "管道", + colInstalls: "安裝量", + colNewInstalls: "新增", + colInstallShare: "佔比", + reliabilityTitle: "可靠性與請求結果", + reliabilitySubtitle: "請求完成率與主要失敗原因(固定錯誤碼)。", + completionRateLabel: "完成率", + outcomeCompleted: "已完成", + outcomeIncomplete: "未完成", + outcomeAborted: "已中止", + outcomeFailed: "失敗", + topErrorsTitle: "主要失敗原因", + colErrorCause: "失敗原因", + colErrorCount: "次數", + noErrorsRecorded: "此時間視窗內無失敗紀錄。", + downloadsTitle: "套件與發行版下載量", + downloadsSubtitle: + "來自 npm (@bitkyc08/opencodex) 與 GitHub Release 資產 (lidge-jun/opencodex) 的即時下載統計。", + npmWeeklyLabel: "npm(最近 7 天)", + npmMonthlyLabel: "npm(最近 30 天)", + npmYearlyLabel: "npm(最近 365 天)", + githubTotalLabel: "GitHub 發行版資產(累計)", + githubLatestLabel: "最新發行版資產", + downloadsUnavailable: "暫不可用", + noDataInTable: "此時間視窗內暫無回報資料。", + creditPrefix: "視覺設計與貢獻熱力圖模式改編自", + creditSuffix: "(MIT 授權條款)。", + }, + ru: { + title: "Дашборд использования", + subtitle: + "Анонимные ежедневные сводки по всему проекту от установок opencodex с включённой телеметрией.", + rangeAria: "Период отчёта", + range7d: "7д", + range30d: "30д", + range90d: "90д", + range365d: "365д", + updatedPrefix: "Обновлено", + updatedPending: "Ожидание снимка", + privacyBadge: "Только анонимные сводки", + privacyBody: + "Отчёты содержат только суточные счётчики фиксированного словаря и ежедневно меняющийся хеш установки — без промптов, ответов моделей, API-ключей, аккаунтов, путей или IP-адресов.", + privacyOptOutHint: + "Отключить можно в любой момент через ocx telemetry disable, OCX_TELEMETRY=0 или DO_NOT_TRACK=1.", + privacyGuideLink: "Руководство по телеметрии и приватности", + stateLoadingTitle: "Загрузка телеметрии проекта…", + stateLoadingBody: + "Получение сводного снимка использования с telemetry.opencodex.me.", + stateEmptyTitle: "Коллектор ещё не развёрнут (Collector not deployed yet)", + stateEmptyBody: + "Публичный коллектор telemetry.opencodex.me ещё не развёрнут или не содержит записей за выбранный период.", + stateErrorTitle: "Не удалось загрузить телеметрию", + stateErrorBody: + "Запрос к telemetry.opencodex.me не завершился (коллектор ещё не развёрнут или временно недоступен).", + retryButton: "Повторить", + kpiSectionAria: "Ключевые метрики использования", + kpiActiveInstalls: "Активные инсталл-дни", + kpiNewInstalls: "Новые установки", + kpiRequests: "Запросы", + kpiTokens: "Всего токенов", + kpiActiveHours: "Активные часы", + tokenMixTitle: "Структура токенов", + tokenMixSubtitle: + "Непересекающиеся категории токенов по всем семействам моделей — в сумме дают 100%.", + tokenInput: "Ввод", + tokenCacheRead: "Чтение кэша", + tokenCacheWrite: "Запись кэша", + tokenOutput: "Вывод", + tokenReasoning: "Рассуждения", + heatmapTitle: "Карта активности за 365 дней", + heatmapSubtitle: + "Ежедневные активные установки за последние 52 недели (UTC).", + heatmapActiveDays: "Активных дней: {count}", + heatmapPeakDay: "Пик: {date} ({count})", + heatmapLess: "Меньше", + heatmapMore: "Больше", + heatmapTooltipUnit: "активных установок", + heatmapKeyboardHint: + "Используйте стрелки для перехода по дням, Home/End для перехода к краям периода и Escape для закрытия подсказки.", + dailyChartTitle: "Ежедневное использование по типам провайдеров", + dailyChartSubtitle: + "Сложенная динамика по дням за выбранный период.", + metricTokens: "Токены", + metricRequests: "Запросы", + providersTitle: "Типы провайдеров", + providersSubtitle: "Доля провайдеров по токенам и запросам.", + modelsTitle: "Семейства моделей", + modelsSubtitle: "Доля семейств моделей по токенам и запросам.", + colProvider: "Провайдер", + colModel: "Семейство моделей", + colRequests: "Запросы", + colTokens: "Токены", + colShare: "Доля токенов", + versionsTitle: "Версии", + versionsSubtitle: + "Активные и новые установки по версиям релиза.", + platformsTitle: "Платформы", + platformsSubtitle: "Распределение по ОС и архитектуре CPU.", + channelsTitle: "Каналы установки", + channelsSubtitle: "Способы установки прокси.", + colVersion: "Версия", + colPlatform: "Платформа", + colChannel: "Канал", + colInstalls: "Установки", + colNewInstalls: "Новые", + colInstallShare: "Доля", + reliabilityTitle: "Надёжность и исходы", + reliabilitySubtitle: + "Доля успешных запросов и основные причины сбоев.", + completionRateLabel: "Доля завершения", + outcomeCompleted: "Завершено", + outcomeIncomplete: "Неполные", + outcomeAborted: "Прервано", + outcomeFailed: "Ошибка", + topErrorsTitle: "Основные причины сбоев", + colErrorCause: "Причина сбоя", + colErrorCount: "Количество", + noErrorsRecorded: "За этот период ошибок не зафиксировано.", + downloadsTitle: "Загрузки пакетов и релизов", + downloadsSubtitle: + "Статистика скачиваний из npm (@bitkyc08/opencodex) и GitHub Releases (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (за 7 дней)", + npmMonthlyLabel: "npm (за 30 дней)", + npmYearlyLabel: "npm (за 365 дней)", + githubTotalLabel: "GitHub Releases (всего)", + githubLatestLabel: "Последний релиз GitHub", + downloadsUnavailable: "Недоступно", + noDataInTable: "Нет данных за выбранный период.", + creditPrefix: + "Визуальный дизайн и тепловая карта активности адаптированы из", + creditSuffix: "(лицензия MIT).", + }, + ja: { + title: "利用統計ダッシュボード", + subtitle: + "オプトインした opencodex インストールから集計されたプロジェクト全体の匿名日次統計です。", + rangeAria: "集計期間", + range7d: "7日", + range30d: "30日", + range90d: "90日", + range365d: "365日", + updatedPrefix: "更新", + updatedPending: "スナップショット待機中", + privacyBadge: "匿名集計のみ", + privacyBody: + "レポートには日替わりインストールハッシュと固定語彙の日次カウンタのみが含まれ、プロンプト、モデル出力、APIキー、アカウント識別子、ファイルパス、IPアドレスは一切含まれません。", + privacyOptOutHint: + "ocx telemetry disable、OCX_TELEMETRY=0、または DO_NOT_TRACK=1 でいつでも無効化できます。", + privacyGuideLink: "テレメトリとプライバシーガイド", + stateLoadingTitle: "プロジェクトテレメトリを読み込み中…", + stateLoadingBody: + "telemetry.opencodex.me から集計スナップショットを取得しています。", + stateEmptyTitle: "コレクターはまだデプロイされていません (Collector not deployed yet)", + stateEmptyBody: + "telemetry.opencodex.me の公開コレクターがまだデプロイされていないか、この期間の日次集計がまだ記録されていません。", + stateErrorTitle: "ライブテレメトリを読み込めませんでした", + stateErrorBody: + "telemetry.opencodex.me へのリクエストが完了しませんでした(コレクターが未デプロイか一時的に到達不能です)。", + retryButton: "再試行", + kpiSectionAria: "主要利用指標", + kpiActiveInstalls: "アクティブインストール日", + kpiNewInstalls: "新規インストール", + kpiRequests: "リクエスト数", + kpiTokens: "総トークン数", + kpiActiveHours: "アクティブ時間", + tokenMixTitle: "トークン内訳", + tokenMixSubtitle: + "入力・キャッシュ読取・キャッシュ書込・出力・推論の互いに素な5区分の比率(合計100%)。", + tokenInput: "入力", + tokenCacheRead: "キャッシュ読取", + tokenCacheWrite: "キャッシュ書込", + tokenOutput: "出力", + tokenReasoning: "推論", + heatmapTitle: "365日アクティビティヒートマップ", + heatmapSubtitle: + "過去52週間の日次アクティブインストール数(UTC)。", + heatmapActiveDays: "アクティブ {count} 日", + heatmapPeakDay: "ピーク: {date} ({count})", + heatmapLess: "少", + heatmapMore: "多", + heatmapTooltipUnit: "アクティブインストール", + heatmapKeyboardHint: + "矢印キーで前後の日に移動し、Home/Endで期間の端へジャンプ、Escapeでツールチップを閉じます。", + dailyChartTitle: "プロバイダー種別ごとの日次利用量", + dailyChartSubtitle: "選択した期間における日次積み上げ推移。", + metricTokens: "トークン", + metricRequests: "リクエスト", + providersTitle: "プロバイダー種別", + providersSubtitle: "トークン数・リクエスト数別のプロバイダーシェア。", + modelsTitle: "モデル系統", + modelsSubtitle: "トークン数・リクエスト数別のモデル系統シェア。", + colProvider: "プロバイダー種別", + colModel: "モデル系統", + colRequests: "リクエスト", + colTokens: "トークン", + colShare: "トークン比率", + versionsTitle: "バージョン", + versionsSubtitle: "リリースバージョン別のアクティブ・新規インストール数。", + platformsTitle: "プラットフォーム", + platformsSubtitle: "OS および CPU アーキテクチャの分布。", + channelsTitle: "インストール経路", + channelsSubtitle: "プロキシのインストール方法別の分布。", + colVersion: "バージョン", + colPlatform: "プラットフォーム", + colChannel: "経路", + colInstalls: "インストール数", + colNewInstalls: "新規", + colInstallShare: "比率", + reliabilityTitle: "信頼性とリクエスト結果", + reliabilitySubtitle: + "リクエスト完了率と主な失敗原因(固定エラーコード)。", + completionRateLabel: "完了率", + outcomeCompleted: "完了", + outcomeIncomplete: "未完了", + outcomeAborted: "中断", + outcomeFailed: "失敗", + topErrorsTitle: "主な失敗原因", + colErrorCause: "失敗原因", + colErrorCount: "件数", + noErrorsRecorded: "この期間に記録された失敗原因はありません。", + downloadsTitle: "パッケージ & リリースダウンロード", + downloadsSubtitle: + "npm (@bitkyc08/opencodex) と GitHub Releases (lidge-jun/opencodex) のライブダウンロード集計。", + npmWeeklyLabel: "npm(過去7日間)", + npmMonthlyLabel: "npm(過去30日間)", + npmYearlyLabel: "npm(過去365日間)", + githubTotalLabel: "GitHub リリースアセット(累計)", + githubLatestLabel: "最新リリースアセット", + downloadsUnavailable: "取得不可", + noDataInTable: "この期間のレポートデータはありません。", + creditPrefix: + "ビジュアルデザインとコントリビューションヒートマップは", + creditSuffix: "(MITライセンス)を参考に構成されています。", + }, + tr: { + title: "Kullanım Paneli", + subtitle: + "Katılımcı opencodex kurulumlarından toplanan proje genelinde anonim günlük istatistikler.", + rangeAria: "Raporlama aralığı", + range7d: "7g", + range30d: "30g", + range90d: "90g", + range365d: "365g", + updatedPrefix: "Güncellendi", + updatedPending: "Anlık görüntü bekleniyor", + privacyBadge: "Yalnızca anonim toplamlar", + privacyBody: + "Raporlar yalnızca günlük dönen kurulum karması ve kapalı sözlük sayaçları içerir; istemler, model çıktıları, API anahtarları, hesap kimlikleri, dosya yolları veya IP adresleri asla gönderilmez.", + privacyOptOutHint: + "İstediğiniz zaman ocx telemetry disable, OCX_TELEMETRY=0 veya DO_NOT_TRACK=1 ile kapatabilirsiniz.", + privacyGuideLink: "Telemetri ve gizlilik kılavuzu", + stateLoadingTitle: "Proje telemetrisi yükleniyor…", + stateLoadingBody: + "telemetry.opencodex.me adresinden toplu kullanım verileri alınıyor.", + stateEmptyTitle: "Toplayıcı henüz devreye alınmadı (Collector not deployed yet)", + stateEmptyBody: + "telemetry.opencodex.me üzerindeki genel toplayıcı henüz dağıtılmadı veya bu aralık için günlük veri kaydedilmedi.", + stateErrorTitle: "Canlı telemetri yüklenemedi", + stateErrorBody: + "telemetry.opencodex.me isteği tamamlanamadı (toplayıcı henüz dağıtılmamış veya geçici olarak erişilemiyor olabilir).", + retryButton: "Yeniden dene", + kpiSectionAria: "Temel kullanım ölçütleri", + kpiActiveInstalls: "Etkin kurulum-gün", + kpiNewInstalls: "Yeni kurulumlar", + kpiRequests: "İstekler", + kpiTokens: "Toplam jeton", + kpiActiveHours: "Etkin saat", + tokenMixTitle: "Jeton dağılımı", + tokenMixSubtitle: + "Giriş, önbellek okuma, önbellek yazma, çıkış ve akıl yürütme ayrık jeton dilimleri — toplamı %100'dür.", + tokenInput: "Giriş", + tokenCacheRead: "Önbellek okuma", + tokenCacheWrite: "Önbellek yazma", + tokenOutput: "Çıkış", + tokenReasoning: "Akıl yürütme", + heatmapTitle: "365 günlük etkinlik ısı haritası", + heatmapSubtitle: + "Son 52주 boyunca günlük rapor veren etkin kurulumlar (UTC).".replace("52주", "52 hafta"), + heatmapActiveDays: "{count} etkin gün", + heatmapPeakDay: "Zirve: {date} ({count})", + heatmapLess: "Düşük", + heatmapMore: "Yüksek", + heatmapTooltipUnit: "etkin kurulum", + heatmapKeyboardHint: + "Günler arasında gezinmek için ok tuşlarını, dönem uçlarına gitmek için Home/End tuşlarını, ipucunu kapatmak için Escape tuşunu kullanın.", + dailyChartTitle: "Sağlayıcı türüne göre günlük kullanım", + dailyChartSubtitle: "Seçilen aralıktaki günlük yığılmış kullanım hacmi.", + metricTokens: "Jeton", + metricRequests: "İstek", + providersTitle: "Sağlayıcı türleri", + providersSubtitle: "Jeton ve isteklere göre sağlayıcı payları.", + modelsTitle: "Model aileleri", + modelsSubtitle: "Jeton ve isteklere göre model ailesi payları.", + colProvider: "Sağlayıcı türü", + colModel: "Model ailesi", + colRequests: "İstek", + colTokens: "Jeton", + colShare: "Jeton payı", + versionsTitle: "Sürümler", + versionsSubtitle: "Sürüme göre etkin ve yeni kurulumlar.", + platformsTitle: "Platformlar", + platformsSubtitle: "İşletim sistemi ve CPU mimarisi dağılımı.", + channelsTitle: "Kurulum kanalları", + channelsSubtitle: "Proxy'lerin nasıl kurulduğu.", + colVersion: "Sürüm", + colPlatform: "Platform", + colChannel: "Kanal", + colInstalls: "Kurulum", + colNewInstalls: "Yeni", + colInstallShare: "Pay", + reliabilityTitle: "Güvenilirlik ve sonuçlar", + reliabilitySubtitle: + "İstek tamamlanma oranı ve başlıca kapalı sözlük hata nedenleri.", + completionRateLabel: "Tamamlanma oranı", + outcomeCompleted: "Tamamlandı", + outcomeIncomplete: "Eksik", + outcomeAborted: "İptal edildi", + outcomeFailed: "Başarısız", + topErrorsTitle: "Başlıca hata nedenleri", + colErrorCause: "Hata nedeni", + colErrorCount: "Sayı", + noErrorsRecorded: "Bu aralıkta hata nedeni kaydedilmedi.", + downloadsTitle: "Paket ve sürüm indirmeleri", + downloadsSubtitle: + "npm (@bitkyc08/opencodex) ve GitHub sürüm varlıklarından (lidge-jun/opencodex) canlı indirme sayıları.", + npmWeeklyLabel: "npm (son 7 gün)", + npmMonthlyLabel: "npm (son 30 gün)", + npmYearlyLabel: "npm (son 365 gün)", + githubTotalLabel: "GitHub sürüm varlıkları (toplam)", + githubLatestLabel: "Son sürüm varlıkları", + downloadsUnavailable: "Kullanılamıyor", + noDataInTable: "Bu aralık için veri bildirilmedi.", + creditPrefix: + "Görsel tasarım ve katkı ısı haritası desenleri şuradan uyarlanmıştır:", + creditSuffix: "(MIT Lisansı).", + }, +}; + +export function resolveTelemetryDashboardLocale( + raw: string | null | undefined, +): TelemetryDashboardLocale { + if (!raw) return "en"; + const normalized = raw.trim().toLowerCase(); + if ((TELEMETRY_DASHBOARD_LOCALES as readonly string[]).includes(normalized)) { + return normalized as TelemetryDashboardLocale; + } + return "en"; +} + +export function getTelemetryDashboardCopy( + locale: string | null | undefined, +): TelemetryDashboardCopy { + return TELEMETRY_DASHBOARD_STRINGS[resolveTelemetryDashboardLocale(locale)]; +} diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 659b463b624..2a80b4c13ad 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -16,5 +16,6 @@ "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "server-telemetry-routes.test.ts": "server", - "cli-telemetry.test.ts": "cli" + "cli-telemetry.test.ts": "cli", + "docs-telemetry-dashboard.test.ts": "ci-workflows" } diff --git a/tests/ci-workflows/docs-telemetry-dashboard.test.ts b/tests/ci-workflows/docs-telemetry-dashboard.test.ts new file mode 100644 index 00000000000..053825a38d1 --- /dev/null +++ b/tests/ci-workflows/docs-telemetry-dashboard.test.ts @@ -0,0 +1,546 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import pkg from "../../package.json"; +import { + INSTALL_CHANNELS, + MODEL_FAMILIES, + PROVIDER_KINDS, + REQUEST_FAILURE_CAUSES, +} from "../../src/telemetry/contract"; +import type { + TelemetryStatsRange as CollectorStatsRange, + TelemetryStatsV1 as CollectorStatsV1, +} from "../../services/telemetry-collector/src/types"; +import { + DEFAULT_TELEMETRY_RANGE, + GITHUB_RELEASES_URL, + GITHUB_REPO_SLUG, + HEATMAP_GRADE_COLORS, + NPM_DOWNLOADS_WEEKLY_URL, + NPM_PACKAGE_NAME, + TELEMETRY_GUIDE_LINK_ENABLED, + TELEMETRY_GUIDE_PATH, + TELEMETRY_RANGES, + TELEMETRY_STATS_ENDPOINT, + TOKSCALE_REPO_URL, + buildChannelRows, + buildDailyProviderChart, + buildHeatmapCalendar, + buildKpiStrip, + buildModelShareRows, + buildPlatformRows, + buildProviderShareRows, + buildReliabilitySummary, + buildStatsUrl, + buildTokenMix, + buildVersionRows, + classifyStatsResponse, + computeHeatmapGrade, + formatActiveHours, + formatCompactNumber, + formatHeatmapCellAriaLabel, + getHeatmapGradeColor, + isStatsSnapshotEmpty, + isValidTelemetryStatsV1, + navigateHeatmapDate, + parseDownloadsPayload, + parseStatsRangeParam, + type TelemetryStatsRange as DashboardStatsRange, + type TelemetryStatsV1 as DashboardStatsV1, +} from "../../docs-site/src/components/telemetry-dashboard/index"; +import { + TELEMETRY_DASHBOARD_LOCALES, + TELEMETRY_DASHBOARD_STRINGS, + getTelemetryDashboardCopy, + resolveTelemetryDashboardLocale, +} from "../../docs-site/src/data/telemetry-dashboard-i18n"; +import { repoPath } from "../helpers/repo-root"; + +type AssertExactType = [A] extends [B] + ? [B] extends [A] + ? true + : never + : never; + +const _assertStatsParity: AssertExactType = true; +const _assertRangeParity: AssertExactType = true; + +function makeFixtureStats(overrides: Partial = {}): DashboardStatsV1 { + const heatmap: DashboardStatsV1["heatmap"] = []; + const startMs = Date.UTC(2025, 10, 9); // 2025-11-09 .. 2026-11-08 (365 days) + for (let i = 0; i < 365; i++) { + const day = new Date(startMs + i * 86_400_000).toISOString().slice(0, 10); + const activeInstalls = + i === 364 + ? 400 + : i === 300 + ? 250 + : i === 200 + ? 120 + : i % 5 === 0 + ? 40 + : 0; + heatmap.push({ day, activeInstalls }); + } + + return { + schemaVersion: 1, + generatedAt: "2026-11-08T00:00:00.000Z", + range: "30d", + totals: { + activeInstalls: 4_820, + newInstalls: 640, + activeMinutes: 187_500, + requests: 1_250_000, + tokens: { + input: 450_000_000, + cacheRead: 1_200_000_000, + cacheWrite: 150_000_000, + output: 120_000_000, + reasoning: 80_000_000, + }, + }, + daily: [ + { + day: "2026-11-06", + activeInstalls: 180, + newInstalls: 24, + activeMinutes: 6_200, + requests: 42_000, + failed: 420, + tokens: 68_000_000, + }, + { + day: "2026-11-07", + activeInstalls: 210, + newInstalls: 31, + activeMinutes: 7_500, + requests: 51_000, + failed: 380, + tokens: 82_000_000, + }, + { + day: "2026-11-08", + activeInstalls: 400, + newInstalls: 45, + activeMinutes: 9_600, + requests: 64_000, + failed: 510, + tokens: 110_000_000, + }, + ], + dailyByProvider: [ + { day: "2026-11-06", providerKind: "anthropic", requests: 22_000, tokens: 38_000_000 }, + { day: "2026-11-06", providerKind: "openai", requests: 14_000, tokens: 20_000_000 }, + { day: "2026-11-06", providerKind: "google", requests: 6_000, tokens: 10_000_000 }, + { day: "2026-11-07", providerKind: "anthropic", requests: 28_000, tokens: 46_000_000 }, + { day: "2026-11-07", providerKind: "openai", requests: 16_000, tokens: 24_000_000 }, + { day: "2026-11-07", providerKind: "google", requests: 7_000, tokens: 12_000_000 }, + { day: "2026-11-08", providerKind: "anthropic", requests: 34_000, tokens: 60_000_000 }, + { day: "2026-11-08", providerKind: "openai", requests: 20_000, tokens: 32_000_000 }, + { day: "2026-11-08", providerKind: "google", requests: 10_000, tokens: 18_000_000 }, + ], + heatmap, + providers: [ + { providerKind: PROVIDER_KINDS[2]!, requests: 640_000, tokens: 1_100_000_000 }, + { providerKind: PROVIDER_KINDS[0]!, requests: 410_000, tokens: 620_000_000 }, + { providerKind: PROVIDER_KINDS[4]!, requests: 200_000, tokens: 280_000_000 }, + ], + models: [ + { modelFamily: MODEL_FAMILIES[6]!, requests: 520_000, tokens: 900_000_000 }, + { modelFamily: MODEL_FAMILIES[1]!, requests: 410_000, tokens: 620_000_000 }, + { modelFamily: MODEL_FAMILIES[8]!, requests: 200_000, tokens: 280_000_000 }, + { modelFamily: MODEL_FAMILIES[5]!, requests: 120_000, tokens: 200_000_000 }, + ], + versions: [ + { version: "2.99.0", installs: 3_100, newInstalls: 420 }, + { version: "2.98.0", installs: 1_220, newInstalls: 150 }, + { version: "2.97.0", installs: 500, newInstalls: 70 }, + ], + platforms: [ + { os: "darwin", arch: "arm64", installs: 2_600 }, + { os: "linux", arch: "x64", installs: 1_420 }, + { os: "win32", arch: "x64", installs: 800 }, + ], + channels: [ + { installChannel: INSTALL_CHANNELS[0]!, installs: 2_400, newInstalls: 310 }, + { installChannel: INSTALL_CHANNELS[2]!, installs: 1_420, newInstalls: 190 }, + { installChannel: INSTALL_CHANNELS[5]!, installs: 1_000, newInstalls: 140 }, + ], + outcomes: { + completed: 1_210_000, + incomplete: 18_000, + aborted: 9_500, + failed: 12_500, + }, + errors: [ + { cause: REQUEST_FAILURE_CAUSES[3]!, count: 6_200 }, + { cause: REQUEST_FAILURE_CAUSES[11]!, count: 3_800 }, + { cause: REQUEST_FAILURE_CAUSES[4]!, count: 2_500 }, + ], + ...overrides, + }; +} + +describe("docs telemetry dashboard route and navigation", () => { + test("dashboard.mdx exists as a splash page mounting TelemetryDashboard.astro", () => { + expect(_assertStatsParity).toBe(true); + expect(_assertRangeParity).toBe(true); + + const mdxPath = repoPath("docs-site/src/content/docs/dashboard.mdx"); + expect(existsSync(mdxPath)).toBe(true); + const mdx = readFileSync(mdxPath, "utf8"); + expect(mdx).toContain("template: splash"); + expect(mdx).toContain("import TelemetryDashboard from '../../components/TelemetryDashboard.astro'"); + expect(mdx).toContain(""); + }); + + test("Header.astro links /dashboard/ in both the desktop nav and the splash mobile menu", () => { + const header = readFileSync(repoPath("docs-site/src/components/Header.astro"), "utf8"); + expect(header).toContain("href: `${prefix}dashboard/`"); + const desktopMatches = header.match(/href={dashboard.href}/g) ?? []; + expect(desktopMatches.length).toBeGreaterThanOrEqual(2); + expect(header).toContain('{dashboard.label}'); + expect(header).toContain('{dashboard.label}'); + }); + + test("constants match collector endpoint, root package.json name, GitHub repo, and tokscale credit", () => { + expect(TELEMETRY_STATS_ENDPOINT).toBe("https://telemetry.opencodex.me/v1/stats"); + expect(TELEMETRY_RANGES).toEqual(["7d", "30d", "90d", "365d"]); + expect(DEFAULT_TELEMETRY_RANGE).toBe("30d"); + expect(NPM_PACKAGE_NAME).toBe(pkg.name); + expect(NPM_DOWNLOADS_WEEKLY_URL).toContain(pkg.name); + expect(GITHUB_REPO_SLUG).toBe("lidge-jun/opencodex"); + expect(GITHUB_RELEASES_URL).toContain("lidge-jun/opencodex"); + expect(TOKSCALE_REPO_URL).toBe("https://github.com/junhoyeo/tokscale"); + expect(TELEMETRY_GUIDE_PATH).toBe("guides/telemetry/"); + expect(typeof TELEMETRY_GUIDE_LINK_ENABLED).toBe("boolean"); + + const guideExists = existsSync(repoPath("docs-site/src/content/docs/guides/telemetry.md")); + if (!guideExists) { + expect(TELEMETRY_GUIDE_LINK_ENABLED).toBe(false); + } + }); +}); + +describe("telemetry dashboard formatting and bucketing helpers", () => { + test("formatCompactNumber formats K/M/B/T and promotes boundary values without 1000.0K artifacts", () => { + expect(formatCompactNumber(-5)).toBe("0"); + expect(formatCompactNumber(0)).toBe("0"); + expect(formatCompactNumber(999)).toBe("999"); + expect(formatCompactNumber(1_250)).toBe("1.3K"); + expect(formatCompactNumber(999_960)).toBe("1.0M"); + expect(formatCompactNumber(1_250_000)).toBe("1.3M"); + expect(formatCompactNumber(999_960_000)).toBe("1.0B"); + expect(formatCompactNumber(2_000_000_000)).toBe("2.0B"); + expect(formatCompactNumber(1_500_000_000_000)).toBe("1.5T"); + }); + + test("buildKpiStrip and buildTokenMix format and bucket a fixture TelemetryStatsV1", () => { + const fixture = makeFixtureStats(); + const kpis = buildKpiStrip(fixture, "en-US"); + expect(kpis).toEqual([ + { id: "activeInstalls", rawValue: 4_820, compact: "4.8K", exactTitle: "4,820" }, + { id: "newInstalls", rawValue: 640, compact: "640", exactTitle: "640" }, + { id: "requests", rawValue: 1_250_000, compact: "1.3M", exactTitle: "1,250,000" }, + { id: "tokens", rawValue: 2_000_000_000, compact: "2.0B", exactTitle: "2,000,000,000" }, + { + id: "activeHours", + rawValue: 187_500, + compact: "3.1Kh", + exactTitle: "3,125h (187,500 min)", + }, + ]); + + expect(formatActiveHours(0)).toEqual({ + hours: 0, + compact: "0h", + exactTitle: "0h (0 min)", + }); + + const mix = buildTokenMix(fixture.totals.tokens, "en-US"); + expect(mix.totalTokens).toBe(2_000_000_000); + expect(mix.totalCompact).toBe("2.0B"); + expect(mix.totalExactTitle).toBe("2,000,000,000"); + expect(mix.segments.map((s) => s.id)).toEqual([ + "input", + "cacheRead", + "cacheWrite", + "output", + "reasoning", + ]); + const sumValues = mix.segments.reduce((acc, s) => acc + s.value, 0); + const sumPct = mix.segments.reduce((acc, s) => acc + s.percent, 0); + expect(sumValues).toBe(mix.totalTokens); + expect(sumPct).toBeCloseTo(100, 5); + expect(mix.segments[1]).toMatchObject({ + id: "cacheRead", + compact: "1.2B", + exactTitle: "1,200,000,000", + percentLabel: "60.0%", + }); + }); + + test("parseStatsRangeParam and buildStatsUrl handle valid and fallback ranges", () => { + expect(parseStatsRangeParam("7d")).toBe("7d"); + expect(parseStatsRangeParam("30d")).toBe("30d"); + expect(parseStatsRangeParam("90d")).toBe("90d"); + expect(parseStatsRangeParam("365d")).toBe("365d"); + expect(parseStatsRangeParam("invalid")).toBe("30d"); + expect(parseStatsRangeParam(null)).toBe("30d"); + expect(buildStatsUrl("90d")).toBe("https://telemetry.opencodex.me/v1/stats?range=90d"); + }); + + test("computeHeatmapGrade and buildHeatmapCalendar grade and lay out 365 days into 52/53 weeks", () => { + expect(computeHeatmapGrade(0, 400)).toBe(0); + expect(computeHeatmapGrade(50, 0)).toBe(0); + expect(computeHeatmapGrade(1, 400)).toBe(1); + expect(computeHeatmapGrade(99, 400)).toBe(1); + expect(computeHeatmapGrade(100, 400)).toBe(2); + expect(computeHeatmapGrade(199, 400)).toBe(2); + expect(computeHeatmapGrade(200, 400)).toBe(3); + expect(computeHeatmapGrade(299, 400)).toBe(3); + expect(computeHeatmapGrade(300, 400)).toBe(4); + expect(computeHeatmapGrade(400, 400)).toBe(4); + + for (const grade of [0, 1, 2, 3, 4] as const) { + expect(getHeatmapGradeColor("light", grade)).toBe(HEATMAP_GRADE_COLORS.light[grade]); + expect(getHeatmapGradeColor("dark", grade)).toBe(HEATMAP_GRADE_COLORS.dark[grade]); + } + + const fixture = makeFixtureStats(); + const calendar = buildHeatmapCalendar(fixture.heatmap); + expect(calendar.weekCount).toBeGreaterThanOrEqual(52); + expect(calendar.weekCount).toBeLessThanOrEqual(54); + expect(calendar.cells.length).toBe(calendar.weekCount * 7); + expect(calendar.startDate).toBe("2025-11-09"); + expect(calendar.endDate).toBe("2026-11-08"); + expect(calendar.maxActiveInstalls).toBe(400); + expect(calendar.peakDay).toEqual({ date: "2026-11-08", activeInstalls: 400 }); + expect(calendar.activeDays).toBeGreaterThan(70); + expect(calendar.monthMarkers.length).toBeGreaterThanOrEqual(10); + + const selectable = calendar.cells.filter((c) => c.selectable); + expect(selectable.length).toBe(365); + const peakCell = selectable.find((c) => c.date === "2026-11-08")!; + expect(peakCell.grade).toBe(4); + expect(formatHeatmapCellAriaLabel(peakCell, "en-US")).toBe( + "2026-11-08: 400 active installs (grade 4)", + ); + + // Keyboard navigation across selectable days + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "Home")).toBe("2025-11-09"); + expect(navigateHeatmapDate(calendar.cells, "2025-11-09", "End")).toBe("2026-11-08"); + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "ArrowUp")).toBe("2026-11-07"); + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "ArrowLeft")).toBe("2026-11-01"); + expect(navigateHeatmapDate(calendar.cells, "2025-11-09", "ArrowDown")).toBe("2025-11-10"); + }); + + test("buildDailyProviderChart, share tables, install tables, and reliability summary bucket fixture data", () => { + const fixture = makeFixtureStats(); + const chartTokens = buildDailyProviderChart( + fixture.dailyByProvider, + fixture.daily, + "tokens", + 2, + ); + expect(chartTokens.days).toEqual(["2026-11-06", "2026-11-07", "2026-11-08"]); + // Top 2 providers ("anthropic", "openai") plus folded "other" ("google") + expect(chartTokens.series.map((s) => s.providerKind)).toEqual([ + "anthropic", + "openai", + "other", + ]); + expect(chartTokens.series[0]?.values).toEqual([38_000_000, 46_000_000, 60_000_000]); + expect(chartTokens.series[2]?.values).toEqual([10_000_000, 12_000_000, 18_000_000]); + + const providerRows = buildProviderShareRows(fixture.providers); + expect(providerRows[0]).toMatchObject({ + key: "anthropic", + requestsCompact: "640.0K", + tokensCompact: "1.1B", + tokenShareLabel: "55.0%", + }); + + const modelRows = buildModelShareRows(fixture.models); + expect(modelRows[0]).toMatchObject({ + key: "claude-sonnet", + tokensCompact: "900.0M", + tokenShareLabel: "45.0%", + }); + + const versionRows = buildVersionRows(fixture.versions); + expect(versionRows[0]).toMatchObject({ + key: "2.99.0", + label: "v2.99.0", + installsCompact: "3.1K", + newInstallsCompact: "420", + }); + + const platformRows = buildPlatformRows(fixture.platforms); + expect(platformRows[0]).toMatchObject({ + key: "darwin/arm64", + installsCompact: "2.6K", + }); + + const channelRows = buildChannelRows(fixture.channels); + expect(channelRows[0]).toMatchObject({ + key: "npm-global", + installsCompact: "2.4K", + newInstallsCompact: "310", + }); + + const reliability = buildReliabilitySummary(fixture.outcomes, fixture.errors); + expect(reliability.totalRequests).toBe(1_250_000); + expect(reliability.completedRatioLabel).toBe("96.8%"); + expect(reliability.totalErrors).toBe(12_500); + expect(reliability.errors[0]).toMatchObject({ + cause: "rate-limit", + compact: "6.2K", + shareLabel: "49.6%", + }); + }); + + test("classifyStatsResponse and parseDownloadsPayload handle ready, empty, error, and download fallbacks", () => { + const fixture = makeFixtureStats(); + expect(isValidTelemetryStatsV1(fixture)).toBe(true); + expect(isStatsSnapshotEmpty(fixture)).toBe(false); + + const ready = classifyStatsResponse({ ok: true, status: 200, body: fixture }); + expect(ready.state).toBe("ready"); + expect(ready.reason).toBe("ok"); + + // Not deployed yet (HTTP 404 / 503) -> explicit empty state + const notDeployed = classifyStatsResponse({ ok: false, status: 404, body: null }); + expect(notDeployed).toEqual({ + state: "empty", + stats: null, + reason: "collector-not-deployed", + detail: "HTTP 404", + }); + + // Zeroed stats snapshot -> explicit empty state + const emptySnapshot = makeFixtureStats({ + totals: { + activeInstalls: 0, + newInstalls: 0, + activeMinutes: 0, + requests: 0, + tokens: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + }, + heatmap: [{ day: "2026-11-08", activeInstalls: 0 }], + }); + expect(classifyStatsResponse({ ok: true, status: 200, body: emptySnapshot })).toMatchObject({ + state: "empty", + reason: "no-reports-yet", + }); + + // Server error / network error / invalid schema -> explicit error state + expect(classifyStatsResponse({ ok: false, status: 500, body: {} })).toMatchObject({ + state: "error", + reason: "http-error", + }); + expect( + classifyStatsResponse({ networkError: true, errorMessage: "Failed to fetch" }), + ).toMatchObject({ + state: "error", + reason: "network-error", + detail: "Failed to fetch", + }); + expect( + classifyStatsResponse({ ok: true, status: 200, body: { schemaVersion: 999 } }), + ).toMatchObject({ + state: "error", + reason: "invalid-payload", + }); + + // Downloads parser with live-shaped payloads and graceful null fallback + const dl = parseDownloadsPayload({ + npmWeekly: { downloads: 18_420 }, + npmMonthly: { downloads: 74_900 }, + npmYearly: { downloads: 612_300 }, + githubReleases: [ + { + tag_name: "v2.99.0", + draft: false, + assets: [{ download_count: 1_200 }, { download_count: 850 }], + }, + { + tag_name: "v2.98.0", + draft: false, + assets: [{ download_count: 3_400 }], + }, + { + tag_name: "v3.0.0-draft", + draft: true, + assets: [{ download_count: 99_999 }], + }, + ], + }); + expect(dl).toEqual({ + npmWeekly: 18_420, + npmMonthly: 74_900, + npmYearly: 612_300, + githubTotalDownloads: 5_450, + githubLatestTag: "v2.99.0", + githubLatestDownloads: 2_050, + githubReleaseCount: 2, + }); + + expect(parseDownloadsPayload({})).toEqual({ + npmWeekly: null, + npmMonthly: null, + npmYearly: null, + githubTotalDownloads: null, + githubLatestTag: null, + githubLatestDownloads: null, + githubReleaseCount: 0, + }); + }); + + test("all 8 site locales carry complete dashboard copy and component markup contains no secrets", () => { + expect(TELEMETRY_DASHBOARD_LOCALES).toEqual([ + "en", + "fr", + "ko", + "zh-cn", + "zh-tw", + "ru", + "ja", + "tr", + ]); + const enKeys = Object.keys(TELEMETRY_DASHBOARD_STRINGS.en).sort(); + for (const locale of TELEMETRY_DASHBOARD_LOCALES) { + const copy = getTelemetryDashboardCopy(locale); + expect(Object.keys(copy).sort()).toEqual(enKeys); + for (const [k, v] of Object.entries(copy)) { + expect(typeof v, `${locale}.${k}`).toBe("string"); + expect(v.trim().length, `${locale}.${k} non-empty`).toBeGreaterThan(0); + } + } + expect(resolveTelemetryDashboardLocale("KO")).toBe("ko"); + expect(resolveTelemetryDashboardLocale("unknown")).toBe("en"); + + const astroSource = readFileSync( + repoPath("docs-site/src/components/TelemetryDashboard.astro"), + "utf8", + ); + expect(astroSource).toContain('data-state-panel="loading"'); + expect(astroSource).toContain('data-state-panel="empty"'); + expect(astroSource).toContain('data-state-panel="error"'); + expect(astroSource).toContain("junhoyeo/tokscale"); + + const helperDir = repoPath("docs-site/src/components/telemetry-dashboard"); + const scannedFiles = [ + repoPath("docs-site/src/content/docs/dashboard.mdx"), + repoPath("docs-site/src/components/TelemetryDashboard.astro"), + repoPath("docs-site/src/data/telemetry-dashboard-i18n.ts"), + ...readdirSync(helperDir).map((name) => repoPath("docs-site/src/components/telemetry-dashboard", name)), + ]; + const secretPattern = /(?:sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{20,}|AIza[0-9A-Za-z-_]{35}|Bearer\s+[A-Za-z0-9._-]{20,})/; + for (const file of scannedFiles) { + const text = readFileSync(file, "utf8"); + expect(secretPattern.test(text), `secret pattern in ${file}`).toBe(false); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 808cb8d39a3..24d508493ea 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1623,5 +1623,5 @@ "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", "sse-payload-rewrite-continuation.test.ts": "responses", - "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" + "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli", "docs-telemetry-dashboard.test.ts": "ci-workflows" } From e32c183aa2819cbce3083ab6a08df260d169e35d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 02:31:46 +0900 Subject: [PATCH 14/63] fix(docs-site): resolve telemetry dashboard review findings and header clipping --- docs-site/src/components/Header.astro | 41 +++-- .../src/components/TelemetryDashboard.astro | 96 +++++++--- .../components/telemetry-dashboard/client.ts | 108 ++++++++--- .../telemetry-dashboard/constants.ts | 5 - .../components/telemetry-dashboard/heatmap.ts | 46 ++++- .../components/telemetry-dashboard/state.ts | 145 ++++++++++++++- .../components/telemetry-dashboard/tables.ts | 142 +++++++++++---- docs-site/src/content/docs/dashboard.mdx | 2 + .../src/content/docs/guides/telemetry.md | 2 +- .../src/data/telemetry-dashboard-i18n.ts | 46 ++--- .../docs-telemetry-dashboard.test.ts | 167 +++++++++++++++++- 11 files changed, 665 insertions(+), 135 deletions(-) diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 1c52dac9d84..7457c6d4210 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -299,7 +299,7 @@ const contributing: NavLink = {