From 01511b8b25e5dd21eb1fbc66ea00a0d60d67f548 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 15:14:24 +0900 Subject: [PATCH 1/3] feat(telemetry): report safe app setting values --- CHANGELOG.md | 4 + docs/guide.md | 6 +- scripts/telemetry-regression.ts | 188 +++++++++++++++++++++++++++ scripts/ui-regression.ts | 2 + server/AGENTS.md | 2 +- server/api.contract.test.ts | 50 +++++-- server/telemetry.test.ts | 179 ++++++++++++++++++------- server/telemetry.ts | 79 ++++++++--- shared/protocol.ts | 6 +- shared/telemetry.ts | 174 ++++++++++++++++++++++--- src/components/TelemetryControls.tsx | 74 ++++++++--- src/lib/api.test.ts | 54 +++++++- src/lib/api.ts | 4 +- src/lib/i18n.ja.ts | 4 +- src/lib/i18n.ko.ts | 4 +- src/lib/i18n.tr.ts | 4 +- src/lib/i18n.zh.ts | 4 +- src/lib/settings.test.ts | 125 +++++++++++++++++- src/lib/settings.ts | 71 +++++++++- telemetry/README.md | 74 +++++++---- telemetry/schema.sql | 7 +- telemetry/worker.test.ts | 159 ++++++++++++++++------ telemetry/worker.ts | 31 +++-- 23 files changed, 1090 insertions(+), 215 deletions(-) create mode 100644 scripts/telemetry-regression.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20475ceff9..660aafe9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#606](https://github.com/devswha/herdr-web-ui/pull/606) by @sobir-git) ### Changed +- Install and update counts include app setting values such as theme, text size and notification + choices, rather than only the names of changed appearance settings. Custom text and account + identifiers stay out; custom choices use flags or counts. The expanded notice is shown again + before sending, and existing opt-outs and install/update-only delivery stay unchanged. - Markdown file previews are parsed in a worker with a 2 s budget and a 60 000-element limit, keeping the existing HTML blocks, images and reader-page navigation. A preview that cannot be drawn offers its source. Partial text and preview, highlighting or line-number limits are diff --git a/docs/guide.md b/docs/guide.md index 112b1ec876..a85d2b87b1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -470,9 +470,11 @@ This is for Linux and macOS. On Windows, and for a remote PC, update herdr on th ## Anonymous usage counts -The app tells its maintainer how many installs there are, which versions they run, which countries they are in and which appearance settings people change from their defaults, and nothing else. It sends one small message when it is first installed and one each time it runs a new version, never in between. The first time you open the app, a line under the header says so, with **What is sent** and **Turn off**. Nothing is sent before that line has been shown, and the first message waits ten minutes after it, so **Turn off** on that line stops it. An install that saw the line before it mentioned the settings sees it once more, and sends nothing until then. +The app tells its maintainer how many installs there are, which versions they run, which countries the messages come from and which app settings people choose. It sends one message when it is first installed and one each time it runs a new version, never in between. The first time you open the app, a line under the header says so, with **What is sent** and **Turn off**. Nothing is sent before that line has been shown, and the first message waits ten minutes after it, so **Turn off** on that line stops it. An install that saw an older notice sees the expanded-settings notice once more, and sends nothing until then. An existing choice to turn counts off stays off. -A message holds the event (`install` or `update`), a random ID made on this PC, the app's version and the version an update replaced, the OS and CPU architecture, how the app was installed (herdr plugin, `bun run start`, or `bun run server`), and the names of the appearance settings changed from their defaults in the browser that last opened the app: theme, density, colors, sidebar rows, Agents order, workspace cards, pin agents, plan flow and the plan count in the header. It holds the names only, never what a setting is set to, and never a setting you type (a font, a shortcut, the key bar). Nothing about your terminals, agents, files, workspaces or accounts is in it. The receiver ([`telemetry/`](../telemetry/README.md)) keeps those fields, the day and the country the message came from (two letters, such as `KR`, worked out by its host from the connection), and never stores your IP address or anything finer than the country. +A message holds the event (`install` or `update`), a random ID made on this PC, the app's version and the version an update replaced, the OS and CPU architecture, how the app was installed (herdr plugin, `bun run start`, or `bun run server`), and an allowlisted `settings` snapshot. The snapshot contains actual choices for appearance, terminal and chat behavior, notifications, language, voice controls, usage-meter display and the file viewer, including defaults. Custom fonts, shortcuts, key-bar layouts and quick replies are represented by customization flags, not their text or key combinations; quick replies and hidden usage accounts also have counts. Account identifiers and ordering, paths, addresses, credentials, commands, terminal content, conversations and files are not collected. + +The snapshot is the last one a browser reported to this server, not a list of devices or a complete picture of a person. Changing a setting updates that local snapshot but does not send another telemetry event. Before a browser reports, `settings` is `null`. The receiver ([`telemetry/`](../telemetry/README.md)) keeps the event fields, the day and the country the message came from (two letters, such as `KR`, worked out by its host from the connection), and never stores your IP address or anything finer than the country. The random install ID and settings can link events from an installation; they do not guarantee anonymity. **Settings → About → Anonymous usage counts** shows the next message exactly as it would be sent, and its switch turns the counts off for this PC. `HERDR_WEB_TELEMETRY=0` or `DO_NOT_TRACK=1` in the server's environment turns them off whatever the switch says, and they are always off in CI. A remote PC's bridge sends nothing. diff --git a/scripts/telemetry-regression.ts b/scripts/telemetry-regression.ts new file mode 100644 index 0000000000..c125c9e677 --- /dev/null +++ b/scripts/telemetry-regression.ts @@ -0,0 +1,188 @@ +/** The real telemetry UI and local API, with a fixture-only transport. Run after `bun run build`. */ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Browser, BrowserContext } from "playwright-core"; +import { NOTICE_VERSION, type TelemetryChange, type TelemetryEvent, type TelemetryStatus } from "../shared/telemetry.ts"; +import { createServer } from "../server/index.ts"; +import { Telemetry } from "../server/telemetry.ts"; +import { UsageService } from "../server/usage.ts"; +import { openSettingsPage } from "./settings-page.ts"; + +const SEND_GRACE_MS = 24 * 60 * 60 * 1_000; + +async function until(check: () => boolean | Promise, label: string): Promise { + const deadline = Date.now() + 15_000; + while (!(await check())) { + if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`); + await Bun.sleep(50); + } +} + +async function statusAt(origin: string): Promise { + const response = await fetch(`${origin}/api/telemetry`); + assert.equal(response.status, 200, "the owned local server serves the telemetry API"); + return await response.json() as TelemetryStatus; +} + +function fixtureSettings() { + return { + language: "en", + palette: "forest", + terminalInputMode: "line", + terminalFontSize: 17, + chatFontSize: 19, + voiceLanguage: "en-GB", + terminalFontFamily: "Fixture Terminal Font Canary", + chatFontFamily: "Fixture Chat Font Canary", + quickReplies: ["Fixture quick reply canary"], + usageOrder: ["fictional-account-order-canary"], + usageHidden: ["fictional-account-id-canary"], + }; +} + +/** A real app page and local API; the injected transport cannot reach the production receiver. */ +export async function checkTelemetrySettings(browser: Browser): Promise { + const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-telemetry-regression-")); + const serverState = join(root, "server-state"); + const telemetryState = join(root, "telemetry-state"); + const sent: TelemetryEvent[] = []; + const changes: TelemetryChange[] = []; + const fixture = fixtureSettings(); + let fixtureNow = Date.now(); + const privateMarkers = [ + fixture.quickReplies[0]!, + fixture.terminalFontFamily, + fixture.chatFontFamily, + fixture.usageOrder[0]!, + fixture.usageHidden[0]!, + ]; + let telemetry: Telemetry | undefined; + let server: ReturnType | undefined; + let context: BrowserContext | undefined; + try { + mkdirSync(telemetryState, { recursive: true }); + writeFileSync(join(telemetryState, "telemetry.json"), JSON.stringify({ + install_id: randomUUID(), + enabled: true, + notice_seen_at: new Date(0).toISOString(), + notice_version: NOTICE_VERSION - 1, + reported_version: null, + settings: null, + }), { mode: 0o600 }); + + telemetry = new Telemetry({ + stateDir: telemetryState, + version: "0.0.0-telemetry-fixture", + env: {}, + fetch: (async (_input: string | URL | Request, init?: RequestInit) => { + if (init?.body) sent.push(JSON.parse(String(init.body)) as TelemetryEvent); + return new Response(null, { status: 204 }); + }) as typeof fetch, + now: () => fixtureNow, + noticeGraceMs: SEND_GRACE_MS, + }); + server = createServer({ + port: 0, + hostname: "127.0.0.1", + token: "", + stateDir: serverState, + telemetry, + usage: new UsageService(undefined, []), + registerBridge: false, + }); + await server.statusReady; + const origin = `http://127.0.0.1:${server.port}`; + const initial = await statusAt(origin); + assert.equal(initial.enabled, true); + assert.equal(initial.notice_seen, false, "a persisted older notice version needs reacknowledgement"); + + context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" }); + await context.addInitScript((settings) => { + localStorage.setItem("herdr-web-ui:settings", JSON.stringify(settings)); + }, fixture); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (request.method() !== "POST" || new URL(request.url()).pathname !== "/api/telemetry") return; + changes.push(request.postDataJSON() as TelemetryChange); + }); + + await page.goto(origin); + const notice = page.locator(".update-notice").filter({ hasText: "herdr web ui sends install and update counts" }).first(); + await notice.waitFor({ state: "visible" }); + await until(async () => (await statusAt(origin)).notice_seen, "the visible notice is reacknowledged"); + assert.ok(changes.some((change) => change.notice_seen === true && change.notice_version === NOTICE_VERSION), "the browser acknowledges exactly the current notice version"); + await until(async () => { + const settings = (await statusAt(origin)).next?.settings; + return settings?.palette === "forest" && settings.terminalFontSize === 17; + }, "the server receives the full initial settings snapshot"); + + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + const dialog = page.locator(".settings-dialog"); + await dialog.waitFor({ state: "visible" }); + await openSettingsPage(page, "About"); + const telemetrySection = dialog.locator(".settings-telemetry"); + await telemetrySection.locator("summary").click(); + const preview = telemetrySection.locator("pre.update-output"); + await preview.waitFor({ state: "visible" }); + const previewText = await preview.innerText(); + const event = JSON.parse(previewText) as TelemetryEvent; + assert.equal(event.settings?.palette, "forest", "the preview shows the selected palette enum"); + assert.equal(event.settings?.terminalInputMode, "line", "the preview shows the selected input-mode enum"); + assert.equal(event.settings?.terminalFontSize, 17, "the preview shows the selected numeric font size"); + assert.equal(event.settings?.chatFontSize, 19); + assert.equal(event.settings?.voiceLanguage, "en-GB"); + assert.equal(event.settings?.terminalFontCustom, true); + assert.equal(event.settings?.chatFontCustom, true); + assert.equal(event.settings?.quickRepliesCustom, true); + assert.equal(event.settings?.quickReplyCount, 1); + assert.equal(event.settings?.usageHiddenCount, 1); + for (const marker of privateMarkers) { + assert.equal(previewText.includes(marker), false, `the preview omits private fixture value ${marker}`); + } + + await openSettingsPage(page, "Appearance"); + const palette = page.locator("#settings-palette"); + assert.equal(await palette.inputValue(), "forest"); + await palette.selectOption("lilac"); + await until(async () => { + const settings = (await statusAt(origin)).next?.settings; + return settings?.palette === "lilac"; + }, "a non-default palette change reaches the server snapshot"); + for (const marker of privateMarkers) { + assert.equal(JSON.stringify(changes).includes(marker), false, `the browser submission omits private fixture value ${marker}`); + } + + await openSettingsPage(page, "About"); + await telemetrySection.locator("summary").click(); + const updatedPreview = JSON.parse(await preview.innerText()) as TelemetryEvent; + assert.equal(updatedPreview.settings?.palette, "lilac", "the preview refreshes with the server's updated snapshot"); + await dialog.getByRole("button", { name: "Close settings", exact: true }).click(); + await notice.getByRole("button", { name: "Turn off", exact: true }).click(); + await until(async () => !(await statusAt(origin)).enabled, "Turn off cancels the pending event"); + await notice.waitFor({ state: "hidden" }); + fixtureNow += SEND_GRACE_MS + 1; + await telemetry.report(); + assert.deepEqual(sent, [], "the grace period and fixture-only transport prevent outgoing telemetry"); + assert.deepEqual(errors, []); + console.log("PASS telemetry settings: current notice reacknowledged, safe preview and snapshot update, opt-out stops pending delivery"); + } finally { + await context?.close(); + telemetry?.stop(); + server?.stop(); + rmSync(root, { recursive: true, force: true }); + } +} + +// Focused QA entry point, e.g. `bun run check run --build bun scripts/telemetry-regression.ts`. +if (import.meta.main) { + const { chromium } = await import("playwright-core"); + const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? chromium.executablePath(), headless: true, args: ["--no-sandbox", "--accept-lang=en-US"] }); + try { await checkTelemetrySettings(browser); } + finally { await browser.close(); } +} diff --git a/scripts/ui-regression.ts b/scripts/ui-regression.ts index b80d06bd17..28a5d84714 100644 --- a/scripts/ui-regression.ts +++ b/scripts/ui-regression.ts @@ -41,6 +41,7 @@ import { checkCommandArrows } from "./terminal-command-arrows-regression.ts"; import { checkFolderFilter } from "./folder-filter-regression.ts"; import { checkPaletteKeys } from "./palette-keys-regression.ts"; import { checkUpdateNotice } from "./update-notice-regression.ts"; +import { checkTelemetrySettings } from "./telemetry-regression.ts"; import { UsageService } from "../server/usage.ts"; import { openSettingsPage } from "./settings-page.ts"; import { assertCspClean, watchCsp } from "./csp-violations.ts"; @@ -870,6 +871,7 @@ try { await checkBackgroundTabKeepsTerminalSize(browser, origin); await checkFocusedWindowReclaimsTerminalSize(browser, origin); await checkUpdateNotice(browser, origin); + await checkTelemetrySettings(browser); // The pending protocol has a real foreground agent and an owned byte recorder: merely // reporting an idle shell as working would not prove that server-side queueing happened. diff --git a/server/AGENTS.md b/server/AGENTS.md index 46a1573965..47a51c0516 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -78,7 +78,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | Subscription plan limits | usage.ts + GET /api/usage in index.ts | read-only, from each CLI's own sign-in files; a provider is asked only when a client asks, at most every FRESH_MS unless forced, and a forced ask no more than once per MIN_REFRESH_MS. Where each sign-in lives follows OpenUsage | | Voice input | voice.ts + /api/voice, /api/voice/config, /api/voice/transcribe in index.ts | BYOK OpenAI key in `stateDir/voice.json` (0600) or `HERDR_WEB_OPENAI_API_KEY`; the route raises Bun's request timeout to 120 s; the transcript streams back as NDJSON and the provider picks its decoder from the file name, so the browser's container has to survive the multipart framing | | Mirrored terminals | mirror.ts + mirror-input.ts | for a herdr with no `terminal attach`: Windows (herdrdev/herdr#4821) and any bridge runtime that ships no PTY sidecar | -| Anonymous install/update counts | telemetry.ts + /api/telemetry in index.ts; shared/telemetry.ts; receiver in telemetry/ | one event per install and per new version, never before the app's current notice (`NOTICE_VERSION`; a file that saw an older one counts as unseen) was shown (`notice_seen`) and NOTICE_GRACE_MS has passed since, so the notice's Turn off still stops it; turning the switch off aborts a send in flight, and the state file is re-read before each decision (two servers can share it); never while the switch is off or `telemetryBlocked` (HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK, CI, test env); only the real entrypoint passes a `Telemetry`, so tests and remote bridges answer 404 and send nothing. A failed send is retried at the next start, never in a loop. An event carries `changed_settings`: the last list a browser POSTed, names from `REPORTED_SETTINGS` only (anything else is a 400); telling it sends nothing by itself, and a new reported setting goes into shared/telemetry.ts and the Worker's `SETTINGS` together | +| Anonymous install/update counts | telemetry.ts + /api/telemetry in index.ts; shared/telemetry.ts; receiver in telemetry/ | one event per install and per new version, never before the app's current notice (`NOTICE_VERSION`; a file that saw an older one counts as unseen) was shown (`notice_seen`) and NOTICE_GRACE_MS has passed since, so the notice's Turn off still stops it; turning the switch off aborts a send in flight, and the state file is re-read before each decision (two servers can share it); never while the switch is off or `telemetryBlocked` (HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK, CI, test env); only the real entrypoint passes a `Telemetry`, so tests and remote bridges answer 404 and send nothing. A failed send is retried at the next start, never in a loop. An event carries `settings`: the last browser's allowlisted scalar snapshot, validated by shared/telemetry.ts on both sender and receiver. Raw custom text and account identifiers never enter it; custom choices use flags or counts. Reporting settings to the local server sends no event by itself. Expand the notice version when the disclosed collection scope changes | | What an update brings | release-notes.ts | CHANGELOG.md sections plus `release-summaries.json`, newest first and bounded; `## [Unreleased]` is no release, and another level-two heading belongs to the notes it sits in | | Agent stores beyond claude/codex/omo | gjc-runtime.ts, pi.ts, claude-store.ts, omp.ts (transcript-records.ts and skill-activity.ts parse what they find) | each store lookup answers null rather than guessing which transcript a pane writes. pi's store moves with `PI_CODING_AGENT_SESSION_DIR` or `PI_CODING_AGENT_DIR`, and a session file is not written until the first answer, so an absent path can still be the pane's. gjc on Windows is found through the PC's process table, shared between polls for PROCESS_TABLE_MS; an empty table is a failed read, not an answer | | Paired devices | devices.ts + /api/devices* in index.ts | six-digit code valid ten minutes, one per pairing; the device's token is stored only as a hash in devices.json (0600, written whole), and revoking also closes that device's live streams | diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 2ad49de2cb..5c638cab75 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -11,7 +11,8 @@ import { noInstalledNotes, unmanagedUpdateStatus, type HerdrUpdateStatus, type I import { USAGE_PROVIDERS, UsageService } from "./usage.ts"; import { VoiceService } from "./voice.ts"; import { Telemetry } from "./telemetry.ts"; -import type { TelemetryStatus } from "../shared/telemetry.ts"; +import { NOTICE_VERSION, type TelemetryEvent, type TelemetryStatus } from "../shared/telemetry.ts"; +import { DEFAULT_SETTINGS, telemetrySettingsSnapshot } from "../src/lib/settings.ts"; import { herdrRpc, ping, sessionSnapshot, tabCreate, workspaceCreate, workspaceClose } from "./herdr/client.ts"; import { startFakePushService, type FakePushService } from "./push.fake.ts"; import { descriptorPath, type BridgeDescriptor } from "./bridge.ts"; @@ -473,24 +474,53 @@ describe("telemetry API", () => { } }); - it("tells the switch and the next event, and takes the notice and the switch from the app", async () => { + it("validates real settings payloads, protects acknowledgement, and previews only safe event values", async () => { const state = mkdtempSync(join(tmpdir(), "herdr-telemetry-api-")); - const sent: unknown[] = []; + const sent: TelemetryEvent[] = []; const telemetry = new Telemetry({ stateDir: state, version: "9.9.9", env: {}, url: "http://receiver.invalid/v1/events", noticeGraceMs: 0, - fetch: (async (_url: string | URL | Request, init?: RequestInit) => { sent.push(JSON.parse(String(init?.body))); return new Response(null, { status: 204 }); }) as typeof fetch }); + fetch: (async (_url: string | URL | Request, init?: RequestInit) => { sent.push(JSON.parse(String(init?.body)) as TelemetryEvent); return new Response(null, { status: 204 }); }) as typeof fetch }); const server = createServer({ port: 0, stateDir: state, token: "", telemetry }); const url = `http://localhost:${server.port}/api/telemetry`; + const headers = { "content-type": "application/json", "x-herdr-update": "1", origin: new URL(url).origin }; + const settings = telemetrySettingsSnapshot({ + ...DEFAULT_SETTINGS, + theme: "light", + terminalFontFamily: "private font name", + quickReplies: ["private reply"], + usageHidden: ["private-account-key"], + }); try { const first = await fetch(url); expect(first.headers.get("cache-control")).toBe("no-store"); - expect(await first.json() as TelemetryStatus).toMatchObject({ enabled: true, notice_seen: false, blocked_by_env: false, next: { event: "install", version: "9.9.9" } }); - expect((await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ notice_seen: true }) })).status).toBe(403); - const seen = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-herdr-update": "1" }, body: JSON.stringify({ notice_seen: true }) }); - expect(await seen.json() as TelemetryStatus).toMatchObject({ notice_seen: true }); + expect(await first.json() as TelemetryStatus).toMatchObject({ enabled: true, notice_seen: false, blocked_by_env: false, next: { event: "install", version: "9.9.9", settings: null } }); + + const post = (body: unknown, extraHeaders: Record = {}) => fetch(url, { + method: "POST", headers: { ...headers, ...extraHeaders }, body: JSON.stringify(body), + }); + expect((await post({ notice_seen: true, notice_version: NOTICE_VERSION }, { "x-herdr-update": "" })).status).toBe(403); + expect((await post({ notice_seen: true, notice_version: NOTICE_VERSION }, { origin: "https://attacker.invalid", "sec-fetch-site": "cross-site" })).status).toBe(403); + expect((await post({ notice_seen: true })).status).toBe(400); + expect((await post({ notice_seen: true, notice_version: NOTICE_VERSION - 1 })).status).toBe(400); + expect((await post({ settings: { ...settings, theme: "private text" } })).status).toBe(400); + expect((await post({ settings: { ...settings, injected: true } })).status).toBe(400); + expect((await post({ settings, changed_settings: ["theme"] })).status).toBe(400); + expect(await (await fetch(url)).json() as TelemetryStatus).toMatchObject({ notice_seen: false, next: { settings: null } }); + expect(sent).toEqual([]); + + const seen = await post({ notice_seen: true, notice_version: NOTICE_VERSION, settings }); + const preview = await seen.json() as TelemetryStatus; + expect(seen.status).toBe(200); + expect(preview).toMatchObject({ notice_seen: true, next: { event: "install", settings } }); + expect(JSON.stringify(preview)).not.toContain("private font name"); + expect(JSON.stringify(preview)).not.toContain("private reply"); + expect(JSON.stringify(preview)).not.toContain("private-account-key"); await telemetry.report(); - expect(sent).toEqual([expect.objectContaining({ event: "install", version: "9.9.9" })]); + expect(sent).toEqual([expect.objectContaining({ event: "install", version: "9.9.9", settings })]); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private font name"); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private reply"); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private-account-key"); expect(await (await fetch(url)).json() as TelemetryStatus).toMatchObject({ next: null }); - const off = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-herdr-update": "1" }, body: JSON.stringify({ enabled: false }) }); + const off = await post({ enabled: false }); expect(await off.json() as TelemetryStatus).toMatchObject({ enabled: false }); } finally { server.stop(); rmSync(state, { recursive: true, force: true }); } }); diff --git a/server/telemetry.test.ts b/server/telemetry.test.ts index 744b120da8..29fedce6f6 100644 --- a/server/telemetry.test.ts +++ b/server/telemetry.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { TelemetryEvent } from "../shared/telemetry.ts"; +import { NOTICE_VERSION, type TelemetryChange, type TelemetryEvent, type TelemetrySettings } from "../shared/telemetry.ts"; +import { DEFAULT_SETTINGS, telemetrySettingsSnapshot, type Settings } from "../src/lib/settings.ts"; import { handleTelemetryRequest, installMethod, Telemetry, telemetryBlocked } from "./telemetry.ts"; const dirs: string[] = []; @@ -25,8 +26,12 @@ function setup(options: { version?: string; env?: Record; status return { telemetry, sent, stateDir, clock }; } +function settingsSnapshot(overrides: Partial = {}): TelemetrySettings { + return telemetrySettingsSnapshot({ ...DEFAULT_SETTINGS, ...overrides }); +} + describe("telemetry events", () => { - test("nothing is sent before the notice was shown", async () => { + test("nothing is sent before the current notice was shown", async () => { const { telemetry, sent } = setup(); await telemetry.report(); expect(sent).toEqual([]); @@ -35,10 +40,10 @@ describe("telemetry events", () => { test("the notice sends the install once, and a restart on the same version sends nothing", async () => { const { telemetry, sent, stateDir } = setup(); - telemetry.change({ notice_seen: true }); + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ event: "install", version: "0.4.1", previous_version: null, os: "linux", arch: "x64", install_method: "source" }); + expect(sent[0]).toMatchObject({ event: "install", version: "0.4.1", previous_version: null, os: "linux", arch: "x64", install_method: "source", settings: null }); expect(sent[0]!.install_id).toMatch(/^[0-9a-f-]{36}$/); expect(statSync(join(stateDir, "telemetry.json")).mode & 0o777).toBe(0o600); @@ -50,7 +55,7 @@ describe("telemetry events", () => { test("a new version sends one update from the version last reported", async () => { const first = setup(); - first.telemetry.change({ notice_seen: true }); + first.telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await first.telemetry.report(); const next = setup({ stateDir: first.stateDir, version: "0.4.2" }); await next.telemetry.report(); @@ -59,14 +64,14 @@ describe("telemetry events", () => { test("the first event of an install updated from before telemetry is an update", async () => { const { telemetry, sent } = setup({ previous: "0.4.0" }); - telemetry.change({ notice_seen: true }); + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toEqual([expect.objectContaining({ event: "update", previous_version: "0.4.0" })]); }); test("the switch off sends nothing, and on again sends what is owed", async () => { const { telemetry, sent } = setup(); - telemetry.change({ enabled: false, notice_seen: true }); + telemetry.change({ enabled: false, notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toEqual([]); telemetry.change({ enabled: true }); @@ -78,7 +83,7 @@ describe("telemetry events", () => { const environments: Record[] = [{ HERDR_WEB_TELEMETRY: "0" }, { DO_NOT_TRACK: "1" }, { CI: "true" }, { HERDR_TEST_MODE: "unit" }]; for (const env of environments) { const { telemetry, sent } = setup({ env }); - telemetry.change({ notice_seen: true }); + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toEqual([]); expect(telemetry.status().blocked_by_env).toBe(true); @@ -88,7 +93,7 @@ describe("telemetry events", () => { test("an event the receiver refused is sent again later", async () => { const failing = setup({ status: 503 }); - failing.telemetry.change({ notice_seen: true }); + failing.telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await failing.telemetry.report(); expect(failing.sent).toHaveLength(1); expect(JSON.parse(readFileSync(join(failing.stateDir, "telemetry.json"), "utf8")).reported_version).toBeNull(); @@ -99,7 +104,7 @@ describe("telemetry events", () => { test("the first event waits after the notice, so Turn off on the notice sends nothing", async () => { const off = setup({ grace: 600_000 }); - off.telemetry.change({ notice_seen: true }); + off.telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await off.telemetry.report(); expect(off.sent).toEqual([]); off.telemetry.change({ enabled: false }); @@ -108,7 +113,7 @@ describe("telemetry events", () => { expect(off.sent).toEqual([]); const kept = setup({ grace: 600_000 }); - kept.telemetry.change({ notice_seen: true }); + kept.telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); kept.clock.now += 599_999; await kept.telemetry.report(); expect(kept.sent).toEqual([]); @@ -124,7 +129,7 @@ describe("telemetry events", () => { return new Promise((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal?.reason))); }) as typeof fetch; const { telemetry, stateDir } = setup({ fetch: pending }); - telemetry.change({ notice_seen: true }); + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); const sending = telemetry.report(); expect(signal?.aborted).toBe(false); telemetry.change({ enabled: false }); @@ -135,7 +140,7 @@ describe("telemetry events", () => { test("a switch turned off by another server on the same state directory holds", async () => { const first = setup(); - first.telemetry.change({ notice_seen: true }); + first.telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await first.telemetry.report(); const plugin = setup({ stateDir: first.stateDir, version: "0.4.2" }); const source = setup({ stateDir: first.stateDir, version: "0.4.2" }); @@ -156,36 +161,99 @@ describe("telemetry events", () => { expect(telemetry.status().enabled).toBe(true); }); - test("an event names the appearance settings a browser said it changed, and nothing a browser did not say", async () => { + test("full safe settings persist across restarts and are included in install and update events", async () => { + const settings = settingsSnapshot({ + theme: "light", + terminalFontSize: 18, + terminalFontFamily: "private font name", + quickReplies: ["private quick reply"], + usageHidden: ["private-account-key"], + }); const { telemetry, sent, stateDir } = setup(); - expect(telemetry.status().next).toMatchObject({ changed_settings: null }); - // telling the settings sends nothing by itself: the notice still comes first - telemetry.change({ changed_settings: ["agentCards", "theme"] }); + telemetry.change({ settings }); await telemetry.report(); expect(sent).toEqual([]); - telemetry.change({ notice_seen: true }); + + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toHaveLength(1); - expect(sent[0]!.changed_settings).toEqual(["theme", "agentCards"]); - // the next version's event carries the last answer, also across a restart - const next = setup({ stateDir, version: "0.4.2" }); - next.telemetry.change({ changed_settings: [] }); - await next.telemetry.report(); - expect(next.sent).toEqual([expect.objectContaining({ event: "update", changed_settings: [] })]); + expect(sent[0]!.settings).toEqual(settings); + expect(sent[0]!.settings).toMatchObject({ theme: "light", terminalFontSize: 18, terminalFontCustom: true, quickRepliesCustom: true, quickReplyCount: 1, usageHiddenCount: 1 }); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private font name"); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private quick reply"); + expect(JSON.stringify(sent[0]!.settings)).not.toContain("private-account-key"); + expect(JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")).settings).toEqual(settings); + + const restarted = setup({ stateDir }); + expect(restarted.telemetry.status().next).toBeNull(); + await restarted.telemetry.report(); + expect(restarted.sent).toEqual([]); + + const latestSettings = settingsSnapshot({ theme: "system", terminalFontSize: 16 }); + const latestBrowser = setup({ stateDir }); + latestBrowser.telemetry.change({ settings: latestSettings }); + await latestBrowser.telemetry.report(); + expect(latestBrowser.sent).toEqual([]); + + const updated = setup({ stateDir, version: "0.4.2" }); + await updated.telemetry.report(); + expect(updated.sent).toEqual([expect.objectContaining({ event: "update", previous_version: "0.4.1", settings: latestSettings })]); }); - test("an install that saw the notice before it named the settings sends nothing until it sees it again", async () => { + test("invalid settings cannot be persisted or sent, and changing settings after a counted version adds no event", async () => { + const { telemetry, sent, stateDir } = setup(); + const safe = settingsSnapshot(); + const submitted = { ...safe }; + telemetry.change({ settings: submitted }); + submitted.theme = "light"; + const preview = telemetry.status().next?.settings; + expect(preview).toEqual(safe); + if (!preview) throw new Error("telemetry preview omitted the settings snapshot"); + preview.theme = "system"; + expect(telemetry.status().next?.settings).toEqual(safe); + const before = JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")); + + const invalid = { ...safe, theme: "user supplied text", unknown: true } as unknown as TelemetrySettings; + expect(() => telemetry.change({ settings: invalid })).toThrow(); + expect(JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8"))).toEqual(before); + expect(telemetry.status().next?.settings).toEqual(safe); + expect(sent).toEqual([]); + + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); + await telemetry.report(); + expect(sent).toHaveLength(1); + telemetry.change({ settings: settingsSnapshot({ theme: "light" }) }); + await telemetry.report(); + expect(sent).toHaveLength(1); + expect(telemetry.status().next).toBeNull(); + expect(JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")).settings).toEqual(settingsSnapshot({ theme: "light" })); + }); + + test("notice upgrades preserve the install ID and disabled state, and invalid or legacy settings become null", async () => { const stateDir = mkdtempSync(join(tmpdir(), "telemetry-")); dirs.push(stateDir); - writeFileSync(join(stateDir, "telemetry.json"), JSON.stringify({ install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", enabled: true, notice_seen_at: "2026-10-01T00:00:00.000Z", reported_version: "0.4.0" })); + const settings = settingsSnapshot(); + writeFileSync(join(stateDir, "telemetry.json"), JSON.stringify({ + install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", + enabled: false, + notice_seen_at: "2026-10-01T00:00:00.000Z", + notice_version: NOTICE_VERSION - 1, + reported_version: "0.4.0", + settings: { ...settings, theme: "invalid" }, + changed_settings: ["theme"], + })); const { telemetry, sent } = setup({ stateDir }); - expect(telemetry.status()).toMatchObject({ notice_seen: false, next: { event: "update", previous_version: "0.4.0" } }); - telemetry.change({ changed_settings: ["agentCards"] }); + expect(telemetry.status()).toMatchObject({ enabled: false, notice_seen: false, next: { event: "update", previous_version: "0.4.0", install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", settings: null } }); + telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION }); await telemetry.report(); expect(sent).toEqual([]); - telemetry.change({ notice_seen: true }); + expect(telemetry.status().enabled).toBe(false); + telemetry.change({ enabled: true }); await telemetry.report(); - expect(sent).toEqual([expect.objectContaining({ event: "update", install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", changed_settings: ["agentCards"] })]); + expect(sent).toEqual([expect.objectContaining({ event: "update", install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", settings: null })]); + const saved = JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")); + expect(saved.settings).toBeNull(); + expect(saved.changed_settings).toBeUndefined(); }); test("the install method follows how the app was started", () => { @@ -205,27 +273,48 @@ describe("/api/telemetry", () => { expect((await response.json()).error.code).toBe("not_found"); }); - test("GET tells the status and the event that would be sent", async () => { + test("GET tells the status and previews only an allowlisted event", async () => { const { telemetry } = setup(); const response = await handleTelemetryRequest(new Request("http://localhost/api/telemetry"), telemetry); expect(response.headers.get("cache-control")).toBe("no-store"); - expect(await response.json()).toMatchObject({ enabled: true, notice_seen: false, next: { event: "install", version: "0.4.1" } }); + expect(await response.json()).toMatchObject({ enabled: true, notice_seen: false, next: { event: "install", version: "0.4.1", settings: null } }); }); - test("POST needs the app's header and a well-formed body", async () => { - const { telemetry } = setup(); + test("POST validates exact settings and current notice acknowledgements before changing state", async () => { + const { telemetry, sent, stateDir } = setup(); + const settings = settingsSnapshot({ theme: "light", terminalFontFamily: "private font name", quickReplies: ["private reply"] }); expect((await handleTelemetryRequest(post({ enabled: false }, {}), telemetry)).status).toBe(403); expect((await handleTelemetryRequest(post({ enabled: "no" }), telemetry)).status).toBe(400); - expect((await handleTelemetryRequest(post({ notice_seen: false }), telemetry)).status).toBe(400); - // only names from the fixed list: a value, a typed setting or anything else is refused whole - for (const changed_settings of ["theme", ["terminalFontFamily"], ["theme", "D2Coding"], [{ theme: "light" }]]) { - expect((await handleTelemetryRequest(post({ changed_settings }), telemetry)).status).toBe(400); - } - expect(telemetry.status().next).toMatchObject({ changed_settings: null }); - expect(await (await handleTelemetryRequest(post({ changed_settings: ["agentPins", "theme", "theme"] }), telemetry)).json()).toMatchObject({ next: { changed_settings: ["theme", "agentPins"] } }); - // a setting that was removed, named by a page loaded before the update: kept out, not refused - expect(await (await handleTelemetryRequest(post({ changed_settings: ["planFlow", "theme"] }), telemetry)).json()).toMatchObject({ next: { changed_settings: ["theme"] } }); - const response = await handleTelemetryRequest(post({ enabled: false, notice_seen: true }), telemetry); - expect(await response.json()).toMatchObject({ enabled: false, notice_seen: true }); + expect((await handleTelemetryRequest(post({ notice_seen: false, notice_version: NOTICE_VERSION }), telemetry)).status).toBe(400); + expect((await handleTelemetryRequest(post({ notice_seen: true }), telemetry)).status).toBe(400); + expect((await handleTelemetryRequest(post({ notice_seen: true, notice_version: NOTICE_VERSION - 1 }), telemetry)).status).toBe(400); + expect((await handleTelemetryRequest(post({ settings: { ...settings, theme: "private text" } }), telemetry)).status).toBe(400); + expect((await handleTelemetryRequest(post({ settings: { ...settings, extra: true } }), telemetry)).status).toBe(400); + expect((await handleTelemetryRequest(post({ settings, old_alias: ["theme"] }), telemetry)).status).toBe(400); + expect(telemetry.status()).toMatchObject({ notice_seen: false, next: { settings: null } }); + expect(JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")).settings).toBeNull(); + expect(sent).toEqual([]); + + const response = await handleTelemetryRequest(post({ enabled: false, notice_seen: true, notice_version: NOTICE_VERSION, settings }), telemetry); + expect(await response.json()).toMatchObject({ enabled: false, notice_seen: true, next: { settings } }); + expect(JSON.stringify(telemetry.status())).not.toContain("private font name"); + expect(JSON.stringify(telemetry.status())).not.toContain("private reply"); + expect(JSON.parse(readFileSync(join(stateDir, "telemetry.json"), "utf8")).settings).toEqual(settings); + await telemetry.report(); + expect(sent).toEqual([]); + + const enabled = await handleTelemetryRequest(post({ enabled: true }), telemetry); + expect(await enabled.json()).toMatchObject({ enabled: true, next: { settings } }); + await telemetry.report(); + expect(sent).toHaveLength(1); + expect(sent[0]!.settings).toEqual(settings); + }); + + test("change() requires the exact current notice version before acknowledging", () => { + const { telemetry } = setup(); + expect(() => telemetry.change({ notice_seen: true } as TelemetryChange)).toThrow(); + expect(() => telemetry.change({ notice_seen: true, notice_version: NOTICE_VERSION - 1 })).toThrow(); + expect(telemetry.status().notice_seen).toBe(false); + expect(telemetry.change({ enabled: false, notice_seen: true, notice_version: NOTICE_VERSION }).notice_seen).toBe(true); }); }); diff --git a/server/telemetry.ts b/server/telemetry.ts index ecc8f5d2ee..938cd54d27 100644 --- a/server/telemetry.ts +++ b/server/telemetry.ts @@ -2,13 +2,15 @@ * Anonymous install and update counts (shared/telemetry.ts). One event when an install first * runs, one when it runs a new version; nothing in between. No event goes out before the app * has shown its notice once, while the switch is off, or where the environment says not to. + * Events include the latest browser's schema-validated settings snapshot; free-form strings and + * account identifiers are summarized or omitted by the browser's projection. * Only the real entrypoint (index.ts) makes one of these, so tests and a remote PC's bridge * send nothing. */ import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { NOTICE_VERSION, reportedSettings, type InstallMethod, type ReportedSetting, type TelemetryEvent, type TelemetryStatus } from "../shared/telemetry.ts"; +import { NOTICE_VERSION, telemetrySettings, type InstallMethod, type TelemetryChange, type TelemetryEvent, type TelemetrySettings, type TelemetryStatus } from "../shared/telemetry.ts"; import { badRequest, errorResponse, isJsonObject, jsonResponse } from "./http.ts"; import { updateRequestAllowed } from "./update-api.ts"; @@ -19,6 +21,7 @@ const SEND_TIMEOUT_MS = 10_000; const START_DELAY_MS = 15_000; /** after the notice was shown, time to read it and press Turn off before the first event goes */ export const NOTICE_GRACE_MS = 10 * 60_000; +const TELEMETRY_CHANGE_KEYS = new Set(["enabled", "notice_seen", "notice_version", "settings"]); interface Saved { install_id: string; @@ -28,8 +31,8 @@ interface Saved { notice_version: number; /** the version the last delivered event was for; null before the first one */ reported_version: string | null; - /** what a browser last told of its appearance settings; null before one has */ - changed_settings: ReportedSetting[] | null; + /** the last browser's complete, allowlisted settings snapshot; null before one has */ + settings: TelemetrySettings | null; } type Env = Record; @@ -47,6 +50,47 @@ export function installMethod(env: Env): InstallMethod { return env["HERDR_WEB_MANAGED"] === "1" ? "managed" : "source"; } +function telemetryChange(value: unknown): TelemetryChange | null { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string" || !TELEMETRY_CHANGE_KEYS.has(key))) return null; + + const clean: TelemetryChange = {}; + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) return null; + switch (key) { + case "enabled": + if (typeof descriptor.value !== "boolean") return null; + clean.enabled = descriptor.value; + break; + case "notice_seen": + if (descriptor.value !== true) return null; + clean.notice_seen = true; + break; + case "notice_version": + if (descriptor.value !== NOTICE_VERSION) return null; + clean.notice_version = NOTICE_VERSION; + break; + case "settings": { + const settings = telemetrySettings(descriptor.value); + if (!settings) return null; + clean.settings = settings; + break; + } + } + } + if (clean.notice_seen === true && clean.notice_version !== NOTICE_VERSION) return null; + return clean; + } catch { + return null; + } +} + function readSaved(file: string): Saved | null { try { const value: unknown = JSON.parse(readFileSync(file, "utf8")); @@ -58,7 +102,7 @@ function readSaved(file: string): Saved | null { notice_seen_at: value["notice_version"] === NOTICE_VERSION && typeof value["notice_seen_at"] === "string" ? value["notice_seen_at"] : null, notice_version: NOTICE_VERSION, reported_version: typeof value["reported_version"] === "string" ? value["reported_version"] : null, - changed_settings: reportedSettings(value["changed_settings"]), + settings: telemetrySettings(value["settings"]), }; } catch { return null; } } @@ -88,7 +132,7 @@ export class Telemetry { }) { this.file = join(options.stateDir, FILE); const saved = readSaved(this.file); - this.saved = saved ?? { install_id: randomUUID(), enabled: true, notice_seen_at: null, notice_version: NOTICE_VERSION, reported_version: null, changed_settings: null }; + this.saved = saved ?? { install_id: randomUUID(), enabled: true, notice_seen_at: null, notice_version: NOTICE_VERSION, reported_version: null, settings: null }; if (!saved) { try { this.persist(); } catch (error) { // the app still starts; telemetry just stays silent @@ -137,7 +181,7 @@ export class Telemetry { os: this.options.platform ?? process.platform, arch: this.options.arch ?? process.arch, install_method: installMethod(this.options.env), - changed_settings: this.saved.changed_settings, + settings: this.saved.settings === null ? null : telemetrySettings(this.saved.settings), }; } @@ -151,15 +195,17 @@ export class Telemetry { }; } - change(patch: { enabled?: boolean; notice_seen?: true; changed_settings?: ReportedSetting[] }): TelemetryStatus { + change(patch: TelemetryChange): TelemetryStatus { + const clean = telemetryChange(patch); + if (!clean) throw new TypeError("Invalid telemetry change"); this.refresh(); - if (patch.enabled !== undefined) this.saved.enabled = patch.enabled; - // kept for the next event; telling it sends nothing by itself - if (patch.changed_settings !== undefined) this.saved.changed_settings = patch.changed_settings; - if (patch.notice_seen && this.saved.notice_seen_at === null) this.saved.notice_seen_at = new Date(this.now()).toISOString(); + if (clean.enabled !== undefined) this.saved.enabled = clean.enabled; + // kept for the next install/update event; telling it sends nothing by itself + if (clean.settings !== undefined) this.saved.settings = clean.settings; + if (clean.notice_seen && this.saved.notice_seen_at === null) this.saved.notice_seen_at = new Date(this.now()).toISOString(); this.persist(); // turned off while an event is on its way: stop it rather than let it land - if (patch.enabled === false) this.abort?.abort(); + if (clean.enabled === false) this.abort?.abort(); void this.report(); return this.status(); } @@ -211,11 +257,8 @@ export async function handleTelemetryRequest(request: Request, telemetry?: Telem let body: unknown; try { body = await request.json(); } catch { return badRequest("invalid_json", "The body is not JSON"); } if (!isJsonObject(body)) return badRequest("invalid_body", "Send an object"); - const { enabled, notice_seen } = body; - const changed = body["changed_settings"] === undefined ? undefined : reportedSettings(body["changed_settings"]); - if (changed === null) return badRequest("invalid_body", "changed_settings is a list of reported setting names"); - if (enabled !== undefined && typeof enabled !== "boolean") return badRequest("invalid_body", "enabled is a boolean"); - if (notice_seen !== undefined && notice_seen !== true) return badRequest("invalid_body", "notice_seen can only be true"); - try { return reply(telemetry.change({ ...(enabled !== undefined ? { enabled } : {}), ...(notice_seen ? { notice_seen } : {}), ...(changed !== undefined ? { changed_settings: changed } : {}) })); } + const change = telemetryChange(body); + if (!change) return badRequest("invalid_body", "Send enabled, settings, or the current notice acknowledgement"); + try { return reply(telemetry.change(change)); } catch (error) { return errorResponse(error); } } diff --git a/shared/protocol.ts b/shared/protocol.ts index c6e2b942da..8b4f95e1dd 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -70,8 +70,10 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * Update POSTs require X-Herdr-Update: 1, same-origin browser requests, * and the usual token gate. Managed starts only; status is polled during restart. * GET /api/telemetry -> TelemetryStatus (shared/telemetry.ts), no-store - * POST /api/telemetry -> TelemetryStatus; body { enabled?, notice_seen?: true, changed_settings? }, - * X-Herdr-Update: 1 and same-origin. 404 from a server that sends no telemetry. + * POST /api/telemetry -> TelemetryStatus; TelemetryChange (shared/telemetry.ts): + * { enabled?, notice_seen?: true, notice_version?, settings? }. A notice acknowledgement + * requires the current NOTICE_VERSION. X-Herdr-Update: 1 and same-origin. + * 404 from a server that sends no telemetry. * GET /api/agents -> { agents: AgentKind[] } (herdr's agent manifests: the * kinds `agent.start` accepts, plus omo and gjc when they are on this server's PATH, * for the new-session dialog) diff --git a/shared/telemetry.ts b/shared/telemetry.ts index dba75b7288..1f8abef9a3 100644 --- a/shared/telemetry.ts +++ b/shared/telemetry.ts @@ -2,12 +2,13 @@ * Anonymous install and update counts (server/telemetry.ts). On by default after a one-time * notice in the app; nothing is sent before that notice was shown (and a grace period after it, * server/telemetry.ts NOTICE_GRACE_MS), and nothing between an install and an update. An event - * also names the appearance settings changed from their defaults (REPORTED_SETTINGS): the browser - * tells its server which, and the server keeps the last answer for its next event. + * also carries the browser's latest allowlisted settings snapshot; no raw user-entered values or + * account identifiers are included. * * GET /api/telemetry -> TelemetryStatus, no-store - * POST /api/telemetry { enabled?: boolean, notice_seen?: true, changed_settings?: ReportedSetting[] } -> TelemetryStatus + * POST /api/telemetry { enabled?: boolean, notice_seen?: true, notice_version?: number, settings?: TelemetrySettings } -> TelemetryStatus * (same-origin, `x-herdr-update: 1`) + * The server accepts notice_seen only with notice_version === NOTICE_VERSION. * * A server that sends nothing (tests, a remote PC's bridge, an older server) answers 404 to * both, and the app shows neither the notice nor the switch. @@ -17,24 +18,161 @@ export type TelemetryEventName = "install" | "update"; /** * The notice's wording as a number: an install that saw an older one sees the notice again, and - * sends nothing until it has (2: the event names changed appearance settings). + * sends nothing until it has (3: events now carry an allowlisted settings snapshot). */ -export const NOTICE_VERSION = 2; +export const NOTICE_VERSION = 3; /** - * The settings an event may name as changed from their defaults. Choices from a fixed set only: - * never one the user types (a font, a shortcut, the key bar), and only the name, not the value. + * Current setting values safe to report. Only finite enums, booleans and bounded numbers are + * represented; user-entered strings and account identifiers are summarized or omitted by the + * browser producer in src/lib/settings.ts. */ -export const REPORTED_SETTINGS = ["theme", "density", "palette", "sidebarRows", "agentOrder", "agentCards", "agentPins"] as const; -export type ReportedSetting = typeof REPORTED_SETTINGS[number]; -/** Settings that were removed: a saved state or a page loaded before the update may still name - * them, so they are accepted and dropped, never refused or sent. */ -export const RETIRED_SETTINGS: readonly string[] = ["planFlow", "planButton"]; +export interface TelemetrySettings { + terminalInputMode: "auto" | "line" | "direct"; + theme: "dark" | "light" | "system"; + density: "compact" | "comfortable"; + sidebarRows: "one" | "two"; + agentOrder: "workspace" | "activity"; + agentCards: boolean; + agentPins: boolean; + quietOpenedDone: boolean; + agentMarks: "color" | "mono"; + palette: "amber" | "report" | "charcoal" | "catppuccin" | "lilac" | "forest"; + terminalFontSize: number; + terminalCursorStyle: "block" | "bar" | "underline"; + terminalCursorBlink: boolean; + terminalWheelSpeed: number; + paneClipboard: boolean; + releasePaneAway: boolean; + narrowPaneFont: boolean; + chatFontSize: number | null; + chatWidth: "narrow" | "default" | "wide" | "full"; + enterSends: boolean; + autoSendQueued: boolean; + showThinking: boolean; + keepScreenOn: boolean; + language: "system" | "en" | "zh" | "ja" | "ko" | "tr"; + alertsOn: boolean; + alertInput: boolean; + alertDone: "off" | "long" | "always"; + alertInApp: boolean; + alertSound: boolean; + showQuickReplies: boolean; + showSuggestionChip: boolean; + showUsage: boolean; + usageCount: "used" | "left"; + usagePace: boolean; + usageGlance: "week" | "session"; + defaultView: "auto" | "chat" | "terminal"; + voiceInput: "auto" | "on" | "off"; + voiceLanguage: "auto" | "ar-SA" | "cs-CZ" | "da-DK" | "de-DE" | "el-GR" | "en-GB" | "en-US" | "es-ES" | "fi-FI" | "fr-FR" | "he-IL" | "hi-IN" | "hu-HU" | "id-ID" | "it-IT" | "ja-JP" | "ko-KR" | "nb-NO" | "nl-NL" | "pl-PL" | "pt-BR" | "pt-PT" | "ro-RO" | "ru-RU" | "sk-SK" | "sv-SE" | "th-TH" | "tr-TR" | "uk-UA" | "vi-VN" | "zh-CN" | "zh-TW"; + voicePolishChat: boolean; + voicePolishTerminal: boolean; + wrapCode: boolean; + highlightCode: boolean; + markdownWidth: "readable" | "full"; + terminalFontCustom: boolean; + chatFontCustom: boolean; + shortcutsCustom: boolean; + keyBarCustom: boolean; + quickRepliesCustom: boolean; + quickReplyCount: number; + usageHiddenCount: number; +} + +/** The browser's full, non-sensitive snapshot. */ +export interface TelemetryChange { + enabled?: boolean; + notice_seen?: true; + notice_version?: number; + settings?: TelemetrySettings; +} + +const oneOf = (choices: T) => (value: unknown): boolean => + typeof value === "string" && (choices as readonly string[]).includes(value); +const booleanValue = (value: unknown): boolean => typeof value === "boolean"; +const integerBetween = (min: number, max: number) => (value: unknown): boolean => + typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; +const nullableIntegerBetween = (min: number, max: number) => (value: unknown): boolean => + value === null || integerBetween(min, max)(value); + +// This explicit shared schema must track the producer without importing frontend code at runtime. +const TELEMETRY_SETTINGS_VALIDATORS = { + terminalInputMode: oneOf(["auto", "line", "direct"]), + theme: oneOf(["dark", "light", "system"]), + density: oneOf(["compact", "comfortable"]), + sidebarRows: oneOf(["one", "two"]), + agentOrder: oneOf(["workspace", "activity"]), + agentCards: booleanValue, + agentPins: booleanValue, + quietOpenedDone: booleanValue, + agentMarks: oneOf(["color", "mono"]), + palette: oneOf(["amber", "report", "charcoal", "catppuccin", "lilac", "forest"]), + terminalFontSize: integerBetween(10, 22), + terminalCursorStyle: oneOf(["block", "bar", "underline"]), + terminalCursorBlink: booleanValue, + terminalWheelSpeed: integerBetween(1, 10), + paneClipboard: booleanValue, + releasePaneAway: booleanValue, + narrowPaneFont: booleanValue, + chatFontSize: nullableIntegerBetween(11, 24), + chatWidth: oneOf(["narrow", "default", "wide", "full"]), + enterSends: booleanValue, + autoSendQueued: booleanValue, + showThinking: booleanValue, + keepScreenOn: booleanValue, + language: oneOf(["system", "en", "zh", "ja", "ko", "tr"]), + alertsOn: booleanValue, + alertInput: booleanValue, + alertDone: oneOf(["off", "long", "always"]), + alertInApp: booleanValue, + alertSound: booleanValue, + showQuickReplies: booleanValue, + showSuggestionChip: booleanValue, + showUsage: booleanValue, + usageCount: oneOf(["used", "left"]), + usagePace: booleanValue, + usageGlance: oneOf(["week", "session"]), + defaultView: oneOf(["auto", "chat", "terminal"]), + voiceInput: oneOf(["auto", "on", "off"]), + voiceLanguage: oneOf(["auto", "ar-SA", "cs-CZ", "da-DK", "de-DE", "el-GR", "en-GB", "en-US", "es-ES", "fi-FI", "fr-FR", "he-IL", "hi-IN", "hu-HU", "id-ID", "it-IT", "ja-JP", "ko-KR", "nb-NO", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ro-RO", "ru-RU", "sk-SK", "sv-SE", "th-TH", "tr-TR", "uk-UA", "vi-VN", "zh-CN", "zh-TW"]), + voicePolishChat: booleanValue, + voicePolishTerminal: booleanValue, + wrapCode: booleanValue, + highlightCode: booleanValue, + markdownWidth: oneOf(["readable", "full"]), + terminalFontCustom: booleanValue, + chatFontCustom: booleanValue, + shortcutsCustom: booleanValue, + keyBarCustom: booleanValue, + quickRepliesCustom: booleanValue, + quickReplyCount: integerBetween(0, 12), + usageHiddenCount: integerBetween(0, 64), +} satisfies Record boolean>; + +const TELEMETRY_SETTINGS_KEYS = Object.keys(TELEMETRY_SETTINGS_VALIDATORS) as (keyof TelemetrySettings)[]; + +/** Validate the complete snapshot and return a fresh object containing only schema keys. */ +export function telemetrySettings(value: unknown): TelemetrySettings | null { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + + const keys = Reflect.ownKeys(value); + if (keys.length !== TELEMETRY_SETTINGS_KEYS.length + || keys.some((key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(TELEMETRY_SETTINGS_VALIDATORS, key))) return null; -/** A list of reported settings in their own order, each once; null when the value is not one. */ -export function reportedSettings(value: unknown): ReportedSetting[] | null { - if (!Array.isArray(value) || value.some((name) => !(REPORTED_SETTINGS as readonly unknown[]).includes(name) && !RETIRED_SETTINGS.includes(name))) return null; - return REPORTED_SETTINGS.filter((name) => value.includes(name)); + const clean: Record = {}; + for (const key of TELEMETRY_SETTINGS_KEYS) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !TELEMETRY_SETTINGS_VALIDATORS[key](descriptor.value)) return null; + clean[key] = descriptor.value; + } + return clean as unknown as TelemetrySettings; + } catch { + return null; + } } export type InstallMethod = "plugin" | "managed" | "source"; @@ -54,8 +192,8 @@ export interface TelemetryEvent { os: string; arch: string; install_method: InstallMethod; - /** the appearance settings changed from their defaults in the browser that last opened the app; null before one has told */ - changed_settings: ReportedSetting[] | null; + /** the latest browser's allowlisted settings snapshot; null before one has told */ + settings: TelemetrySettings | null; } export interface TelemetryStatus { diff --git a/src/components/TelemetryControls.tsx b/src/components/TelemetryControls.tsx index f5199c2955..c334250021 100644 --- a/src/components/TelemetryControls.tsx +++ b/src/components/TelemetryControls.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import type { TelemetryStatus } from "../../shared/telemetry.ts"; +import { NOTICE_VERSION, type TelemetryStatus } from "../../shared/telemetry.ts"; import { ApiError, changeTelemetry, fetchTelemetry } from "../lib/api.ts"; -import { changedSettings, useSettings } from "../lib/settings.ts"; +import { telemetrySettingsSnapshot, useSettings } from "../lib/settings.ts"; import { useT } from "../lib/i18n.ts"; import { SettingsGroup, SettingsRow, Toggle } from "./SettingsControls.tsx"; @@ -18,17 +18,21 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: const [closed, setClosed] = useState(false); const [turningOff, setTurningOff] = useState(false); const [offFailed, setOffFailed] = useState(false); - // The server keeps which appearance settings this browser changed, for its next count. It is - // told on opening and on a change; telling it sends nothing, and a server without counts says 404. + // The server keeps this browser's latest safe settings snapshot for its next count. The snapshot + // contains no raw user-entered values or account identifiers. const { settings } = useSettings(); - const changed = changedSettings(settings).join(","); + const settingsSnapshot = useMemo(() => telemetrySettingsSnapshot(settings), [settings]); + const settingsSignature = JSON.stringify(settingsSnapshot); + const settingsSnapshotRef = useRef(settingsSnapshot); + settingsSnapshotRef.current = settingsSnapshot; + const submittedSettingsSignature = useRef(null); useEffect(() => { - if (!enabled) return; - changeTelemetry({ changed_settings: changedSettings(settings) }).catch((error: unknown) => { + if (!enabled || submittedSettingsSignature.current === settingsSignature) return; + submittedSettingsSignature.current = settingsSignature; + changeTelemetry({ settings: settingsSnapshot }).catch((error: unknown) => { if (!(error instanceof ApiError && error.status === 404)) console.error("telemetry settings", error); }); - // `changed` stands for the list: the settings object changes with every other setting too - }, [enabled, changed]); + }, [enabled, settingsSignature, settingsSnapshot]); useEffect(() => { if (!enabled) return; let live = true; @@ -49,8 +53,11 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: const record = () => { if (recorded || document.visibilityState !== "visible") return; recorded = true; - // with the list, so the count this starts cannot leave before the server has it - changeTelemetry({ notice_seen: true, changed_settings: changedSettings(settings) }).catch((error: unknown) => console.error("telemetry notice", error)); + changeTelemetry({ + notice_seen: true, + notice_version: NOTICE_VERSION, + settings: settingsSnapshotRef.current, + }).catch((error: unknown) => console.error("telemetry notice", error)); }; const onVisibility = () => { if (document.visibilityState === "visible") afterPaint(); }; afterPaint(); @@ -68,7 +75,7 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: }).finally(() => setTurningOff(false)); }; return
- {t("herdr web ui sends an anonymous count when it is installed and updated: the version, the OS, a random ID and which appearance settings are changed from their defaults. The receiver also notes the country it came from, never the address.")} + {t("herdr web ui sends install and update counts with the version, OS, a random ID and app settings such as theme, text size and notification choices. Custom text and account identifiers are excluded. The receiver also notes the country, never the address.")} {offFailed && {t("Could not turn it off. Try again.")}} @@ -79,27 +86,60 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: /** Settings → About: the switch and the event as it would be sent. Hidden on a server that sends none. */ export function TelemetryControls() { const t = useT(); + const { settings } = useSettings(); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); + const settingsSnapshot = useMemo(() => telemetrySettingsSnapshot(settings), [settings]); + const settingsSignature = JSON.stringify(settingsSnapshot); + const settingsSnapshotRef = useRef(settingsSnapshot); + settingsSnapshotRef.current = settingsSnapshot; + const statusRequest = useRef(0); useEffect(() => { let live = true; - fetchTelemetry().then((next) => { if (live) setStatus(next); }, (failure: unknown) => { if (live) setError(failure instanceof Error ? failure.message : String(failure)); }); + const request = ++statusRequest.current; + const snapshot = settingsSnapshotRef.current; + setError(null); + // Show what the server accepted, not a local snapshot pasted over an older response. + // The GET keeps controls hidden on bridges and the demo, where telemetry is absent. + fetchTelemetry().then((current) => { + if (!live || request !== statusRequest.current) return; + if (!current) { + setStatus(null); + return; + } + return changeTelemetry({ settings: snapshot }).then((next) => { + if (!live || request !== statusRequest.current) return; + setStatus(next); + }); + }).catch((failure: unknown) => { + if (!live || request !== statusRequest.current) return; + if (failure instanceof ApiError && failure.status === 404) { + setStatus(null); + setError(null); + } else { + setError(failure instanceof Error ? failure.message : String(failure)); + } + }); return () => { live = false; }; - }, []); + }, [settingsSignature]); if (!status) return error ?

{error}

: null; const toggle = (enabled: boolean) => { // one change at a time: two answers out of order would leave the switch showing what the server does not hold if (saving) return; + const request = ++statusRequest.current; setSaving(true); setError(null); - changeTelemetry({ enabled }).then(setStatus, (failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure))).finally(() => setSaving(false)); + changeTelemetry({ enabled }).then( + (next) => { if (request === statusRequest.current) setStatus(next); }, + (failure: unknown) => { if (request === statusRequest.current) setError(failure instanceof Error ? failure.message : String(failure)); }, + ).finally(() => setSaving(false)); }; return ( {status.blocked_by_env ?

{t("Turned off on this PC by HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK or a CI environment.")}

- : + : } {error &&

{error}

} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 8792faabea..d400c4f19c 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,6 +1,8 @@ import { afterEach, expect, it } from "bun:test"; import { MAX_ATTACHMENT_BYTES } from "../../shared/attachments.ts"; -import { AttachmentTooLargeError, uploadPaneImage } from "./api.ts"; +import { NOTICE_VERSION } from "../../shared/telemetry.ts"; +import { AttachmentTooLargeError, ApiError, changeTelemetry, fetchTelemetry, uploadPaneImage } from "./api.ts"; +import { DEFAULT_SETTINGS, telemetrySettingsSnapshot } from "./settings.ts"; const realFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = realFetch; }); @@ -22,3 +24,53 @@ it("refuses a file over the attachment limit before any of it is sent, and sends expect(await uploadPaneImage("p1", atLimit)).toBe("/work/.herdr-web-ui/site.zip"); expect(requests).toEqual(["/api/pane/image"]); }); + +it("posts the full safe telemetry snapshot and current notice version", async () => { + const settings = telemetrySettingsSnapshot({ + ...DEFAULT_SETTINGS, + terminalFontFamily: "Private Font Name", + chatFontFamily: "Private Chat Font", + usageHidden: ["account-identifier"], + }); + const change = { notice_seen: true as const, notice_version: NOTICE_VERSION, settings }; + let requestUrl = ""; + let requestInit: RequestInit | undefined; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + requestUrl = url; + requestInit = init; + return Response.json({ enabled: true, blocked_by_env: false, notice_seen: true, next: null }); + }) as typeof fetch; + + await changeTelemetry(change); + + expect(requestUrl).toBe("/api/telemetry"); + expect(requestInit?.method).toBe("POST"); + expect(requestInit?.headers).toEqual({ "content-type": "application/json", "x-herdr-update": "1" }); + const body = JSON.parse(String(requestInit?.body)) as Record; + expect(body).toEqual(change); + expect(body).not.toHaveProperty("changed_settings"); + expect(Object.keys(body.settings as object)).toHaveLength(50); + expect(body.settings).not.toHaveProperty("terminalFontFamily"); + expect(body.settings).not.toHaveProperty("chatFontFamily"); + expect(body.settings).not.toHaveProperty("usageHidden"); + expect((body.settings as Record).terminalFontCustom).toBe(true); + expect((body.settings as Record).usageHiddenCount).toBe(1); +}); + +it("preserves telemetry API errors", async () => { + globalThis.fetch = (async (_url: string) => Response.json({ error: { code: "telemetry_failed", message: "Could not save" } }, { status: 500 })) as typeof fetch; + + const error = await changeTelemetry({ enabled: false }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 500, code: "telemetry_failed", detail: "Could not save" }); +}); + +it("treats a missing telemetry route as unavailable for reads and an API error for writes", async () => { + globalThis.fetch = (async (_url: string) => Response.json({ error: { code: "not_found", message: "Not found" } }, { status: 404 })) as typeof fetch; + + expect(await fetchTelemetry()).toBeNull(); + const error = await changeTelemetry({ enabled: true }).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 404, code: "not_found" }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index a6d9b4c049..646a5fb208 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,7 +60,7 @@ import type { import type { PaneScrollInfo } from "../../shared/herdr-api.generated.ts"; import { readInstalledNotes, readUpdateNotes, type HerdrUpdateStatus, type InstalledNotes, type UpdateCommand, type UpdateNotes, type UpdateStatus } from "../../shared/update.ts"; import type { AlertPrefs } from "../../shared/notify-policy.ts"; -import type { ReportedSetting, TelemetryStatus } from "../../shared/telemetry.ts"; +import type { TelemetryChange, TelemetryStatus } from "../../shared/telemetry.ts"; import type { VoiceConfigUpdate, VoiceStatus } from "../../shared/voice.ts"; import { MAX_ATTACHMENT_BYTES } from "../../shared/attachments.ts"; import { t } from "./i18n.ts"; @@ -126,7 +126,7 @@ export async function fetchTelemetry(): Promise { catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } } -export async function changeTelemetry(change: { enabled?: boolean; notice_seen?: true; changed_settings?: ReportedSetting[] }): Promise { +export async function changeTelemetry(change: TelemetryChange): Promise { const url = "/api/telemetry"; const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-herdr-update": "1" }, body: JSON.stringify(change) }); if (!response.ok) throw await errorFrom(url, response); diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 7daa710a8e..d4fd6d9f35 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -951,14 +951,14 @@ export const JA: Record = { "It is the last tab of {workspace}: the workspace closes with it, and the agents and shells in it stop.": "{workspace} の最後のタブです。ワークスペースも一緒に閉じ、その中のエージェントとシェルが停止します。", "Tabs of {workspace}": "{workspace} のタブ", "{n} panes": "{n} 個のペイン", - "herdr web ui sends an anonymous count when it is installed and updated: the version, the OS, a random ID and which appearance settings are changed from their defaults. The receiver also notes the country it came from, never the address.": "herdr web ui はインストール時と更新時に匿名の集計を送ります。送るのはバージョン、OS、ランダムな ID、そして既定値から変更した外観設定の名前です。受信側は送信元の国も記録しますが、アドレスは記録しません。", + "herdr web ui sends install and update counts with the version, OS, a random ID and app settings such as theme, text size and notification choices. Custom text and account identifiers are excluded. The receiver also notes the country, never the address.": "herdr web ui は、バージョン、OS、ランダムな ID、テーマ・文字サイズ・通知の選択などのアプリ設定を添えて、インストール数と更新数を送信します。カスタムテキストとアカウント識別子は含まれません。受信側は国も記録しますが、アドレスは記録しません。", "What is sent": "送信内容", "Turn off": "オフにする", "Could not turn it off. Try again.": "オフにできませんでした。もう一度お試しください。", "Anonymous usage counts": "匿名の利用集計", "Turned off on this PC by HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK or a CI environment.": "この PC では HERDR_WEB_TELEMETRY=0、DO_NOT_TRACK または CI 環境によりオフになっています。", "Send install and update counts": "インストールと更新の集計を送る", - "Once when the app is installed and once per update, with the names of the appearance settings changed from their defaults. The receiver notes the country a message came from but stores no IP address, and nothing about your terminals, agents or files is sent.": "アプリのインストール時に 1 回、更新のたびに 1 回、既定値から変更した外観設定の名前とともに送ります。受信側はメッセージの送信元の国を記録しますが IP アドレスは保存せず、ターミナル、エージェント、ファイルについては何も送りません。", + "Once on install and once per update, with the last browser's app settings. Custom fonts, shortcuts and replies are reported only as customization flags or counts, never their text. Account identifiers, terminal content and files are excluded. The receiver stores the country, not the IP address.": "インストール時に 1 回、更新ごとに 1 回、最後に使用したブラウザーのアプリ設定とともに送信します。カスタムフォント、ショートカット、返信は、内容そのものではなく、カスタマイズの有無を示すフラグまたは件数としてのみ報告されます。アカウント識別子、ターミナルの内容、ファイルは含まれません。受信側が保存するのは国であり、IP アドレスではありません。", "The receiver adds the country this message comes from (two letters, such as KR). It does not store the address.": "受信側が、このメッセージの送信元の国(KR のような 2 文字)を付け加えます。アドレスは保存しません。", "This version has already been counted. The next event is sent after an update.": "このバージョンは集計済みです。次の送信は更新の後です。", "Agent integrations": "エージェント連携", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index cd6eb79c01..abb15832ab 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -949,14 +949,14 @@ export const KO: Record = { "It is the last tab of {workspace}: the workspace closes with it, and the agents and shells in it stop.": "{workspace}의 마지막 탭입니다. 워크스페이스도 함께 닫히고, 그 안의 에이전트와 셸이 멈춥니다.", "Tabs of {workspace}": "{workspace}의 탭", "{n} panes": "패널 {n}개", - "herdr web ui sends an anonymous count when it is installed and updated: the version, the OS, a random ID and which appearance settings are changed from their defaults. The receiver also notes the country it came from, never the address.": "herdr web ui는 설치와 업데이트 때 익명 집계를 보냅니다. 버전, OS, 무작위 ID, 그리고 기본값에서 바꾼 화면 설정이 무엇인지입니다. 받는 쪽은 보낸 국가도 기록하지만 주소는 기록하지 않습니다.", + "herdr web ui sends install and update counts with the version, OS, a random ID and app settings such as theme, text size and notification choices. Custom text and account identifiers are excluded. The receiver also notes the country, never the address.": "herdr web ui는 설치 및 업데이트 집계와 함께 버전, OS, 무작위 ID, 테마·텍스트 크기·알림 선택과 같은 앱 설정을 보냅니다. 사용자 지정 텍스트와 계정 식별자는 제외됩니다. 받는 쪽은 국가도 기록하지만 주소는 기록하지 않습니다.", "What is sent": "보내는 내용", "Turn off": "끄기", "Could not turn it off. Try again.": "이 설정을 끄지 못했습니다. 다시 시도하세요.", "Anonymous usage counts": "익명 사용 집계", "Turned off on this PC by HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK or a CI environment.": "이 PC에서는 HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK 또는 CI 환경 때문에 꺼져 있습니다.", "Send install and update counts": "설치·업데이트 집계 보내기", - "Once when the app is installed and once per update, with the names of the appearance settings changed from their defaults. The receiver notes the country a message came from but stores no IP address, and nothing about your terminals, agents or files is sent.": "앱을 설치할 때 한 번, 업데이트할 때마다 한 번, 기본값에서 바꾼 화면 설정의 이름과 함께 보냅니다. 받는 쪽은 메시지를 보낸 국가를 기록하지만 IP 주소는 저장하지 않으며, 터미널, 에이전트, 파일에 관한 내용은 보내지 않습니다.", + "Once on install and once per update, with the last browser's app settings. Custom fonts, shortcuts and replies are reported only as customization flags or counts, never their text. Account identifiers, terminal content and files are excluded. The receiver stores the country, not the IP address.": "앱 설치 시 한 번, 업데이트마다 한 번, 마지막으로 사용한 브라우저의 앱 설정과 함께 보냅니다. 사용자 지정 글꼴, 바로 가기 및 답변은 텍스트가 아니라 사용자 지정 여부 플래그나 개수로만 보고됩니다. 계정 식별자, 터미널 내용 및 파일은 제외됩니다. 받는 쪽은 IP 주소가 아닌 국가를 저장합니다.", "The receiver adds the country this message comes from (two letters, such as KR). It does not store the address.": "받는 쪽에서 이 메시지를 보낸 국가(KR 같은 두 글자)를 덧붙입니다. 주소는 저장하지 않습니다.", "This version has already been counted. The next event is sent after an update.": "이 버전은 이미 집계되었습니다. 다음 전송은 업데이트 후입니다.", "Agent integrations": "에이전트 통합", diff --git a/src/lib/i18n.tr.ts b/src/lib/i18n.tr.ts index ba1b85cabd..4d123444a8 100644 --- a/src/lib/i18n.tr.ts +++ b/src/lib/i18n.tr.ts @@ -904,14 +904,14 @@ export const TR: Record = { "It is the last tab of {workspace}: the workspace closes with it, and the agents and shells in it stop.": "Bu, {workspace} öğesinin son sekmesi: çalışma alanı onunla kapanır ve içindeki ajanlar ile kabuklar durur.", "Tabs of {workspace}": "{workspace} sekmeleri", "{n} panes": "{n} panel", - "herdr web ui sends an anonymous count when it is installed and updated: the version, the OS, a random ID and which appearance settings are changed from their defaults. The receiver also notes the country it came from, never the address.": "herdr web ui, kurulumda ve her güncellemede sürümü, işletim sistemini, rastgele bir kimliği ve hangi görünüm ayarlarının varsayılandan değiştirildiğini içeren anonim bir sayaç gönderir. Alıcı mesajın geldiği ülkeyi kaydeder; IP adresini hiçbir zaman kaydetmez.", + "herdr web ui sends install and update counts with the version, OS, a random ID and app settings such as theme, text size and notification choices. Custom text and account identifiers are excluded. The receiver also notes the country, never the address.": "herdr web ui, sürüm, işletim sistemi, rastgele bir kimlik ve tema, metin boyutu ve bildirim tercihleri gibi uygulama ayarlarıyla birlikte kurulum ve güncelleme sayılarını gönderir. Özel metinler ve hesap tanımlayıcıları gönderilmez. Alıcı ülkeyi de kaydeder, adresi asla kaydetmez.", "What is sent": "Ne gönderiliyor", "Turn off": "Kapat", "Could not turn it off. Try again.": "Kapatılamadı. Tekrar deneyin.", "Anonymous usage counts": "Anonim kullanım sayaçları", "Turned off on this PC by HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK or a CI environment.": "Bu bilgisayarda HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK veya bir CI ortamıyla kapatıldı.", "Send install and update counts": "Kurulum ve güncelleme sayaçlarını gönder", - "Once when the app is installed and once per update, with the names of the appearance settings changed from their defaults. The receiver notes the country a message came from but stores no IP address, and nothing about your terminals, agents or files is sent.": "Uygulama kurulduğunda bir kez ve her güncellemede bir kez, varsayılandan değiştirilen görünüm ayarlarının adlarıyla birlikte. Alıcı, mesajın hangi ülkeden geldiğini not eder ama IP adresi saklamaz; terminalleriniz, ajanlarınız veya dosyalarınız hakkında hiçbir şey gönderilmez.", + "Once on install and once per update, with the last browser's app settings. Custom fonts, shortcuts and replies are reported only as customization flags or counts, never their text. Account identifiers, terminal content and files are excluded. The receiver stores the country, not the IP address.": "Kurulum sırasında bir kez, her güncellemede bir kez gönderilir; bunlara son kullanılan tarayıcının uygulama ayarları eşlik eder. Özel yazı tipleri, kısayollar ve yanıtlar yalnızca özelleştirme işaretleri veya sayıları olarak bildirilir; metinleri asla gönderilmez. Hesap tanımlayıcıları, terminal içeriği ve dosyalar gönderilmez. Alıcı IP adresini değil, ülkeyi kaydeder.", "The receiver adds the country this message comes from (two letters, such as KR). It does not store the address.": "Alıcı, mesajın geldiği ülkeyi iki harfli koduyla (ör. TR) kaydeder. IP adresini saklamaz.", "This version has already been counted. The next event is sent after an update.": "Bu sürüm zaten sayıldı. Sonraki olay bir güncellemeden sonra gönderilir.", "Agent integrations": "Ajan entegrasyonları", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 19abaf0f48..9d0eba8487 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -953,14 +953,14 @@ export const ZH: Record = { "It is the last tab of {workspace}: the workspace closes with it, and the agents and shells in it stop.": "这是 {workspace} 的最后一个标签页:工作区会随之关闭,其中的代理和 shell 会停止。", "Tabs of {workspace}": "{workspace} 的标签页", "{n} panes": "{n} 个窗格", - "herdr web ui sends an anonymous count when it is installed and updated: the version, the OS, a random ID and which appearance settings are changed from their defaults. The receiver also notes the country it came from, never the address.": "herdr web ui 会在安装和更新时发送一次匿名统计:版本、操作系统、一个随机 ID,以及哪些外观设置已从默认值更改。接收端还会记录消息来自的国家,但不记录地址。", + "herdr web ui sends install and update counts with the version, OS, a random ID and app settings such as theme, text size and notification choices. Custom text and account identifiers are excluded. The receiver also notes the country, never the address.": "herdr web ui 会发送安装和更新次数,以及版本、操作系统、随机 ID 和主题、文字大小、通知选项等应用设置。不会包含自定义文本和账户标识符。接收端也会记录国家,但绝不记录地址。", "What is sent": "发送的内容", "Turn off": "关闭统计", "Could not turn it off. Try again.": "未能关闭,请重试。", "Anonymous usage counts": "匿名使用统计", "Turned off on this PC by HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK or a CI environment.": "此电脑因 HERDR_WEB_TELEMETRY=0、DO_NOT_TRACK 或 CI 环境而已关闭。", "Send install and update counts": "发送安装和更新统计", - "Once when the app is installed and once per update, with the names of the appearance settings changed from their defaults. The receiver notes the country a message came from but stores no IP address, and nothing about your terminals, agents or files is sent.": "安装应用时发送一次,每次更新再发送一次,并附上已从默认值更改的外观设置的名称。接收端会记录消息来自的国家,但不保存 IP 地址,也不发送任何关于终端、代理或文件的内容。", + "Once on install and once per update, with the last browser's app settings. Custom fonts, shortcuts and replies are reported only as customization flags or counts, never their text. Account identifiers, terminal content and files are excluded. The receiver stores the country, not the IP address.": "应用安装时发送一次,每次更新发送一次,并附带最后使用的浏览器中的应用设置。自定义字体、快捷键和快捷回复仅以自定义标记或数量的形式上报,不会上报其文本。账户标识符、终端内容和文件均不包含在内。接收端保存国家/地区,而非 IP 地址。", "The receiver adds the country this message comes from (two letters, such as KR). It does not store the address.": "接收端会附加这条消息来自的国家(两个字母,例如 KR)。不保存地址。", "This version has already been counted. The next event is sent after an update.": "此版本已统计。下一次发送在更新之后。", "Agent integrations": "代理集成", diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 17ed88aa2f..2b2832acee 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import { FONT_FAMILY_MAX_CHARS } from "./fontFamily.ts"; import { DEFAULT_KEY_BAR_ITEMS, migrateKeyBarItems } from "./keyBar.ts"; import { SHARED_SETTING_KEYS } from "../../shared/preferences.ts"; -import { SETTING_SCOPE, changedSettings, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, MARKDOWN_WIDTHS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; +import { NOTICE_VERSION, telemetrySettings } from "../../shared/telemetry.ts"; +import { SETTING_SCOPE, telemetrySettingsSnapshot, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, MARKDOWN_WIDTHS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; it("keeps a tab's pane while out of use until this device turns the pause on", () => { expect(DEFAULT_SETTINGS.releasePaneAway).toBe(false); @@ -83,11 +84,123 @@ it("draws agent logos in their brand colors unless Mono was chosen", () => { } }); -it("names the appearance settings changed from their defaults, and no other setting", () => { - expect(changedSettings(DEFAULT_SETTINGS)).toEqual([]); - expect(changedSettings({ ...DEFAULT_SETTINGS, agentPins: true, theme: "light", agentCards: true })).toEqual(["theme", "agentCards", "agentPins"]); - // a setting the user types, or one outside the list, is never named - expect(changedSettings({ ...DEFAULT_SETTINGS, terminalFontFamily: "D2Coding", terminalFontSize: 16, quietOpenedDone: true })).toEqual([]); +describe("telemetry settings snapshot", () => { + it("reports current enum, boolean and bounded scalar values", () => { + const settings = sanitizeSettings({ + terminalInputMode: "direct", + theme: "light", + density: "compact", + sidebarRows: "one", + agentOrder: "activity", + agentMarks: "mono", + palette: "forest", + terminalFontSize: 22, + terminalCursorStyle: "underline", + terminalCursorBlink: false, + terminalWheelSpeed: 10, + chatFontSize: 11, + chatWidth: "wide", + autoSendQueued: false, + language: "ko", + alertDone: "always", + usageCount: "left", + usageGlance: "session", + defaultView: "chat", + voiceInput: "on", + voiceLanguage: "ko-KR", + wrapCode: true, + markdownWidth: "full", + }); + const snapshot = telemetrySettingsSnapshot(settings); + + expect(NOTICE_VERSION).toBe(3); + expect(snapshot).toMatchObject({ + terminalInputMode: "direct", + theme: "light", + density: "compact", + sidebarRows: "one", + agentOrder: "activity", + agentMarks: "mono", + palette: "forest", + terminalFontSize: 22, + terminalCursorStyle: "underline", + terminalCursorBlink: false, + terminalWheelSpeed: 10, + chatFontSize: 11, + chatWidth: "wide", + autoSendQueued: false, + language: "ko", + alertDone: "always", + usageCount: "left", + usageGlance: "session", + defaultView: "chat", + voiceInput: "on", + voiceLanguage: "ko-KR", + wrapCode: true, + markdownWidth: "full", + }); + expect(telemetrySettings(snapshot)).toEqual(snapshot); + expect(telemetrySettingsSnapshot(DEFAULT_SETTINGS)).toMatchObject({ + terminalFontCustom: false, + chatFontCustom: false, + shortcutsCustom: false, + keyBarCustom: false, + quickRepliesCustom: false, + quickReplyCount: DEFAULT_SETTINGS.quickReplies.length, + usageHiddenCount: 0, + }); + }); + + it("summarizes custom text and account-key settings without sending their contents", () => { + const settings = sanitizeSettings({ + terminalFontFamily: "private-terminal-font", + chatFontFamily: "private-chat-font", + shortcutOverrides: { palette: "x" }, + keyBarItems: [{ type: "key", key: "x" }], + keyBarExtras: ["alt", "shift-tab"], + quickReplies: ["private quick reply"], + usageOrder: ["private-order-account-id"], + usageHidden: ["private-hidden-account-id"], + }); + const snapshot = telemetrySettingsSnapshot(settings); + const serialized = JSON.stringify(snapshot); + + expect(snapshot).toMatchObject({ + terminalFontCustom: true, + chatFontCustom: true, + shortcutsCustom: true, + keyBarCustom: true, + quickRepliesCustom: true, + quickReplyCount: 1, + usageHiddenCount: 1, + }); + for (const key of ["keyBarExtras", "keyBarItems", "shortcutOverrides", "terminalFontFamily", "chatFontFamily", "quickReplies", "usageOrder", "usageHidden"]) { + expect(snapshot).not.toHaveProperty(key); + } + for (const raw of ["private-terminal-font", "private-chat-font", "private quick reply", "private-order-account-id", "private-hidden-account-id"]) { + expect(serialized).not.toContain(raw); + } + }); + + it("requires a complete, valid schema and reconstructs a clean own-key snapshot", () => { + const snapshot = telemetrySettingsSnapshot(DEFAULT_SETTINGS); + const clean = telemetrySettings(snapshot); + + expect(clean).toEqual(snapshot); + expect(clean).not.toBe(snapshot); + expect(Object.getPrototypeOf(clean!)).toBe(Object.prototype); + expect(Object.keys(clean!)).toEqual(Object.keys(snapshot)); + expect(telemetrySettings(Object.fromEntries(Object.entries(snapshot).filter(([key]) => key !== "theme")))).toBeNull(); + expect(telemetrySettings({ ...snapshot, unknownSetting: true })).toBeNull(); + expect(telemetrySettings({ ...snapshot, theme: "neon" })).toBeNull(); + expect(telemetrySettings({ ...snapshot, theme: null })).toBeNull(); + expect(telemetrySettings({ ...snapshot, terminalFontSize: 9 })).toBeNull(); + expect(telemetrySettings({ ...snapshot, terminalWheelSpeed: 2.5 })).toBeNull(); + expect(telemetrySettings({ ...snapshot, chatFontSize: 10 })).toBeNull(); + expect(telemetrySettings({ ...snapshot, quickReplyCount: 13 })).toBeNull(); + expect(telemetrySettings({ ...snapshot, usageHiddenCount: 65 })).toBeNull(); + expect(telemetrySettings(Object.assign(Object.create({ inherited: true }), snapshot))).toBeNull(); + }); }); describe("clipboard from a pane", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 340ac1d603..9a17d4b774 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -5,7 +5,7 @@ * `--term-*` tokens of each theme for PaneTerminal's theme object. */ -import { REPORTED_SETTINGS, type ReportedSetting } from "../../shared/telemetry.ts"; +import type { TelemetrySettings } from "../../shared/telemetry.ts"; import { SHARED_SETTING_KEYS, type SharedSettingKey } from "../../shared/preferences.ts"; import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { LANGUAGE_SETTINGS, LOCALE_TAGS, resolveLanguage, setCurrentLanguage, type Language, type LanguageSetting } from "./i18n.ts"; @@ -164,9 +164,72 @@ export interface Settings { export const MARKDOWN_WIDTHS = ["readable", "full"] as const; export type MarkdownWidth = (typeof MARKDOWN_WIDTHS)[number]; -/** The appearance settings that differ from their defaults, by name: what the anonymous counts tell (shared/telemetry.ts). */ -export function changedSettings(settings: Settings): ReportedSetting[] { - return REPORTED_SETTINGS.filter((name) => settings[name] !== DEFAULT_SETTINGS[name]); +/** Raw custom text, key combinations and account keys are never copied. Requiring every other + * Settings field in the projection makes a new field require an explicit collection decision. */ +type TelemetrySourceSettings = Omit; + +/** Compare structured custom settings by content against this app's built-in defaults. */ +function differsFromDefault(value: unknown, defaultValue: unknown): boolean { + return JSON.stringify(value) !== JSON.stringify(defaultValue); +} + +/** Full allowlisted snapshot. Text and account-key values are only used for safe summaries. */ +export function telemetrySettingsSnapshot(settings: Settings): TelemetrySettings & TelemetrySourceSettings { + return { + terminalInputMode: settings.terminalInputMode, + theme: settings.theme, + density: settings.density, + sidebarRows: settings.sidebarRows, + agentOrder: settings.agentOrder, + agentCards: settings.agentCards, + agentPins: settings.agentPins, + quietOpenedDone: settings.quietOpenedDone, + agentMarks: settings.agentMarks, + palette: settings.palette, + terminalFontSize: settings.terminalFontSize, + terminalCursorStyle: settings.terminalCursorStyle, + terminalCursorBlink: settings.terminalCursorBlink, + terminalWheelSpeed: settings.terminalWheelSpeed, + paneClipboard: settings.paneClipboard, + releasePaneAway: settings.releasePaneAway, + narrowPaneFont: settings.narrowPaneFont, + chatFontSize: settings.chatFontSize, + chatWidth: settings.chatWidth, + enterSends: settings.enterSends, + autoSendQueued: settings.autoSendQueued, + showThinking: settings.showThinking, + keepScreenOn: settings.keepScreenOn, + language: settings.language, + alertsOn: settings.alertsOn, + alertInput: settings.alertInput, + alertDone: settings.alertDone, + alertInApp: settings.alertInApp, + alertSound: settings.alertSound, + showQuickReplies: settings.showQuickReplies, + showSuggestionChip: settings.showSuggestionChip, + showUsage: settings.showUsage, + usageCount: settings.usageCount, + usagePace: settings.usagePace, + usageGlance: settings.usageGlance, + defaultView: settings.defaultView, + voiceInput: settings.voiceInput, + voiceLanguage: settings.voiceLanguage, + voicePolishChat: settings.voicePolishChat, + voicePolishTerminal: settings.voicePolishTerminal, + wrapCode: settings.wrapCode, + highlightCode: settings.highlightCode, + markdownWidth: settings.markdownWidth, + terminalFontCustom: settings.terminalFontFamily !== DEFAULT_SETTINGS.terminalFontFamily, + chatFontCustom: settings.chatFontFamily !== DEFAULT_SETTINGS.chatFontFamily, + shortcutsCustom: differsFromDefault(settings.shortcutOverrides, DEFAULT_SETTINGS.shortcutOverrides), + keyBarCustom: differsFromDefault(settings.keyBarItems, DEFAULT_SETTINGS.keyBarItems) + || differsFromDefault(settings.keyBarExtras, DEFAULT_SETTINGS.keyBarExtras), + quickRepliesCustom: differsFromDefault(settings.quickReplies, DEFAULT_SETTINGS.quickReplies), + quickReplyCount: settings.quickReplies.length, + usageHiddenCount: settings.usageHidden.length, + }; } /** diff --git a/telemetry/README.md b/telemetry/README.md index 9746809854..28595d7d5b 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -6,38 +6,48 @@ sends and when is in [the guide](../docs/guide.md#anonymous-usage-counts); the s It stores one row per install, event and version in a D1 table (`schema.sql`): the day, the event, the random install ID, the version and the version it replaced, the OS, the CPU -architecture, how the app was installed, and the names of the appearance settings the app says are -changed from their defaults (`changed_settings`: sorted and comma-joined, empty when none is, -`NULL` when the app told none; only names from the list in `worker.ts`, never a value). It adds the country the request came from: the two -characters Cloudflare works out from the sender's address (`request.cf.country`, such as `KR`), -and nothing finer (no city, region or network; a request Cloudflare marks as Tor or unknown is -stored with no country). The Worker itself never reads the sender's address -or any header other than the content type, and Workers Logs are off (`wrangler.toml`), so no -request log keeps the address either. +architecture, how the app was installed, and `settings`, a JSON snapshot validated by the shared +schema in `shared/telemetry.ts` (`NULL` when the sender has not provided one). The snapshot +contains only allowlisted enums, booleans and bounded numbers, never raw user-entered strings or +account identifiers. It adds the country the request came from: the two characters Cloudflare +works out from the sender's address (`request.cf.country`, such as `KR`), and nothing finer (no +city, region or network; a request Cloudflare marks as Tor or unknown is stored with no country). +The Worker itself never reads the sender's address or any header other than the content type, and +Workers Logs are off (`wrangler.toml`), so no request log keeps the address either. ## Deploy +Prepare the database before deploying the Worker, then release the app version that sends +snapshots only after the Worker is deployed. On a brand-new database, create it and apply the +schema: + ```sh cd telemetry bunx wrangler login bunx wrangler d1 create herdr-web-ui-telemetry # done once: its database_id is in wrangler.toml; only a new account repeats it bunx wrangler d1 execute herdr-web-ui-telemetry --remote --file schema.sql -bunx wrangler deploy ``` -A database made before the country was kept needs its column once, before the Worker that writes -it is deployed (an INSERT naming a missing column fails, and the event is lost until its sender -starts again): +For an existing database, `schema.sql` does not add columns to its existing table. Add whichever +columns are missing, in this order, before deploying the Worker. A database that predates country +collection needs `country`: ```sh bunx wrangler d1 execute herdr-web-ui-telemetry --remote --command "ALTER TABLE events ADD COLUMN country TEXT" ``` -A database made before the changed settings were kept needs that column the same way, before the -Worker that writes it is deployed: +Then add `settings` to any existing database that does not have it: + +```sh +bunx wrangler d1 execute herdr-web-ui-telemetry --remote --command "ALTER TABLE events ADD COLUMN settings TEXT" +``` + +These ALTERs preserve historical rows; their newly added `settings` value remains `NULL` and is +not inferred from old data. After the database is ready, deploy the Worker and then release the +app version that sends snapshots: ```sh -bunx wrangler d1 execute herdr-web-ui-telemetry --remote --command "ALTER TABLE events ADD COLUMN changed_settings TEXT" +bunx wrangler deploy ``` The Worker answers at `https://herdr-web-ui-telemetry..workers.dev/v1/events`, and @@ -65,14 +75,32 @@ SELECT country, COUNT(*) FROM ( SELECT (SELECT country FROM events WHERE install_id = e.install_id AND country IS NOT NULL ORDER BY rowid DESC LIMIT 1) AS country FROM events e GROUP BY install_id ) GROUP BY country ORDER BY 2 DESC; --- installs that changed each appearance setting, of the installs that told their settings --- (each install by the last event that told them): setting, installs that changed it, installs that told -WITH latest AS ( - SELECT (SELECT changed_settings FROM events WHERE install_id = e.install_id AND changed_settings IS NOT NULL ORDER BY rowid DESC LIMIT 1) AS changed - FROM events e GROUP BY install_id -), told AS (SELECT changed FROM latest WHERE changed IS NOT NULL), -names(name) AS (VALUES ('theme'), ('density'), ('palette'), ('sidebarRows'), ('agentOrder'), ('agentCards'), ('agentPins'), ('planFlow'), ('planButton')) -SELECT name, (SELECT COUNT(*) FROM told WHERE ',' || changed || ',' LIKE '%,' || name || ',%') AS changed, (SELECT COUNT(*) FROM told) AS told FROM names; +-- values of every setting in the last non-NULL snapshot per install; installs with no snapshot +-- are excluded, and a later event with settings=NULL does not replace a prior snapshot +WITH latest_settings AS ( + SELECT install_id, settings, + ROW_NUMBER() OVER (PARTITION BY install_id ORDER BY rowid DESC) AS recency + FROM events + WHERE settings IS NOT NULL +) +SELECT setting.key, setting.type, setting.value, COUNT(*) AS installs +FROM latest_settings +CROSS JOIN json_each(latest_settings.settings) AS setting +WHERE latest_settings.recency = 1 +GROUP BY setting.key, setting.type, setting.value +ORDER BY setting.key, installs DESC, setting.type, setting.value; +-- a focused distribution using json_extract: theme in each install's last non-NULL snapshot +WITH latest_settings AS ( + SELECT install_id, settings, + ROW_NUMBER() OVER (PARTITION BY install_id ORDER BY rowid DESC) AS recency + FROM events + WHERE settings IS NOT NULL +) +SELECT json_extract(settings, '$.theme') AS theme, COUNT(*) AS installs +FROM latest_settings +WHERE recency = 1 +GROUP BY theme +ORDER BY installs DESC, theme; ``` `MAX(version)` compares text, which orders `0.10.0` before `0.9.0`; read the version table with diff --git a/telemetry/schema.sql b/telemetry/schema.sql index dd4ab0a090..0aa659141d 100644 --- a/telemetry/schema.sql +++ b/telemetry/schema.sql @@ -1,9 +1,8 @@ -- One row per install, event and version: a replayed event is ignored (INSERT OR IGNORE). -- No address, no time of day, nothing about the user's terminals or files. Of where an event -- came from, only the country (two characters, as Cloudflare names it); NULL in rows from --- before it was kept. changed_settings names the appearance settings changed from their defaults --- (sorted, comma-joined, '' for none); NULL when the sender told none. A table made before either --- column gets it from README.md's ALTERs. +-- before it was kept. settings is a complete, validated JSON snapshot or NULL until one is sent. +-- A table made before either column gets it from README.md's ALTERs. CREATE TABLE IF NOT EXISTS events ( day TEXT NOT NULL, event TEXT NOT NULL CHECK (event IN ('install', 'update')), @@ -14,7 +13,7 @@ CREATE TABLE IF NOT EXISTS events ( arch TEXT NOT NULL, install_method TEXT NOT NULL CHECK (install_method IN ('plugin', 'managed', 'source')), country TEXT, - changed_settings TEXT, + settings TEXT CHECK (settings IS NULL OR json_valid(settings)), UNIQUE (install_id, event, version) ); CREATE INDEX IF NOT EXISTS events_day ON events (day); diff --git a/telemetry/worker.test.ts b/telemetry/worker.test.ts index 856f01ed37..18eb412e8a 100644 --- a/telemetry/worker.test.ts +++ b/telemetry/worker.test.ts @@ -2,13 +2,65 @@ import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { REPORTED_SETTINGS, RETIRED_SETTINGS } from "../shared/telemetry.ts"; -import { handle, readEvent, requestCountry, SETTINGS, type Env } from "./worker.ts"; +import type { TelemetrySettings } from "../shared/telemetry.ts"; +import { handle, readEvent, requestCountry, type Env } from "./worker.ts"; const event = { event: "update", install_id: "0b5f3c2e-8a51-4c47-9d0e-3f6a2b1c9e84", version: "0.4.2", previous_version: "0.4.1", os: "linux", arch: "x64", install_method: "plugin", }; +const settings: TelemetrySettings = { + terminalInputMode: "auto", + theme: "dark", + density: "comfortable", + sidebarRows: "two", + agentOrder: "activity", + agentCards: true, + agentPins: false, + quietOpenedDone: true, + agentMarks: "mono", + palette: "catppuccin", + terminalFontSize: 22, + terminalCursorStyle: "underline", + terminalCursorBlink: true, + terminalWheelSpeed: 10, + paneClipboard: false, + releasePaneAway: true, + narrowPaneFont: false, + chatFontSize: 24, + chatWidth: "narrow", + enterSends: true, + autoSendQueued: false, + showThinking: true, + keepScreenOn: false, + language: "ko", + alertsOn: true, + alertInput: false, + alertDone: "always", + alertInApp: true, + alertSound: false, + showQuickReplies: true, + showSuggestionChip: false, + showUsage: true, + usageCount: "left", + usagePace: true, + usageGlance: "session", + defaultView: "terminal", + voiceInput: "auto", + voiceLanguage: "zh-TW", + voicePolishChat: true, + voicePolishTerminal: false, + wrapCode: true, + highlightCode: false, + markdownWidth: "full", + terminalFontCustom: true, + chatFontCustom: false, + shortcutsCustom: true, + keyBarCustom: false, + quickRepliesCustom: true, + quickReplyCount: 12, + usageHiddenCount: 64, +}; const now = new Date("2026-10-08T13:45:00Z"); function fakeDb() { @@ -23,35 +75,48 @@ const post = (body: string, type = "application/json") => const from = (request: Request, cf: unknown) => Object.assign(request, { cf }); describe("telemetry receiver", () => { - test("stores the event's fields, the day and the country, never the address", async () => { + test("stores a normalized complete settings snapshot, the event fields and the country, never the address", async () => { const { env, rows } = fakeDb(); - const response = await handle(from(post(JSON.stringify(event)), { country: "KR", city: "Seoul", postalCode: "04524", latitude: "37.5" }), env, now); + const unordered = Object.fromEntries(Object.entries(settings).reverse()); + const body = { ...event, settings: unordered, email: "person@example.com" }; + expect(new TextEncoder().encode(JSON.stringify(body)).byteLength).toBeLessThan(8192); + const response = await handle(from(post(JSON.stringify(body)), { country: "KR", city: "Seoul", postalCode: "04524", latitude: "37.5" }), env, now); expect(response.status).toBe(204); expect(rows).toHaveLength(1); expect(rows[0]!.query).toContain("INSERT OR IGNORE"); - expect(rows[0]!.values).toEqual(["2026-10-08", "update", event.install_id, "0.4.2", "0.4.1", "linux", "x64", "plugin", "KR", null]); + expect(rows[0]!.values).toEqual(["2026-10-08", "update", event.install_id, "0.4.2", "0.4.1", "linux", "x64", "plugin", "KR", JSON.stringify(settings)]); expect(JSON.stringify(rows)).not.toContain("203.0.113.9"); - expect(JSON.stringify(rows)).not.toMatch(/Seoul|04524|37\.5/); + expect(JSON.stringify(rows)).not.toMatch(/Seoul|04524|37\.5|person@example\.com/); + expect(readEvent({ ...event, settings: unordered }, now)?.settings).toEqual(settings); }); - test("an event with no country, or one that is not two characters, is stored without it", async () => { + test("missing and null snapshots stay null; country is stored only when it is a country", async () => { const { env, rows } = fakeDb(); expect((await handle(post(JSON.stringify(event)), env, now)).status).toBe(204); expect(rows[0]!.values[8]).toBeNull(); + expect(rows[0]!.values[9]).toBeNull(); + expect((await handle(post(JSON.stringify({ ...event, settings: null })), env, now)).status).toBe(204); + expect(rows[1]!.values[9]).toBeNull(); // XX is Cloudflare's "unknown" and T1 its Tor marker: neither is a country, and T1 would say more than one for (const cf of [undefined, {}, { country: "Korea" }, { country: "kr" }, { country: 82 }, { country: "XX" }, { country: "T1" }]) expect(requestCountry(from(post("{}"), cf))).toBeNull(); for (const country of ["US", "KR"]) expect(requestCountry(from(post("{}"), { country }))).toBe(country); }); - test("keeps the names of changed settings it knows, sorted, and tells none changed from none told", () => { - // installs older than a setting's removal still name it: the Worker keeps counting those - expect([...SETTINGS].sort()).toEqual([...REPORTED_SETTINGS, ...RETIRED_SETTINGS].sort()); - expect(readEvent({ ...event, changed_settings: ["theme", "agentCards", "theme"] }, now)?.changed_settings).toBe("agentCards,theme"); - expect(readEvent({ ...event, changed_settings: [] }, now)?.changed_settings).toBe(""); - // an older app sends no list; a newer one may name a setting this Worker does not know yet - expect(readEvent(event, now)?.changed_settings).toBeNull(); - expect(readEvent({ ...event, changed_settings: null }, now)?.changed_settings).toBeNull(); - expect(readEvent({ ...event, changed_settings: ["theme", "somethingNew", 7, "D2Coding, monospace"] }, now)?.changed_settings).toBe("theme"); + test("refuses malformed values and unexpected or sensitive snapshot keys without persisting them", async () => { + const { env, rows } = fakeDb(); + const invalidSettings = [ + { ...settings, theme: "user-selected theme" }, + { ...settings, terminalWheelSpeed: 11 }, + { ...settings, chatFontSize: "16px" }, + { ...settings, account: { accountId: "acct-private-42", email: "person@example.com" } }, + { ...settings, fontFamily: { value: "D2Coding" } }, + Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "agentCards")), + ]; + for (const bad of invalidSettings) { + expect(readEvent({ ...event, settings: bad }, now)).toBeNull(); + expect((await handle(post(JSON.stringify({ ...event, settings: bad })), env, now)).status).toBe(400); + } + expect(rows).toEqual([]); }); describe("against the real table", () => { @@ -60,20 +125,33 @@ describe("telemetry receiver", () => { const sqlite = (db: Database): Env => ({ DB: { prepare: (query) => ({ bind: (...values) => ({ run: async () => db.prepare(query).run(...(values as (string | null)[])) }) }) } }); const send = (env: Env, body: object, country?: string) => handle(from(post(JSON.stringify(body)), country ? { country } : undefined), env, now); - test("the INSERT fits a new table, and one that got its column from the README's ALTER", async () => { + test("the INSERT fits a new table and an upgraded table while retaining historical rows", async () => { const fresh = new Database(":memory:"); fresh.exec(schema); const migrated = new Database(":memory:"); - migrated.exec(schema.replace(" country TEXT,\n", "").replace(" changed_settings TEXT,\n", "")); - // a table from before the country was kept refuses the event: the ALTER goes first - await expect(send(sqlite(migrated), event, "KR")).rejects.toThrow(/country/); - const alters = [...readme.matchAll(/"(ALTER TABLE events ADD COLUMN[^"]+)"/g)].map((match) => match[1]!); - expect(alters).toHaveLength(2); + const legacySchema = schema + .replace(" country TEXT,\n", "") + .replace(" settings TEXT CHECK (settings IS NULL OR json_valid(settings)),\n", " changed_settings TEXT,\n"); + migrated.exec(legacySchema); + migrated.exec("INSERT INTO events (day, event, install_id, version, previous_version, os, arch, install_method, changed_settings) VALUES ('2026-10-07', 'install', '11111111-2222-4333-8444-555555555555', '0.4.0', NULL, 'linux', 'x64', 'plugin', 'theme')"); + const columns = migrated.query("PRAGMA table_info(events)").all().map((row) => (row as { name: string }).name); + expect(columns).toContain("changed_settings"); + expect(columns).not.toContain("settings"); + // An existing D1 cannot take the new INSERT until its columns are added. + await expect(send(sqlite(migrated), event, "KR")).rejects.toThrow(/country|settings/); + const alters = [...readme.matchAll(/"(ALTER TABLE events ADD COLUMN (?:country|settings) TEXT)"/g)].map((match) => match[1]!); + expect(alters).toEqual(["ALTER TABLE events ADD COLUMN country TEXT", "ALTER TABLE events ADD COLUMN settings TEXT"]); for (const alter of alters) migrated.exec(alter); + expect(migrated.query("SELECT settings, country, changed_settings FROM events").all()).toEqual([{ settings: null, country: null, changed_settings: "theme" }]); for (const db of [fresh, migrated]) { - expect((await send(sqlite(db), { ...event, changed_settings: ["planFlow", "agentCards"] }, "KR")).status).toBe(204); - expect(db.query("SELECT country, version, changed_settings FROM events").all()).toEqual([{ country: "KR", version: "0.4.2", changed_settings: "agentCards,planFlow" }]); + expect((await send(sqlite(db), { ...event, settings }, "KR")).status).toBe(204); + // A replay with different values and country is ignored for the same install/event/version. + expect((await send(sqlite(db), { ...event, settings: { ...settings, theme: "light" } }, "US")).status).toBe(204); + expect(db.query("SELECT country, version, settings FROM events WHERE version = '0.4.2'").all()).toEqual([ + { country: "KR", version: "0.4.2", settings: JSON.stringify(settings) }, + ]); } + expect(fresh.query("PRAGMA table_info(events)").all().map((row) => (row as { name: string }).name)).not.toContain("changed_settings"); }); test("the README's country query counts an install once, under the last country it was seen in", async () => { @@ -95,22 +173,27 @@ describe("telemetry receiver", () => { expect(counts).toContainEqual([null, 1]); }); - test("the README's settings query counts, of the installs that told their settings, those that changed each", async () => { + test("the README's json_each distribution uses each install's last non-null snapshot", async () => { const db = new Database(":memory:"); db.exec(schema); const env = sqlite(db); const ids = ["11111111-2222-4333-8444-555555555555", "22222222-3333-4444-8555-666666666666", "33333333-4444-4555-8666-777777777777"]; - // one install changed its mind between versions: its last answer counts; one changed nothing; one never told - await send(env, { ...event, install_id: ids[0], version: "0.4.1", previous_version: "0.4.0", changed_settings: ["theme"] }); - await send(env, { ...event, install_id: ids[0], changed_settings: ["agentCards", "planFlow"] }); - await send(env, { ...event, install_id: ids[1], changed_settings: [] }); + // The last non-null snapshot wins, even if a later event has no snapshot; the third install never reports one. + await send(env, { ...event, install_id: ids[0], version: "0.4.1", previous_version: "0.4.0", settings: { ...settings, theme: "light", density: "compact", agentCards: true } }); + await send(env, { ...event, install_id: ids[0], version: "0.4.3", previous_version: "0.4.2", settings: null }); + await send(env, { ...event, install_id: ids[1], settings: { ...settings, theme: "dark", density: "comfortable", agentCards: false } }); await send(env, { ...event, install_id: ids[2] }); - const query = /-- installs that changed each appearance setting[^\n]*\n(?:--[^\n]*\n)*(WITH[\s\S]+?;)/.exec(readme)![1]!; - const counts = db.query(query).all().map((row) => Object.values(row as object)); - expect(counts).toHaveLength(SETTINGS.size); - expect(counts).toContainEqual(["agentCards", 1, 2]); - expect(counts).toContainEqual(["planFlow", 1, 2]); - expect(counts).toContainEqual(["theme", 0, 2]); + const query = /-- values of every setting[\s\S]*?\n(WITH[\s\S]+?;)/.exec(readme)![1]!; + const values = db.query(query).all().map((row) => Object.values(row as object)); + expect(values).toContainEqual(["theme", "text", "light", 1]); + expect(values).toContainEqual(["theme", "text", "dark", 1]); + expect(values).toContainEqual(["agentCards", "true", 1, 1]); + expect(values).not.toContainEqual(["theme", "text", "dark", 2]); + const themeQuery = /-- a focused distribution using json_extract[\s\S]*?\n(WITH[\s\S]+?;)/.exec(readme)![1]!; + const themes = db.query(themeQuery).all().map((row) => Object.values(row as object)); + expect(themes).toContainEqual(["light", 1]); + expect(themes).toContainEqual(["dark", 1]); + expect(themes).toHaveLength(2); }); }); @@ -126,7 +209,7 @@ describe("telemetry receiver", () => { JSON.stringify({ ...event, install_method: "docker" }), ]) expect((await handle(post(body), env, now)).status).toBe(400); expect((await handle(post(JSON.stringify(event), "text/plain"), env, now)).status).toBe(415); - expect((await handle(post(JSON.stringify({ ...event, pad: "x".repeat(4096) })), env, now)).status).toBe(413); + expect((await handle(post(JSON.stringify({ ...event, pad: "x".repeat(9000) })), env, now)).status).toBe(413); expect((await handle(new Request("https://receiver.test/v1/events"), env, now)).status).toBe(405); expect((await handle(new Request("https://receiver.test/"), env, now)).status).toBe(404); expect(rows).toEqual([]); @@ -134,8 +217,8 @@ describe("telemetry receiver", () => { test("refuses an oversized body before it ends", async () => { const { env, rows } = fakeDb(); - // 4 KB arrive and the stream never closes: the answer must not wait for the rest - const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(4096).fill(0x20)); } }); + // More than the 8 KB cap arrive and the stream never closes: do not wait for the rest. + const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(8193).fill(0x20)); } }); const request = new Request("https://receiver.test/v1/events", { method: "POST", headers: { "content-type": "application/json" }, body, duplex: "half" } as RequestInit); expect((await handle(request, env, now)).status).toBe(413); expect(rows).toEqual([]); diff --git a/telemetry/worker.ts b/telemetry/worker.ts index 1847244a44..21f62ef1c9 100644 --- a/telemetry/worker.ts +++ b/telemetry/worker.ts @@ -3,10 +3,12 @@ * A Cloudflare Worker over one D1 table. It never reads the visitor's address or any header * beyond the content type; of where a request came from it keeps the country Cloudflare names * and nothing finer. It keeps the day rather than the time, and stores each - * (install, event, version) once, so a replayed event counts once. Of the app's settings it keeps - * the names of the appearance settings changed from their defaults. Deploy: telemetry/README.md. + * (install, event, version) once, so a replayed event counts once. It stores only the app's + * validated, allowlisted settings snapshot. Deploy: telemetry/README.md. */ +import { telemetrySettings, type TelemetrySettings } from "../shared/telemetry.ts"; + /** the part of Cloudflare's D1 binding this Worker uses */ interface D1Statement { bind(...values: unknown[]): { run(): Promise } } export interface Env { DB: { prepare(query: string): D1Statement } } @@ -20,19 +22,17 @@ export interface StoredEvent { os: string; arch: string; install_method: "plugin" | "managed" | "source"; - /** names of the appearance settings changed from their defaults, sorted and comma-joined ("" for none); null when the sender told none */ - changed_settings: string | null; + /** complete validated snapshot, or null when the sender has not provided one */ + settings: TelemetrySettings | null; } -const MAX_BODY_BYTES = 2048; +const MAX_BODY_BYTES = 8192; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; /** a release, or a prerelease of one (0.5.0-rc.1): the sender reports package.json's version as it is */ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}(-[0-9a-z.]{1,24})?$/; const WORD = /^[a-z0-9_]{1,16}$/; const EVENTS = new Set(["install", "update"]); const METHODS = new Set(["plugin", "managed", "source"]); -/** shared/telemetry.ts REPORTED_SETTINGS and RETIRED_SETTINGS (worker.test.ts holds them together); a name this Worker does not know is dropped, not refused */ -export const SETTINGS = new Set(["theme", "density", "palette", "sidebarRows", "agentOrder", "agentCards", "agentPins", "planFlow", "planButton"]); /** ISO 3166-1 alpha-2 */ const COUNTRY = /^[A-Z]{2}$/; @@ -45,12 +45,6 @@ export function requestCountry(request: Request): string | null { return typeof country === "string" && COUNTRY.test(country) && country !== "XX" ? country : null; } -/** The known setting names of a list, or null when the sender sent none (an older app, or a browser that has not told yet). */ -function changedSettings(value: unknown): string | null { - if (!Array.isArray(value)) return null; - return [...new Set(value.filter((name): name is string => typeof name === "string" && SETTINGS.has(name)))].sort().join(","); -} - /** the event as it is stored, or null when the body is not one */ export function readEvent(body: unknown, now: Date): StoredEvent | null { if (typeof body !== "object" || body === null || Array.isArray(body)) return null; @@ -63,11 +57,15 @@ export function readEvent(body: unknown, now: Date): StoredEvent | null { if (event === "install" && previous_version !== null) return null; if (typeof os !== "string" || !WORD.test(os) || typeof arch !== "string" || !WORD.test(arch)) return null; if (typeof install_method !== "string" || !METHODS.has(install_method)) return null; + const hasSettings = Object.prototype.hasOwnProperty.call(value, "settings"); + const settingsValue = hasSettings ? value["settings"] : null; + const settings = settingsValue === null ? null : telemetrySettings(settingsValue); + if (hasSettings && settingsValue !== null && settings === null) return null; return { day: now.toISOString().slice(0, 10), event: event as StoredEvent["event"], install_id, version, previous_version, os, arch, install_method: install_method as StoredEvent["install_method"], - changed_settings: changedSettings(value["changed_settings"]), + settings, }; } @@ -101,9 +99,10 @@ export async function handle(request: Request, env: Env, now = new Date()): Prom try { body = JSON.parse(text); } catch { return new Response(null, { status: 400 }); } const stored = readEvent(body, now); if (!stored) return new Response(null, { status: 400 }); + const settings = stored.settings === null ? null : JSON.stringify(stored.settings); await env.DB.prepare( - "INSERT OR IGNORE INTO events (day, event, install_id, version, previous_version, os, arch, install_method, country, changed_settings) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).bind(stored.day, stored.event, stored.install_id, stored.version, stored.previous_version, stored.os, stored.arch, stored.install_method, requestCountry(request), stored.changed_settings).run(); + "INSERT OR IGNORE INTO events (day, event, install_id, version, previous_version, os, arch, install_method, country, settings) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).bind(stored.day, stored.event, stored.install_id, stored.version, stored.previous_version, stored.os, stored.arch, stored.install_method, requestCountry(request), settings).run(); return new Response(null, { status: 204 }); } From 9945230216078fd9dcfb4c1467f22a0e46f3d78e Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 15:14:57 +0900 Subject: [PATCH 2/3] docs(changelog): link telemetry settings PR --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660aafe9de..d97d68aea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa choices, rather than only the names of changed appearance settings. Custom text and account identifiers stay out; custom choices use flags or counts. The expanded notice is shown again before sending, and existing opt-outs and install/update-only delivery stay unchanged. + ([#866](https://github.com/devswha/herdr-web-ui/pull/866)) - Markdown file previews are parsed in a worker with a 2 s budget and a 60 000-element limit, keeping the existing HTML blocks, images and reader-page navigation. A preview that cannot be drawn offers its source. Partial text and preview, highlighting or line-number limits are From 358bd7a20dd614e280df6eb89749dcdddc742218 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 16:24:47 +0900 Subject: [PATCH 3/3] fix(telemetry): tell the snapshot only where an event may go, refuse unknown event fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #866 (Codex astra medium, 2026-10-12), with the two settings #811 added meanwhile: - The browser posted its settings snapshot to the local server as soon as the app was unlocked, before the version-3 notice and whatever the switch or the environment said. Both producers (the notice line and Settings → About) now read the server's status first and tell the snapshot only while telemetry is on, not blocked and the notice seen; the first snapshot rides with the acknowledgement, and turning the switch on brings the snapshot the browser kept to itself. Off or blocked, About still shows the switch and the preview from the status alone. - The receiver refuses an event body with a field no sender has ever had, so a misspelled or foreign field is a 400 instead of silently dropped; the `changed_settings` list of senders before 0.4.9 is still accepted and not stored. - `pageOverlapRows` (0-100) and `chatPageKeys` (#811) join the schema and the projection: 45 values and 7 flags/counts. The strict receiver means the Worker must be deployed before the app release that adds a key, or events are refused and retried unreported. - `voiceLanguage` stays in the allowlist: an explicit preference from a fixed list, noted in the review as the one value naming a region beyond the UI language. - telemetry regression: the first snapshot is the acknowledgement's, and an opted-out page sends none on reload; receiver tests for the refused field and the legacy sender. Co-Authored-By: Claude Fable 5.1 --- scripts/telemetry-regression.ts | 12 +++++++++- shared/telemetry.ts | 5 ++++ src/components/TelemetryControls.tsx | 34 ++++++++++++++++++++-------- src/lib/api.test.ts | 2 +- src/lib/settings.ts | 2 ++ telemetry/worker.test.ts | 19 ++++++++++++++-- telemetry/worker.ts | 4 ++++ 7 files changed, 65 insertions(+), 13 deletions(-) diff --git a/scripts/telemetry-regression.ts b/scripts/telemetry-regression.ts index c125c9e677..92244d8998 100644 --- a/scripts/telemetry-regression.ts +++ b/scripts/telemetry-regression.ts @@ -117,6 +117,8 @@ export async function checkTelemetrySettings(browser: Browser): Promise { await notice.waitFor({ state: "visible" }); await until(async () => (await statusAt(origin)).notice_seen, "the visible notice is reacknowledged"); assert.ok(changes.some((change) => change.notice_seen === true && change.notice_version === NOTICE_VERSION), "the browser acknowledges exactly the current notice version"); + assert.equal(changes.findIndex((change) => change.settings !== undefined), changes.findIndex((change) => change.notice_seen === true), + "no settings snapshot leaves the browser before the notice is acknowledged; the first rides with the acknowledgement"); await until(async () => { const settings = (await statusAt(origin)).next?.settings; return settings?.palette === "forest" && settings.terminalFontSize === 17; @@ -169,8 +171,16 @@ export async function checkTelemetrySettings(browser: Browser): Promise { fixtureNow += SEND_GRACE_MS + 1; await telemetry.report(); assert.deepEqual(sent, [], "the grace period and fixture-only transport prevent outgoing telemetry"); + // turned off, a page keeps its settings to itself: the status is read, nothing is told + const beforeReload = changes.length; + const statusRead = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/telemetry" && response.request().method() === "GET"); + await page.reload(); + await statusRead; + await page.locator(".conn-live").waitFor(); + await page.waitForTimeout(500); + assert.equal(changes.slice(beforeReload).some((change) => change.settings !== undefined), false, "an opted-out install sends no settings snapshot to its server"); assert.deepEqual(errors, []); - console.log("PASS telemetry settings: current notice reacknowledged, safe preview and snapshot update, opt-out stops pending delivery"); + console.log("PASS telemetry settings: current notice reacknowledged, snapshot first with the acknowledgement, safe preview and snapshot update, opt-out stops pending delivery and later snapshots"); } finally { await context?.close(); telemetry?.stop(); diff --git a/shared/telemetry.ts b/shared/telemetry.ts index 1f8abef9a3..0feb7b6d51 100644 --- a/shared/telemetry.ts +++ b/shared/telemetry.ts @@ -78,6 +78,9 @@ export interface TelemetrySettings { quickRepliesCustom: boolean; quickReplyCount: number; usageHiddenCount: number; + /** paging shortcuts (#811): lines kept between pages, and plain PageUp/PageDown while composing */ + pageOverlapRows: number; + chatPageKeys: boolean; } /** The browser's full, non-sensitive snapshot. */ @@ -148,6 +151,8 @@ const TELEMETRY_SETTINGS_VALIDATORS = { quickRepliesCustom: booleanValue, quickReplyCount: integerBetween(0, 12), usageHiddenCount: integerBetween(0, 64), + pageOverlapRows: integerBetween(0, 100), + chatPageKeys: booleanValue, } satisfies Record boolean>; const TELEMETRY_SETTINGS_KEYS = Object.keys(TELEMETRY_SETTINGS_VALIDATORS) as (keyof TelemetrySettings)[]; diff --git a/src/components/TelemetryControls.tsx b/src/components/TelemetryControls.tsx index c334250021..95c1bd2a2e 100644 --- a/src/components/TelemetryControls.tsx +++ b/src/components/TelemetryControls.tsx @@ -26,18 +26,25 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: const settingsSnapshotRef = useRef(settingsSnapshot); settingsSnapshotRef.current = settingsSnapshot; const submittedSettingsSignature = useRef(null); + // the server's word on the switch, the environment and the notice: the snapshot is told only where + // an event may go out, so an install that is off, blocked or has not seen the notice keeps its + // settings to itself. The first snapshot rides with the acknowledgement below. + const [status, setStatus] = useState(null); + const allowed = status !== null && status.enabled && !status.blocked_by_env && status.notice_seen; useEffect(() => { - if (!enabled || submittedSettingsSignature.current === settingsSignature) return; + if (!enabled || !allowed || submittedSettingsSignature.current === settingsSignature) return; submittedSettingsSignature.current = settingsSignature; - changeTelemetry({ settings: settingsSnapshot }).catch((error: unknown) => { + changeTelemetry({ settings: settingsSnapshot }).then(setStatus, (error: unknown) => { if (!(error instanceof ApiError && error.status === 404)) console.error("telemetry settings", error); }); - }, [enabled, settingsSignature, settingsSnapshot]); + }, [enabled, allowed, settingsSignature, settingsSnapshot]); useEffect(() => { if (!enabled) return; let live = true; - fetchTelemetry().then((status) => { - if (live && status && !status.notice_seen && status.enabled && !status.blocked_by_env) setShown(true); + fetchTelemetry().then((current) => { + if (!live || !current) return; + setStatus(current); + if (!current.notice_seen && current.enabled && !current.blocked_by_env) setShown(true); }, (error: unknown) => console.error("telemetry status", error)); return () => { live = false; }; }, [enabled]); @@ -53,11 +60,13 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: const record = () => { if (recorded || document.visibilityState !== "visible") return; recorded = true; + const snapshot = settingsSnapshotRef.current; changeTelemetry({ notice_seen: true, notice_version: NOTICE_VERSION, - settings: settingsSnapshotRef.current, - }).catch((error: unknown) => console.error("telemetry notice", error)); + settings: snapshot, + }).then((next) => { submittedSettingsSignature.current = JSON.stringify(snapshot); setStatus(next); }, + (error: unknown) => console.error("telemetry notice", error)); }; const onVisibility = () => { if (document.visibilityState === "visible") afterPaint(); }; afterPaint(); @@ -69,7 +78,7 @@ export function TelemetryNotice({ enabled, onOpen }: { enabled: boolean; onOpen: setTurningOff(true); setOffFailed(false); // the line stays until the server has the switch off, so a failure is seen and can be retried - changeTelemetry({ enabled: false }).then(() => setClosed(true), (error: unknown) => { + changeTelemetry({ enabled: false }).then((next) => { setStatus(next); setClosed(true); }, (error: unknown) => { console.error("telemetry off", error); setOffFailed(true); }).finally(() => setTurningOff(false)); @@ -108,6 +117,12 @@ export function TelemetryControls() { setStatus(null); return; } + // off, blocked or before the notice: the switch and the preview show the server's state, and the + // snapshot stays in the browser + if (!current.enabled || current.blocked_by_env || !current.notice_seen) { + setStatus(current); + return; + } return changeTelemetry({ settings: snapshot }).then((next) => { if (!live || request !== statusRequest.current) return; setStatus(next); @@ -130,7 +145,8 @@ export function TelemetryControls() { const request = ++statusRequest.current; setSaving(true); setError(null); - changeTelemetry({ enabled }).then( + // turned on, the switch brings the snapshot the browser kept to itself while it was off + changeTelemetry(enabled ? { enabled, settings: settingsSnapshotRef.current } : { enabled }).then( (next) => { if (request === statusRequest.current) setStatus(next); }, (failure: unknown) => { if (request === statusRequest.current) setError(failure instanceof Error ? failure.message : String(failure)); }, ).finally(() => setSaving(false)); diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index d400c4f19c..278b31fc2b 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -49,7 +49,7 @@ it("posts the full safe telemetry snapshot and current notice version", async () const body = JSON.parse(String(requestInit?.body)) as Record; expect(body).toEqual(change); expect(body).not.toHaveProperty("changed_settings"); - expect(Object.keys(body.settings as object)).toHaveLength(50); + expect(Object.keys(body.settings as object)).toHaveLength(52); expect(body.settings).not.toHaveProperty("terminalFontFamily"); expect(body.settings).not.toHaveProperty("chatFontFamily"); expect(body.settings).not.toHaveProperty("usageHidden"); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index bd65702f36..475c9e3ec7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -232,6 +232,8 @@ export function telemetrySettingsSnapshot(settings: Settings): TelemetrySettings quickRepliesCustom: differsFromDefault(settings.quickReplies, DEFAULT_SETTINGS.quickReplies), quickReplyCount: settings.quickReplies.length, usageHiddenCount: settings.usageHidden.length, + pageOverlapRows: settings.pageOverlapRows, + chatPageKeys: settings.chatPageKeys, }; } diff --git a/telemetry/worker.test.ts b/telemetry/worker.test.ts index 18eb412e8a..8f9b370807 100644 --- a/telemetry/worker.test.ts +++ b/telemetry/worker.test.ts @@ -60,6 +60,8 @@ const settings: TelemetrySettings = { quickRepliesCustom: true, quickReplyCount: 12, usageHiddenCount: 64, + pageOverlapRows: 1, + chatPageKeys: false, }; const now = new Date("2026-10-08T13:45:00Z"); @@ -75,10 +77,23 @@ const post = (body: string, type = "application/json") => const from = (request: Request, cf: unknown) => Object.assign(request, { cf }); describe("telemetry receiver", () => { + test("refuses a field no sender has ever had, and still takes an older sender's changed_settings", async () => { + const { env, rows } = fakeDb(); + const refused = await handle(from(post(JSON.stringify({ ...event, settings, email: "person@example.com" })), { country: "KR" }), env, now); + expect(refused.status).toBe(400); + expect(rows).toHaveLength(0); + const { settings: _current, ...legacy } = { ...event, settings } as Record; + const accepted = await handle(from(post(JSON.stringify({ ...legacy, changed_settings: ["theme", "palette"] })), { country: "KR" }), env, now); + expect(accepted.status).toBe(204); + expect(rows).toHaveLength(1); + expect(rows[0]!.values.at(-1)).toBeNull(); + expect(JSON.stringify(rows)).not.toContain("changed_settings"); + }); + test("stores a normalized complete settings snapshot, the event fields and the country, never the address", async () => { const { env, rows } = fakeDb(); const unordered = Object.fromEntries(Object.entries(settings).reverse()); - const body = { ...event, settings: unordered, email: "person@example.com" }; + const body = { ...event, settings: unordered }; expect(new TextEncoder().encode(JSON.stringify(body)).byteLength).toBeLessThan(8192); const response = await handle(from(post(JSON.stringify(body)), { country: "KR", city: "Seoul", postalCode: "04524", latitude: "37.5" }), env, now); expect(response.status).toBe(204); @@ -86,7 +101,7 @@ describe("telemetry receiver", () => { expect(rows[0]!.query).toContain("INSERT OR IGNORE"); expect(rows[0]!.values).toEqual(["2026-10-08", "update", event.install_id, "0.4.2", "0.4.1", "linux", "x64", "plugin", "KR", JSON.stringify(settings)]); expect(JSON.stringify(rows)).not.toContain("203.0.113.9"); - expect(JSON.stringify(rows)).not.toMatch(/Seoul|04524|37\.5|person@example\.com/); + expect(JSON.stringify(rows)).not.toMatch(/Seoul|04524|37\.5/); expect(readEvent({ ...event, settings: unordered }, now)?.settings).toEqual(settings); }); diff --git a/telemetry/worker.ts b/telemetry/worker.ts index 21f62ef1c9..59e33b5819 100644 --- a/telemetry/worker.ts +++ b/telemetry/worker.ts @@ -46,9 +46,13 @@ export function requestCountry(request: Request): string | null { } /** the event as it is stored, or null when the body is not one */ +/** the fields an event has ever carried; `changed_settings` is what senders before 0.4.9 told and is not kept */ +const EVENT_KEYS = new Set(["event", "install_id", "version", "previous_version", "os", "arch", "install_method", "settings", "changed_settings"]); + export function readEvent(body: unknown, now: Date): StoredEvent | null { if (typeof body !== "object" || body === null || Array.isArray(body)) return null; const value = body as Record; + if (Object.keys(value).some((key) => !EVENT_KEYS.has(key))) return null; const { event, install_id, version, previous_version, os, arch, install_method } = value; if (typeof event !== "string" || !EVENTS.has(event)) return null; if (typeof install_id !== "string" || !UUID.test(install_id)) return null;