From db50e5094da2764d45a9125070ff355f96ba20b8 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 13:11:26 +0800 Subject: [PATCH 01/16] feat: telemetry carries the backend user id and tenant when signed in Resolve the signed-in user once per credential (GET /users/me, cached at ~/.altimate/telemetry-identity.json) and stamp user_id and tenant on every event; the hashed account email is gone. Backend calls send x-altimate-client. Closes #1498 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/api/client.ts | 10 + .../opencode/src/altimate/plugin/altimate.ts | 5 + .../src/altimate/telemetry/identity.ts | 130 +++++++++++++ .../opencode/src/altimate/telemetry/index.ts | 59 +++--- .../src/altimate/workspace/api-client.ts | 1 + .../test/altimate/telemetry/identity.test.ts | 184 ++++++++++++++++++ 6 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 packages/opencode/src/altimate/telemetry/identity.ts create mode 100644 packages/opencode/test/altimate/telemetry/identity.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 492d9dd702..3b6875d5d5 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -3,6 +3,7 @@ import path from "path" import { rm } from "node:fs/promises" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" +import { InstallationVersion } from "@opencode-ai/core/installation/version" const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse" // altimate_change start — default Altimate API URL when user omits it from the TUI credential entry @@ -66,6 +67,12 @@ export namespace AltimateApi { return path.join(Global.Path.home, ".altimate", "altimate.json") } + /** Names this client on every backend call so the server can attribute create and link + * calls to altimate-code rather than to the web app or the extension. */ + export function clientHeaders(): Record { + return { "x-altimate-client": `altimate-code/${InstallationVersion}` } + } + export async function isConfigured(): Promise { return Filesystem.exists(credentialsPath()) } @@ -185,6 +192,7 @@ export namespace AltimateApi { "x-tenant": creds.altimateInstanceName, Authorization: `Bearer ${creds.altimateApiKey}`, "Content-Type": "application/json", + ...clientHeaders(), }, // altimate_change start — upstream_fix: attach abort signal signal: controller.signal, @@ -224,6 +232,7 @@ export namespace AltimateApi { headers: { "x-tenant": instance, "Content-Type": "application/json", + ...clientHeaders(), }, body: JSON.stringify({ token }), signal: controller.signal, @@ -252,6 +261,7 @@ export namespace AltimateApi { "Content-Type": "application/json", Authorization: `Bearer ${creds.altimateApiKey}`, "x-tenant": creds.altimateInstanceName, + ...clientHeaders(), }, ...(body ? { body: JSON.stringify(body) } : {}), }) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index e67ff13311..b9e0fbe4fc 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -5,6 +5,7 @@ import open from "open" import { AltimateApi } from "../api/client" // altimate_change — onboarding telemetry for the gateway sign-in funnel import * as OnboardingTelemetry from "../telemetry/onboarding" +import { Telemetry } from "../telemetry" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "../util/machine-id" import { Config } from "@/config/config" @@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { altimateInstanceName: creds.instance, altimateApiKey: authToken, }) + // altimate_change start — telemetry is already running in this process; stamp the + // user id and tenant of the account just signed in on the events that follow. + void Telemetry.refreshIdentity().catch(() => {}) + // altimate_change end // altimate_change start — onboarding funnel: auth succeeded and the instance // is live. The instance name is the customer's tenant identifier and is never // sent. time_to_connect_ms runs from the start of authorize() — before the diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts new file mode 100644 index 0000000000..ea9e27cac3 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -0,0 +1,130 @@ +import path from "path" +import { rm } from "node:fs/promises" +import { createHash } from "crypto" +import { Global } from "../../global" +import { Filesystem } from "../../util/filesystem" +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +const log = Log.create({ service: "telemetry-identity" }) + +/** + * Who this machine is signed in as, for telemetry. + * + * Web events in PostHog are keyed by the backend user uuid; CLI events were keyed by the + * machine only, so nothing joined a CLI session to the account that created or linked the + * workspace it uses. This resolves the backend user once per credential (``GET /users/me``, + * the same call the workspace client makes) and hands telemetry a ``user_id`` and ``tenant`` + * to stamp on every event. No email, hashed or not, leaves the client. + * + * The result is cached at ``~/.altimate/telemetry-identity.json`` under a fingerprint of the + * credential, so a process only hits the network when the credential changed. Every failure + * resolves to ``undefined``: telemetry must never block or break on identity. + */ +export namespace TelemetryIdentity { + export type Identity = { user_id: string; tenant: string } + + type Cached = Identity & { fingerprint: string } + + const REQUEST_TIMEOUT_MS = 10_000 + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + export function cachePath(): string { + return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") + } + + function fingerprint(c: { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string }): string { + return createHash("sha256") + .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) + .digest("hex") + .slice(0, 16) + } + + async function readCache(): Promise { + try { + const p = cachePath() + if (!(await Filesystem.exists(p))) return undefined + const raw = JSON.parse(await Filesystem.readText(p)) as Partial + if (typeof raw.fingerprint !== "string" || typeof raw.user_id !== "string" || typeof raw.tenant !== "string") + return undefined + if (!UUID.test(raw.user_id)) return undefined + return { fingerprint: raw.fingerprint, user_id: raw.user_id, tenant: raw.tenant } + } catch { + return undefined + } + } + + /** The cached identity, if it belongs to the credential currently on disk. No network. */ + export async function cached(): Promise { + try { + if (!(await AltimateApi.isConfigured())) return undefined + const creds = await AltimateApi.getCredentials() + const hit = await readCache() + if (!hit || hit.fingerprint !== fingerprint(creds)) return undefined + return { user_id: hit.user_id, tenant: hit.tenant } + } catch { + return undefined + } + } + + /** + * Resolve the signed-in user. Uses the cache when the credential has not changed; otherwise + * asks the backend and caches the answer. ``refresh`` forces the request (after a sign-in). + */ + export async function resolve(opts: { refresh?: boolean } = {}): Promise { + try { + if (!(await AltimateApi.isConfigured())) { + await clear() + return undefined + } + const creds = await AltimateApi.getCredentials() + const fp = fingerprint(creds) + if (!opts.refresh) { + const hit = await readCache() + if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } + } + const user = await fetchMe(creds) + if (!user) return undefined + const identity: Identity = { user_id: user, tenant: creds.altimateInstanceName } + try { + await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) + } catch (err) { + log.warn("telemetry identity cache not written", { err: String(err) }) + } + return identity + } catch (err) { + log.warn("telemetry identity unresolved", { err: String(err) }) + return undefined + } + } + + /** Forget the cached identity (sign-out). */ + export async function clear(): Promise { + try { + await rm(cachePath(), { force: true }) + } catch { + // nothing to forget + } + } + + async function fetchMe(c: { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string }) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const res = await fetch(`${c.altimateUrl}/users/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${c.altimateApiKey}`, + "x-tenant": c.altimateInstanceName, + ...AltimateApi.clientHeaders(), + }, + signal: controller.signal, + }) + if (!res.ok) return undefined + const body = (await res.json()) as { uuid?: unknown } + return typeof body?.uuid === "string" && UUID.test(body.uuid) ? body.uuid.toLowerCase() : undefined + } finally { + clearTimeout(timeout) + } + } +} diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index f350d9783c..8c1d35e704 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1,4 +1,3 @@ -import { Account } from "@/account" import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -6,6 +5,7 @@ import { Log } from "@/altimate/util/log" import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "@/altimate/util/machine-id" +import { TelemetryIdentity } from "./identity" import { createHash, randomUUID } from "crypto" // altimate_change — first-run health: the event-loop stall monitor labels which thread stalled import { isMainThread } from "node:worker_threads" @@ -1851,7 +1851,9 @@ export namespace Telemetry { let enabled = false let buffer: Event[] = [] let flushTimer: ReturnType | undefined - let userEmail = "" + // altimate_change — who the machine is signed in as (backend user uuid + tenant), for the + // funnel join with web events. Resolved off the stored credential in doInit(); see identity.ts. + let identity: TelemetryIdentity.Identity | undefined let machineId = "" let sessionId = "" let projectId = "" @@ -1938,6 +1940,9 @@ export namespace Telemetry { source: clientSource, project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), + // altimate_change — the backend user uuid and tenant when signed in to Altimate, so a CLI + // row joins the PostHog person and the backend record for the same account. + ...(identity && { user_id: identity.user_id, tenant: identity.tenant }), // altimate_change — groups every event from one process launch. The onboarding funnel // spans the TUI main thread and the server worker, and most of it runs before any chat // session exists, so `session_id` is empty for the first half and real for the second — @@ -1962,12 +1967,10 @@ export namespace Telemetry { iKey: cfg.iKey, tags: { "ai.session.id": sid || "startup", - // altimate_change start — use machine_id as fallback for anonymous user identification - // This IMPROVES privacy: previously all anonymous users shared ai.user.id="" - // which made them appear as one mega-user in analytics. Using the random UUID - // (already sent as a custom property) gives each machine a distinct identity - // without any PII. machine_id is a crypto.randomUUID() stored locally. - "ai.user.id": userEmail || machineId || "", + // altimate_change start — the backend user uuid when signed in, else the machine id. + // Anonymous machines each keep a distinct identity (the random machine UUID, already a + // custom property) instead of sharing ai.user.id="". No email, hashed or not, is sent. + "ai.user.id": identity?.user_id || machineId || "", // altimate_change end "ai.cloud.role": "altimate", "ai.application.ver": InstallationVersion, @@ -2073,16 +2076,16 @@ export namespace Telemetry { return } appInsights = cfg - try { - // altimate_change start — bridge merge: Account.active() became async in v1.4.0 - const account = await Account.active() - if (account?.email) { - userEmail = createHash("sha256").update(account.email.toLowerCase().trim()).digest("hex") - } - // altimate_change end - } catch { - // Account unavailable — proceed without user ID - } + // altimate_change start — identity for the funnel join: the backend user uuid and tenant from + // the stored Altimate credential, cached per credential so this costs no network unless the + // credential changed. Replaces the hashed account email. Not awaited: events flushed before it + // resolves carry only the machine id, and telemetry never waits on the backend. + void TelemetryIdentity.resolve() + .then((resolved) => { + identity = resolved + }) + .catch(() => {}) + // altimate_change end // altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts. // Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized: // logs + returns ""). No try/catch needed — all paths are handled inside. @@ -2113,6 +2116,19 @@ export namespace Telemetry { } } + // altimate_change start — re-resolve the signed-in user after a sign-in or sign-out in this + // process (the browser login flow saves the credential while telemetry is already running). + /** Re-resolve who the machine is signed in as; clears it when no credential is stored. */ + export async function refreshIdentity(): Promise { + identity = await TelemetryIdentity.resolve({ refresh: true }) + } + + /** Test seam. */ + export function identityForTest(): TelemetryIdentity.Identity | undefined { + return identity + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string }) { sessionId = opts.sessionId projectId = opts.projectId @@ -2466,10 +2482,9 @@ export namespace Telemetry { sessionId = "" projectId = "" machineId = "" - // doInit() only assigns userEmail when Account.active() returns one, so without this a - // logout followed by a re-init in the same process kept hashing the previous account into - // ai.user.id. - userEmail = "" + // Cleared so a sign-out followed by a re-init in the same process does not keep stamping the + // previous account on events; doInit() resolves it again from whatever credential is on disk. + identity = undefined // NOTE: cachedLaunchId is deliberately NOT cleared here. session/prompt.ts shuts telemetry // down at the end of every session, so clearing it would mint a fresh launch_id per prompt in // a long-lived `serve` process and shatter the per-launch correlation it exists to provide. diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 47c4a9a676..15df318ff3 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -233,6 +233,7 @@ async function req( "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, "x-tenant": instance, + ...AltimateApi.clientHeaders(), }, signal: controller.signal, ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts new file mode 100644 index 0000000000..6a2d6eb875 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, existsSync, writeFileSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" +import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" + +const UUID = "0f3c2a1e-6b7d-4c8e-9f10-1a2b3c4d5e6f" +const API = "https://api.example.invalid" + +type Call = { url: string; method: string; headers: Record; body: string } + +function headersOf(init?: RequestInit): Record { + const out: Record = {} + const h = init?.headers + if (!h) return out + if (h instanceof Headers) h.forEach((v, k) => (out[k.toLowerCase()] = v)) + else if (Array.isArray(h)) for (const [k, v] of h) out[k.toLowerCase()] = String(v) + else for (const [k, v] of Object.entries(h)) out[k.toLowerCase()] = String(v) + return out +} + +describe("telemetry identity", () => { + let home: string + let calls: Call[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedTestHome: string | undefined + let savedCs: string | undefined + let meStatus = 200 + + function writeCreds(apiKey = "key-1") { + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync( + path.join(home, ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API, altimateInstanceName: "acme", altimateApiKey: apiKey }), + ) + } + function meCalls() { + return calls.filter((c) => c.url === `${API}/users/me`) + } + function trackCalls() { + return calls.filter((c) => c.url.includes("/v2/track")) + } + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-identity-")) + savedTestHome = process.env.OPENCODE_TEST_HOME + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + process.env.OPENCODE_TEST_HOME = home + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + calls = [] + meStatus = 200 + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push({ url, method: init?.method ?? "GET", headers: headersOf(init), body: String(init?.body ?? "") }) + if (url === `${API}/users/me`) { + if (meStatus !== 200) return new Response("nope", { status: meStatus }) + return new Response(JSON.stringify({ id: 7, uuid: UUID.toUpperCase(), email: "someone@example.invalid" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + fetchSpy.mockRestore() + homedirSpy.mockRestore() + if (savedTestHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = savedTestHome + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + rmSync(home, { recursive: true, force: true }) + }) + + test("resolves the backend user once per credential and caches it with owner-only permissions", async () => { + writeCreds() + const first = await TelemetryIdentity.resolve() + expect(first).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(1) + const me = meCalls()[0] + expect(me.headers["x-tenant"]).toBe("acme") + expect(me.headers["authorization"]).toBe("Bearer key-1") + expect(me.headers["x-altimate-client"]).toMatch(/^altimate-code\//) + + const cachePath = TelemetryIdentity.cachePath() + expect(cachePath).toBe(path.join(home, ".altimate", "telemetry-identity.json")) + const cached = JSON.parse(readFileSync(cachePath, "utf8")) + expect(cached.user_id).toBe(UUID) + expect(cached.tenant).toBe("acme") + expect(typeof cached.fingerprint).toBe("string") + expect(cached.email).toBeUndefined() + if (process.platform !== "win32") expect(statSync(cachePath).mode & 0o777).toBe(0o600) + + // Same credential: served from the cache, no request. + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(1) + + // Forced refresh asks again. + await TelemetryIdentity.resolve({ refresh: true }) + expect(meCalls()).toHaveLength(2) + + // A different credential invalidates the cache. + writeCreds("key-2") + expect(await TelemetryIdentity.cached()).toBeUndefined() + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(3) + expect(meCalls()[2].headers["authorization"]).toBe("Bearer key-2") + }) + + test("resolves to nothing without a credential, on a failed request, or on a malformed answer", async () => { + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(meCalls()).toHaveLength(0) + + writeCreds() + meStatus = 401 + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + + meStatus = 200 + await TelemetryIdentity.resolve() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(true) + // Signing out (credential gone) forgets the cached identity. + rmSync(path.join(home, ".altimate", "altimate.json")) + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("events carry user_id and tenant when signed in, and only the machine id otherwise", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds() + await Telemetry.shutdown() + await Telemetry.init() + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID, tenant: "acme" }) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "test", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + expect(trackCalls().length).toBeGreaterThan(0) + const envelopes = JSON.parse(trackCalls().at(-1)!.body) as any[] + const env = envelopes.find((e) => e.data?.baseData?.name === "startup_ready") + expect(env).toBeDefined() + expect(env.data.baseData.properties.user_id).toBe(UUID) + expect(env.data.baseData.properties.tenant).toBe("acme") + expect(env.tags["ai.user.id"]).toBe(UUID) + const machineId = readFileSync(path.join(home, ".altimate", "machine-id"), "utf8").trim() + expect(env.data.baseData.properties.machine_id).toBe(machineId) + for (const value of Object.values(env.data.baseData.properties)) + expect(String(value)).not.toContain("example.invalid") + + // Sign out: the next events fall back to the machine id. + rmSync(path.join(home, ".altimate", "altimate.json")) + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toBeUndefined() + calls = [] + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "test", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + const after = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.name === "startup_ready", + ) + expect(after.data.baseData.properties.user_id).toBeUndefined() + expect(after.data.baseData.properties.tenant).toBeUndefined() + expect(after.tags["ai.user.id"]).toBe(machineId) + }) +}) From a169ffc78d5b77b4a87e906e2956937c25380f84 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 13:33:27 +0800 Subject: [PATCH 02/16] test: telemetry no longer calls Account.active() The two source guards expected the awaited call that the identity change removed; they now assert the call is gone and identity comes from the stored credential. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/test/upstream/altimate-features.test.ts | 9 ++++++--- packages/opencode/test/upstream/bridge-merge-v3.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/upstream/altimate-features.test.ts b/packages/opencode/test/upstream/altimate-features.test.ts index eb67869dc4..90cae54519 100644 --- a/packages/opencode/test/upstream/altimate-features.test.ts +++ b/packages/opencode/test/upstream/altimate-features.test.ts @@ -408,10 +408,13 @@ describe("altimate features: telemetry fail-safe init", () => { expect(src).toContain("ALTIMATE_TELEMETRY_DISABLED") }) - test("telemetry/index.ts awaits Account.active() (now async after bridge)", async () => { + test("telemetry/index.ts no longer reads the account; identity comes from the stored credential", async () => { const src = await readSrc("altimate", "telemetry", "index.ts") - // bridge merge note: Account.active() became async in v1.4.0 - expect(src).toMatch(/await Account\.active\(\)/) + // The hashed account email left with the user-identity change: events carry the backend + // user uuid and tenant resolved by TelemetryIdentity, so there is no Account.active() caller + // here any more (it used to be awaited; Account.active() became async in v1.4.0). + expect(src).not.toMatch(/Account\.active/) + expect(src).toMatch(/TelemetryIdentity\.resolve\(/) }) }) diff --git a/packages/opencode/test/upstream/bridge-merge-v3.test.ts b/packages/opencode/test/upstream/bridge-merge-v3.test.ts index 5cd201bc01..bb2e34ee9d 100644 --- a/packages/opencode/test/upstream/bridge-merge-v3.test.ts +++ b/packages/opencode/test/upstream/bridge-merge-v3.test.ts @@ -86,9 +86,11 @@ describe("bridge merge cycle 1: Account.active() is async — every caller await expect(content).toMatch(/accountSvc\.active\s*\(\s*\)/) }) - test("altimate/telemetry/index.ts awaits Account.active()", async () => { + test("altimate/telemetry/index.ts no longer calls Account.active()", async () => { + // Telemetry stopped reading the account when events gained user_id and tenant from the + // stored credential; the generic no-unawaited-call check below still covers any new caller. const content = await readText(path.join(srcDir, "altimate", "telemetry", "index.ts")) - expect(content).toMatch(/await\s+Account\.active\s*\(\s*\)/) + expect(content).not.toMatch(/Account\.active/) }) test("no source file calls Account.active() without await or assignment", async () => { From fc4f80cc797e59aba52805a30403a4f83df62eee Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 15:40:20 +0800 Subject: [PATCH 03/16] fix: a stale identity resolution never overwrites a newer sign-in or a shutdown Each resolution carries the generation it started in and applies only while it is the newest; shutdown() bumps the generation, so an in-flight /users/me answer for the previous credential is discarded. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 24 +++++++++---- .../test/altimate/telemetry/identity.test.ts | 35 ++++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 8c1d35e704..cefc7a3f63 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1854,6 +1854,11 @@ export namespace Telemetry { // altimate_change — who the machine is signed in as (backend user uuid + tenant), for the // funnel join with web events. Resolved off the stored credential in doInit(); see identity.ts. let identity: TelemetryIdentity.Identity | undefined + // altimate_change — resolutions are detached (doInit does not await the backend), so each one + // carries the generation it started in and applies only while it is still the newest: a + // resolution started under credential A must not land after a sign-in installed B, nor after + // shutdown() cleared the state. shutdown() and every new resolution bump it. + let identityGeneration = 0 let machineId = "" let sessionId = "" let projectId = "" @@ -2080,11 +2085,7 @@ export namespace Telemetry { // the stored Altimate credential, cached per credential so this costs no network unless the // credential changed. Replaces the hashed account email. Not awaited: events flushed before it // resolves carry only the machine id, and telemetry never waits on the backend. - void TelemetryIdentity.resolve() - .then((resolved) => { - identity = resolved - }) - .catch(() => {}) + void resolveIdentity() // altimate_change end // altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts. // Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized: @@ -2118,9 +2119,18 @@ export namespace Telemetry { // altimate_change start — re-resolve the signed-in user after a sign-in or sign-out in this // process (the browser login flow saves the credential while telemetry is already running). + function resolveIdentity(opts: { refresh?: boolean } = {}): Promise { + const generation = ++identityGeneration + return TelemetryIdentity.resolve(opts) + .then((resolved) => { + if (generation === identityGeneration) identity = resolved + }) + .catch(() => {}) + } + /** Re-resolve who the machine is signed in as; clears it when no credential is stored. */ export async function refreshIdentity(): Promise { - identity = await TelemetryIdentity.resolve({ refresh: true }) + await resolveIdentity({ refresh: true }) } /** Test seam. */ @@ -2484,6 +2494,8 @@ export namespace Telemetry { machineId = "" // Cleared so a sign-out followed by a re-init in the same process does not keep stamping the // previous account on events; doInit() resolves it again from whatever credential is on disk. + // The generation bump makes any resolution still in flight a no-op when it settles. + identityGeneration++ identity = undefined // NOTE: cachedLaunchId is deliberately NOT cleared here. session/prompt.ts shuts telemetry // down at the end of every session, so clearing it would mint a fresh launch_id per prompt in diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index 6a2d6eb875..db4b257e21 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -6,6 +6,7 @@ import { Telemetry } from "../../../src/altimate/telemetry" import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" const UUID = "0f3c2a1e-6b7d-4c8e-9f10-1a2b3c4d5e6f" +const UUID_B = "7a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d" const API = "https://api.example.invalid" type Call = { url: string; method: string; headers: Record; body: string } @@ -28,6 +29,8 @@ describe("telemetry identity", () => { let savedTestHome: string | undefined let savedCs: string | undefined let meStatus = 200 + /** Per-api-key gates: a /users/me request for that key waits on the promise before answering. */ + let gates: Record | undefined> = {} function writeCreds(apiKey = "key-1") { mkdirSync(path.join(home, ".altimate"), { recursive: true }) @@ -51,12 +54,17 @@ describe("telemetry identity", () => { homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) calls = [] meStatus = 200 + gates = {} fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url calls.push({ url, method: init?.method ?? "GET", headers: headersOf(init), body: String(init?.body ?? "") }) if (url === `${API}/users/me`) { + const key = headersOf(init)["authorization"]?.replace("Bearer ", "") ?? "" + const gate = gates[key] + if (gate) await gate if (meStatus !== 200) return new Response("nope", { status: meStatus }) - return new Response(JSON.stringify({ id: 7, uuid: UUID.toUpperCase(), email: "someone@example.invalid" }), { + const uuid = key === "key-B" ? UUID_B : UUID.toUpperCase() + return new Response(JSON.stringify({ id: 7, uuid, email: "someone@example.invalid" }), { status: 200, headers: { "content-type": "application/json" }, }) @@ -130,6 +138,31 @@ describe("telemetry identity", () => { expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) }) + test("a resolution that started under the old credential never overwrites a newer sign-in", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds("key-A") + let releaseA: () => void = () => {} + gates["key-A"] = new Promise((r) => (releaseA = r)) + await Telemetry.shutdown() + await Telemetry.init() // kicks off the resolution for key-A, which is now held + writeCreds("key-B") + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID_B, tenant: "acme" }) + releaseA() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID_B, tenant: "acme" }) + + // Likewise after shutdown: a resolution still in flight cannot repopulate the cleared state. + let releaseB: () => void = () => {} + gates["key-B"] = new Promise((r) => (releaseB = r)) + void Telemetry.refreshIdentity() + await Telemetry.shutdown() + releaseB() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toBeUndefined() + }) + test("events carry user_id and tenant when signed in, and only the machine id otherwise", async () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" From d275dec7c0424e4665b497a3faa3ac59bff089e7 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 15:51:41 +0800 Subject: [PATCH 04/16] fix: telemetry identity follows credential changes from any path Resolve the credential like the provider (file, else the auth-store key); notice sign-in, sign-out and key changes on disk at the next flush; attribute a batch to the identity it was tracked under; drop the cache on a 401 or 403; never cache an answer for a credential that changed mid-flight. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/telemetry/identity.ts | 88 +++++++++++--- .../opencode/src/altimate/telemetry/index.ts | 44 ++++++- packages/opencode/src/auth/index.ts | 7 ++ .../test/altimate/telemetry/identity.test.ts | 112 +++++++++++++++++- .../test/upstream/altimate-features.test.ts | 2 +- .../test/upstream/bridge-merge-v3.test.ts | 2 +- 6 files changed, 230 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts index ea9e27cac3..508e2fcd3c 100644 --- a/packages/opencode/src/altimate/telemetry/identity.ts +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -1,8 +1,10 @@ +import fs from "fs" import path from "path" import { rm } from "node:fs/promises" import { createHash } from "crypto" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" +import { Auth } from "../../auth" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -17,23 +19,63 @@ const log = Log.create({ service: "telemetry-identity" }) * the same call the workspace client makes) and hands telemetry a ``user_id`` and ``tenant`` * to stamp on every event. No email, hashed or not, leaves the client. * - * The result is cached at ``~/.altimate/telemetry-identity.json`` under a fingerprint of the - * credential, so a process only hits the network when the credential changed. Every failure - * resolves to ``undefined``: telemetry must never block or break on identity. + * The credential is the one the Altimate provider uses: ``~/.altimate/altimate.json`` first, + * else the ``altimate-backend`` entry of the auth store (a key entered in the TUI). The result + * is cached at ``~/.altimate/telemetry-identity.json`` under a fingerprint of the credential, so + * a process only hits the network when the credential changed. Every failure resolves to + * ``undefined``: telemetry must never block or break on identity. */ export namespace TelemetryIdentity { export type Identity = { user_id: string; tenant: string } + type Credential = { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string } type Cached = Identity & { fingerprint: string } const REQUEST_TIMEOUT_MS = 10_000 const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const AUTH_STORE_KEY = "altimate-backend" export function cachePath(): string { return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") } - function fingerprint(c: { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string }): string { + /** The credential the Altimate provider would use, or undefined when signed out. */ + export async function loadCredential(): Promise { + try { + if (await AltimateApi.isConfigured()) { + const c = await AltimateApi.getCredentials() + return { + altimateUrl: c.altimateUrl, + altimateInstanceName: c.altimateInstanceName, + altimateApiKey: c.altimateApiKey, + } + } + const auth = await Auth.get(AUTH_STORE_KEY) + if (auth?.type === "api") return AltimateApi.parseAltimateKey(auth.key) ?? undefined + return undefined + } catch { + return undefined + } + } + + /** + * A cheap stamp of the two credential files (mtime and size), so a flush can notice that a + * sign-in, sign-out or key change happened on disk, in this thread or another, without + * reading or parsing anything. + */ + export function credentialStamp(): string { + const stamp = (p: string) => { + try { + const s = fs.statSync(p) + return `${s.mtimeMs}:${s.size}` + } catch { + return "absent" + } + } + return `${stamp(AltimateApi.credentialsPath())}|${stamp(Auth.filepath())}` + } + + function fingerprint(c: Credential): string { return createHash("sha256") .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) .digest("hex") @@ -54,11 +96,11 @@ export namespace TelemetryIdentity { } } - /** The cached identity, if it belongs to the credential currently on disk. No network. */ + /** The cached identity, if it belongs to the credential currently in use. No network. */ export async function cached(): Promise { try { - if (!(await AltimateApi.isConfigured())) return undefined - const creds = await AltimateApi.getCredentials() + const creds = await loadCredential() + if (!creds) return undefined const hit = await readCache() if (!hit || hit.fingerprint !== fingerprint(creds)) return undefined return { user_id: hit.user_id, tenant: hit.tenant } @@ -70,22 +112,31 @@ export namespace TelemetryIdentity { /** * Resolve the signed-in user. Uses the cache when the credential has not changed; otherwise * asks the backend and caches the answer. ``refresh`` forces the request (after a sign-in). + * A backend rejection (401 or 403) drops the cache for that credential; a transient failure + * keeps it. The answer is written and returned only if the same credential is still in use. */ export async function resolve(opts: { refresh?: boolean } = {}): Promise { try { - if (!(await AltimateApi.isConfigured())) { + const creds = await loadCredential() + if (!creds) { await clear() return undefined } - const creds = await AltimateApi.getCredentials() const fp = fingerprint(creds) if (!opts.refresh) { const hit = await readCache() if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } } - const user = await fetchMe(creds) - if (!user) return undefined - const identity: Identity = { user_id: user, tenant: creds.altimateInstanceName } + const answer = await fetchMe(creds) + if (answer.kind === "rejected") { + const hit = await readCache() + if (hit && hit.fingerprint === fp) await clear() + return undefined + } + if (answer.kind !== "ok") return undefined + const now = await loadCredential() + if (!now || fingerprint(now) !== fp) return undefined + const identity: Identity = { user_id: answer.uuid, tenant: creds.altimateInstanceName } try { await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) } catch (err) { @@ -107,7 +158,9 @@ export namespace TelemetryIdentity { } } - async function fetchMe(c: { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string }) { + type Answer = { kind: "ok"; uuid: string } | { kind: "rejected" } | { kind: "error" } + + async function fetchMe(c: Credential): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) try { @@ -120,9 +173,14 @@ export namespace TelemetryIdentity { }, signal: controller.signal, }) - if (!res.ok) return undefined + if (res.status === 401 || res.status === 403) return { kind: "rejected" } + if (!res.ok) return { kind: "error" } const body = (await res.json()) as { uuid?: unknown } - return typeof body?.uuid === "string" && UUID.test(body.uuid) ? body.uuid.toLowerCase() : undefined + return typeof body?.uuid === "string" && UUID.test(body.uuid) + ? { kind: "ok", uuid: body.uuid.toLowerCase() } + : { kind: "error" } + } catch { + return { kind: "error" } } finally { clearTimeout(timeout) } diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index cefc7a3f63..c6cba27547 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1859,6 +1859,11 @@ export namespace Telemetry { // resolution started under credential A must not land after a sign-in installed B, nor after // shutdown() cleared the state. shutdown() and every new resolution bump it. let identityGeneration = 0 + // altimate_change — stamp of the credential files when the current identity was resolved; a + // flush that sees a different stamp re-resolves, so a sign-in, sign-out or key change made on + // disk by any path (manual file edit, the TUI, another thread's auth flow) is picked up within + // one flush interval instead of at the next process start. + let identityStamp = "" let machineId = "" let sessionId = "" let projectId = "" @@ -1927,7 +1932,16 @@ export namespace Telemetry { return { iKey, endpoint: `${base}v2/track` } } - function toAppInsightsEnvelopes(events: Event[], cfg: AppInsightsConfig): object[] { + // altimate_change — `ids` lets a caller build envelopes for a machine and identity other than + // the module's current state (the opt-out marker runs while telemetry is disabled and must not + // touch the state an enabled init may be using concurrently). + function toAppInsightsEnvelopes( + events: Event[], + cfg: AppInsightsConfig, + ids?: { machineId: string; identity: TelemetryIdentity.Identity | undefined }, + ): object[] { + const mid = ids ? ids.machineId : machineId + const who = ids ? ids.identity : identity // Process-level client, read live from the env flag (defaults to "cli") so events // emitted before any prompt runs — startup, connection setup, standalone CLI // subcommands — are labelled correctly instead of stuck at a hardcoded default. @@ -1944,10 +1958,10 @@ export namespace Telemetry { cli_version: InstallationVersion, source: clientSource, project_id: fields.project_id ?? projectId, - ...(machineId && { machine_id: machineId }), + ...(mid && { machine_id: mid }), // altimate_change — the backend user uuid and tenant when signed in to Altimate, so a CLI // row joins the PostHog person and the backend record for the same account. - ...(identity && { user_id: identity.user_id, tenant: identity.tenant }), + ...(who && { user_id: who.user_id, tenant: who.tenant }), // altimate_change — groups every event from one process launch. The onboarding funnel // spans the TUI main thread and the server worker, and most of it runs before any chat // session exists, so `session_id` is empty for the first half and real for the second — @@ -1975,7 +1989,7 @@ export namespace Telemetry { // altimate_change start — the backend user uuid when signed in, else the machine id. // Anonymous machines each keep a distinct identity (the random machine UUID, already a // custom property) instead of sharing ai.user.id="". No email, hashed or not, is sent. - "ai.user.id": identity?.user_id || machineId || "", + "ai.user.id": who?.user_id || mid || "", // altimate_change end "ai.cloud.role": "altimate", "ai.application.ver": InstallationVersion, @@ -2121,6 +2135,7 @@ export namespace Telemetry { // process (the browser login flow saves the credential while telemetry is already running). function resolveIdentity(opts: { refresh?: boolean } = {}): Promise { const generation = ++identityGeneration + identityStamp = TelemetryIdentity.credentialStamp() return TelemetryIdentity.resolve(opts) .then((resolved) => { if (generation === identityGeneration) identity = resolved @@ -2128,8 +2143,12 @@ export namespace Telemetry { .catch(() => {}) } - /** Re-resolve who the machine is signed in as; clears it when no credential is stored. */ + /** Re-resolve who the machine is signed in as; clears it when no credential is stored. Events + * already buffered are flushed under the identity they were tracked with first, and events + * tracked while the new answer is pending carry no account rather than the previous one. */ export async function refreshIdentity(): Promise { + await flush() + identity = undefined await resolveIdentity({ refresh: true }) } @@ -2330,6 +2349,19 @@ export namespace Telemetry { } async function doFlush(timeoutMs: number) { + // altimate_change start — this batch is attributed to the identity in force when it is sent, + // captured before the credential check below can start replacing it; then notice a credential + // change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed). + const who = identity + const mid = machineId + if (enabled) { + const stamp = TelemetryIdentity.credentialStamp() + if (stamp !== identityStamp) { + identity = undefined + void resolveIdentity() + } + } + // altimate_change end if (!enabled || buffer.length === 0 || !appInsights) return const events = buffer.splice(0, buffer.length) @@ -2353,7 +2385,7 @@ export namespace Telemetry { const response = await fetch(appInsights.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(toAppInsightsEnvelopes(events, appInsights)), + body: JSON.stringify(toAppInsightsEnvelopes(events, appInsights, { machineId: mid, identity: who })), signal: controller.signal, }) if (!response.ok) { diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a320d6da95..c06d17254d 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -12,6 +12,13 @@ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" const file = path.join(Global.Path.data, "auth.json") +// altimate_change start — telemetry identity stats this file to notice a sign-in or sign-out made +// through the TUI without parsing the store on every flush. +export function filepath(): string { + return file +} +// altimate_change end + const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) export class Oauth extends Schema.Class("OAuth")({ diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index db4b257e21..6317713774 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -3,11 +3,13 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, existsSync, wri import os from "os" import path from "path" import { Telemetry } from "../../../src/altimate/telemetry" +import { Auth } from "../../../src/auth" import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" const UUID = "0f3c2a1e-6b7d-4c8e-9f10-1a2b3c4d5e6f" const UUID_B = "7a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d" const API = "https://api.example.invalid" +const DEFAULT_API = "https://api.myaltimate.com" type Call = { url: string; method: string; headers: Record; body: string } @@ -29,6 +31,9 @@ describe("telemetry identity", () => { let savedTestHome: string | undefined let savedCs: string | undefined let meStatus = 200 + /** When set, /users/me answers 200 with this raw body instead of a user. */ + let meBody: string | undefined + let authSpy: ReturnType /** Per-api-key gates: a /users/me request for that key waits on the promise before answering. */ let gates: Record | undefined> = {} @@ -54,16 +59,21 @@ describe("telemetry identity", () => { homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) calls = [] meStatus = 200 + meBody = undefined gates = {} + // The auth store lives in the real data directory; never let a test read it. + authSpy = spyOn(Auth, "get").mockResolvedValue(undefined) fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url calls.push({ url, method: init?.method ?? "GET", headers: headersOf(init), body: String(init?.body ?? "") }) - if (url === `${API}/users/me`) { + if (url === `${API}/users/me` || url === `${DEFAULT_API}/users/me`) { const key = headersOf(init)["authorization"]?.replace("Bearer ", "") ?? "" const gate = gates[key] if (gate) await gate if (meStatus !== 200) return new Response("nope", { status: meStatus }) - const uuid = key === "key-B" ? UUID_B : UUID.toUpperCase() + if (meBody !== undefined) + return new Response(meBody, { status: 200, headers: { "content-type": "application/json" } }) + const uuid = key.startsWith("key-B") ? UUID_B : UUID.toUpperCase() return new Response(JSON.stringify({ id: 7, uuid, email: "someone@example.invalid" }), { status: 200, headers: { "content-type": "application/json" }, @@ -77,6 +87,7 @@ describe("telemetry identity", () => { await Telemetry.shutdown() fetchSpy.mockRestore() homedirSpy.mockRestore() + authSpy.mockRestore() if (savedTestHome === undefined) delete process.env.OPENCODE_TEST_HOME else process.env.OPENCODE_TEST_HOME = savedTestHome if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING @@ -130,14 +141,111 @@ describe("telemetry identity", () => { expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) meStatus = 200 + meBody = JSON.stringify({ id: 7, uuid: "not-a-uuid" }) + expect(await TelemetryIdentity.resolve()).toBeUndefined() + meBody = "proxy error" + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + + meBody = undefined await TelemetryIdentity.resolve() expect(existsSync(TelemetryIdentity.cachePath())).toBe(true) + + // A rejection drops the cache for that credential; a transient failure keeps it. + meStatus = 503 + expect(await TelemetryIdentity.resolve({ refresh: true })).toBeUndefined() + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + meStatus = 401 + expect(await TelemetryIdentity.resolve({ refresh: true })).toBeUndefined() + expect(await TelemetryIdentity.cached()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + meStatus = 200 // Signing out (credential gone) forgets the cached identity. rmSync(path.join(home, ".altimate", "altimate.json")) expect(await TelemetryIdentity.resolve()).toBeUndefined() expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) }) + test("an answer for a credential that was removed mid-flight is neither cached nor returned", async () => { + writeCreds("key-A") + let releaseA: () => void = () => {} + gates["key-A"] = new Promise((r) => (releaseA = r)) + const pending = TelemetryIdentity.resolve() + await new Promise((r) => setTimeout(r, 20)) + rmSync(path.join(home, ".altimate", "altimate.json")) + releaseA() + expect(await pending).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("a key entered in the TUI (auth store) resolves like the credential file", async () => { + authSpy.mockResolvedValue({ type: "api", key: "acme::key-S" }) + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls().length + calls.filter((c) => c.url === `${DEFAULT_API}/users/me`).length).toBe(1) + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + // The credential file wins over the store when both exist. + writeCreds("key-F") + await TelemetryIdentity.resolve() + const last = calls.filter((c) => c.url.endsWith("/users/me")).at(-1)! + expect(last.headers["authorization"]).toBe("Bearer key-F") + }) + + test("a credential change on disk is noticed at the next flush, and a buffered batch keeps the identity it was tracked under", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds("key-A") + await Telemetry.shutdown() + await Telemetry.init() + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID, tenant: "acme" }) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "a", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await new Promise((r) => setTimeout(r, 15)) // a distinct mtime for the rewritten file + writeCreds("key-B-longer-so-the-size-differs-too") + await Telemetry.flush() + const first = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.name === "startup_ready", + ) + expect(first.data.baseData.properties.user_id).toBe(UUID) // the batch tracked under A stays A + const ok = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID_B) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(ok).toBe(true) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "b", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + const second = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.properties?.command === "b", + ) + expect(second.data.baseData.properties.user_id).toBe(UUID_B) + + // Sign-out on disk: noticed the same way, and the account is gone from the next batch. + await new Promise((r) => setTimeout(r, 15)) + rmSync(path.join(home, ".altimate", "altimate.json")) + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toBeUndefined() + }) + test("a resolution that started under the old credential never overwrites a newer sign-in", async () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" diff --git a/packages/opencode/test/upstream/altimate-features.test.ts b/packages/opencode/test/upstream/altimate-features.test.ts index 90cae54519..6f5fad55fc 100644 --- a/packages/opencode/test/upstream/altimate-features.test.ts +++ b/packages/opencode/test/upstream/altimate-features.test.ts @@ -413,7 +413,7 @@ describe("altimate features: telemetry fail-safe init", () => { // The hashed account email left with the user-identity change: events carry the backend // user uuid and tenant resolved by TelemetryIdentity, so there is no Account.active() caller // here any more (it used to be awaited; Account.active() became async in v1.4.0). - expect(src).not.toMatch(/Account\.active/) + expect(src).not.toMatch(/Account\.active\s*\(/) expect(src).toMatch(/TelemetryIdentity\.resolve\(/) }) }) diff --git a/packages/opencode/test/upstream/bridge-merge-v3.test.ts b/packages/opencode/test/upstream/bridge-merge-v3.test.ts index bb2e34ee9d..1b3bd32568 100644 --- a/packages/opencode/test/upstream/bridge-merge-v3.test.ts +++ b/packages/opencode/test/upstream/bridge-merge-v3.test.ts @@ -90,7 +90,7 @@ describe("bridge merge cycle 1: Account.active() is async — every caller await // Telemetry stopped reading the account when events gained user_id and tenant from the // stored credential; the generic no-unawaited-call check below still covers any new caller. const content = await readText(path.join(srcDir, "altimate", "telemetry", "index.ts")) - expect(content).not.toMatch(/Account\.active/) + expect(content).not.toMatch(/Account\.active\s*\(/) }) test("no source file calls Account.active() without await or assignment", async () => { From 6be240e94fa01e111e310c0bca7b776829bfa4b3 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 16:05:18 +0800 Subject: [PATCH 05/16] fix: retry a failed identity lookup later, normalise auth-store URLs A resolution that fails while a credential exists resets the stamp with a 60 s backoff so a later flush tries again; an auth-store key keeps no trailing slash; the mid-flight test waits for the request to reach its gate. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/telemetry/identity.ts | 11 ++++- .../opencode/src/altimate/telemetry/index.ts | 26 ++++++++++-- .../test/altimate/telemetry/identity.test.ts | 42 +++++++++++++++++-- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts index 508e2fcd3c..bca2437b4e 100644 --- a/packages/opencode/src/altimate/telemetry/identity.ts +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -39,6 +39,11 @@ export namespace TelemetryIdentity { return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") } + /** True when a credential is in use (file or auth store), whatever its validity. */ + export async function hasCredential(): Promise { + return (await loadCredential()) !== undefined + } + /** The credential the Altimate provider would use, or undefined when signed out. */ export async function loadCredential(): Promise { try { @@ -51,7 +56,11 @@ export namespace TelemetryIdentity { } } const auth = await Auth.get(AUTH_STORE_KEY) - if (auth?.type === "api") return AltimateApi.parseAltimateKey(auth.key) ?? undefined + if (auth?.type === "api") { + const parsed = AltimateApi.parseAltimateKey(auth.key) + // Same normalisation as getCredentials(): a trailing slash would yield "//users/me". + return parsed ? { ...parsed, altimateUrl: parsed.altimateUrl.replace(/\/+$/, "") } : undefined + } return undefined } catch { return undefined diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index c6cba27547..9ecb531038 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1864,6 +1864,11 @@ export namespace Telemetry { // disk by any path (manual file edit, the TUI, another thread's auth flow) is picked up within // one flush interval instead of at the next process start. let identityStamp = "" + // altimate_change — a resolution that failed while a credential exists (backend down, timeout) + // must be tried again, but not on every 5 s flush: the stamp is reset so the next flush after + // this time re-resolves. + const IDENTITY_RETRY_MS = 60_000 + let identityRetryAt = 0 let machineId = "" let sessionId = "" let projectId = "" @@ -2137,8 +2142,16 @@ export namespace Telemetry { const generation = ++identityGeneration identityStamp = TelemetryIdentity.credentialStamp() return TelemetryIdentity.resolve(opts) - .then((resolved) => { - if (generation === identityGeneration) identity = resolved + .then(async (resolved) => { + if (generation !== identityGeneration) return + identity = resolved + if (resolved === undefined && (await TelemetryIdentity.hasCredential())) { + // Signed in but unresolved: a transient failure. Retry at a later flush. + if (generation === identityGeneration) { + identityStamp = "" + identityRetryAt = Date.now() + IDENTITY_RETRY_MS + } + } }) .catch(() => {}) } @@ -2156,6 +2169,11 @@ export namespace Telemetry { export function identityForTest(): TelemetryIdentity.Identity | undefined { return identity } + + /** Test seam: end the retry backoff so the next flush re-resolves. */ + export function retryIdentityNowForTest() { + identityRetryAt = 0 + } // altimate_change end export function setContext(opts: { sessionId: string; projectId: string }) { @@ -2354,7 +2372,7 @@ export namespace Telemetry { // change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed). const who = identity const mid = machineId - if (enabled) { + if (enabled && Date.now() >= identityRetryAt) { const stamp = TelemetryIdentity.credentialStamp() if (stamp !== identityStamp) { identity = undefined @@ -2529,6 +2547,8 @@ export namespace Telemetry { // The generation bump makes any resolution still in flight a no-op when it settles. identityGeneration++ identity = undefined + identityStamp = "" + identityRetryAt = 0 // NOTE: cachedLaunchId is deliberately NOT cleared here. session/prompt.ts shuts telemetry // down at the end of every session, so clearing it would mint a fresh launch_id per prompt in // a long-lived `serve` process and shatter the per-launch correlation it exists to provide. diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index 6317713774..02d469f27b 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -36,6 +36,8 @@ describe("telemetry identity", () => { let authSpy: ReturnType /** Per-api-key gates: a /users/me request for that key waits on the promise before answering. */ let gates: Record | undefined> = {} + /** Resolved when a /users/me request for that key has reached its gate. */ + let entered: Record void> = {} function writeCreds(apiKey = "key-1") { mkdirSync(path.join(home, ".altimate"), { recursive: true }) @@ -61,6 +63,7 @@ describe("telemetry identity", () => { meStatus = 200 meBody = undefined gates = {} + entered = {} // The auth store lives in the real data directory; never let a test read it. authSpy = spyOn(Auth, "get").mockResolvedValue(undefined) fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { @@ -69,6 +72,7 @@ describe("telemetry identity", () => { if (url === `${API}/users/me` || url === `${DEFAULT_API}/users/me`) { const key = headersOf(init)["authorization"]?.replace("Bearer ", "") ?? "" const gate = gates[key] + entered[key]?.() if (gate) await gate if (meStatus !== 200) return new Response("nope", { status: meStatus }) if (meBody !== undefined) @@ -170,8 +174,9 @@ describe("telemetry identity", () => { writeCreds("key-A") let releaseA: () => void = () => {} gates["key-A"] = new Promise((r) => (releaseA = r)) + const reached = new Promise((r) => (entered["key-A"] = r)) const pending = TelemetryIdentity.resolve() - await new Promise((r) => setTimeout(r, 20)) + await reached // the request is in flight at the gate rmSync(path.join(home, ".altimate", "altimate.json")) releaseA() expect(await pending).toBeUndefined() @@ -179,9 +184,10 @@ describe("telemetry identity", () => { }) test("a key entered in the TUI (auth store) resolves like the credential file", async () => { - authSpy.mockResolvedValue({ type: "api", key: "acme::key-S" }) + authSpy.mockResolvedValue({ type: "api", key: `${API}///::acme::key-S` }) expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) - expect(meCalls().length + calls.filter((c) => c.url === `${DEFAULT_API}/users/me`).length).toBe(1) + expect(calls.filter((c) => c.url.includes("/users/me")).at(-1)!.url).toBe(`${API}/users/me`) + expect(meCalls()).toHaveLength(1) expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) // The credential file wins over the store when both exist. writeCreds("key-F") @@ -246,6 +252,36 @@ describe("telemetry identity", () => { expect(Telemetry.identityForTest()).toBeUndefined() }) + test("a transient failure at startup is retried at a later flush, not on every flush", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds() + meStatus = 503 + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 60)) + expect(Telemetry.identityForTest()).toBeUndefined() + const after = meCalls().length + expect(after).toBeGreaterThanOrEqual(1) + await Telemetry.flush() + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 30)) + expect(meCalls().length).toBe(after) // backoff: no retry within the window + + meStatus = 200 + Telemetry.retryIdentityNowForTest() + await Telemetry.flush() + const ok = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(ok).toBe(true) + }) + test("a resolution that started under the old credential never overwrites a newer sign-in", async () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" From cbc3a6d331268bf80d0578b0fb50055e23419429 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 16:10:22 +0800 Subject: [PATCH 06/16] fix: a credential change during the retry backoff is noticed at once The backoff throttles retries of the unchanged failed credential only; the stamp comparison keeps running, so a different credential appearing in the window resolves at the next flush. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 22 +++++++++---------- .../test/altimate/telemetry/identity.test.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 9ecb531038..d4116b5eca 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1865,10 +1865,11 @@ export namespace Telemetry { // one flush interval instead of at the next process start. let identityStamp = "" // altimate_change — a resolution that failed while a credential exists (backend down, timeout) - // must be tried again, but not on every 5 s flush: the stamp is reset so the next flush after - // this time re-resolves. + // is tried again at the first flush after this time, not on every 5 s flush. The stamp of the + // failed credential is kept, so a different credential appearing in the meantime is noticed at + // once by the stamp comparison; the backoff only throttles retries of the unchanged one. const IDENTITY_RETRY_MS = 60_000 - let identityRetryAt = 0 + let identityRetryAt = 0 // 0: no retry pending let machineId = "" let sessionId = "" let projectId = "" @@ -2141,16 +2142,14 @@ export namespace Telemetry { function resolveIdentity(opts: { refresh?: boolean } = {}): Promise { const generation = ++identityGeneration identityStamp = TelemetryIdentity.credentialStamp() + identityRetryAt = 0 return TelemetryIdentity.resolve(opts) .then(async (resolved) => { if (generation !== identityGeneration) return identity = resolved if (resolved === undefined && (await TelemetryIdentity.hasCredential())) { // Signed in but unresolved: a transient failure. Retry at a later flush. - if (generation === identityGeneration) { - identityStamp = "" - identityRetryAt = Date.now() + IDENTITY_RETRY_MS - } + if (generation === identityGeneration) identityRetryAt = Date.now() + IDENTITY_RETRY_MS } }) .catch(() => {}) @@ -2170,9 +2169,9 @@ export namespace Telemetry { return identity } - /** Test seam: end the retry backoff so the next flush re-resolves. */ + /** Test seam: end a pending retry backoff so the next flush re-resolves. */ export function retryIdentityNowForTest() { - identityRetryAt = 0 + if (identityRetryAt !== 0) identityRetryAt = 1 } // altimate_change end @@ -2372,9 +2371,10 @@ export namespace Telemetry { // change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed). const who = identity const mid = machineId - if (enabled && Date.now() >= identityRetryAt) { + if (enabled) { const stamp = TelemetryIdentity.credentialStamp() - if (stamp !== identityStamp) { + const retryDue = identityRetryAt !== 0 && Date.now() >= identityRetryAt + if (stamp !== identityStamp || retryDue) { identity = undefined void resolveIdentity() } diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index 02d469f27b..5d0747155c 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -268,6 +268,28 @@ describe("telemetry identity", () => { await new Promise((r) => setTimeout(r, 30)) expect(meCalls().length).toBe(after) // backoff: no retry within the window + // A different credential appearing during the backoff is picked up at once. + meStatus = 200 + await new Promise((r) => setTimeout(r, 15)) + writeCreds("key-B-longer-so-the-size-differs-too") + await Telemetry.flush() + const gotB = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID_B) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(gotB).toBe(true) + + // And the unchanged credential is retried once the backoff has elapsed. + await new Promise((r) => setTimeout(r, 15)) + writeCreds("key-A") + meStatus = 503 + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 60)) + expect(Telemetry.identityForTest()).toBeUndefined() meStatus = 200 Telemetry.retryIdentityNowForTest() await Telemetry.flush() From 46b678d0d993bc7699426beca590f9b415f4aa38 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 13:20:30 +0800 Subject: [PATCH 07/16] feat: send one final telemetry_opted_out event when telemetry is disabled A machine that has sent telemetry before sends the opt-out once (machine id, version, how it was set, cached user id and tenant) and nothing after; a marker next to the machine id keeps it to once and is cleared when telemetry is re-enabled. Closes #1499 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 75 ++++++++ .../src/altimate/telemetry/opt-out.ts | 52 ++++++ .../test/altimate/telemetry/opt-out.test.ts | 171 ++++++++++++++++++ .../test/telemetry/automated-run.test.ts | 4 +- 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/altimate/telemetry/opt-out.ts create mode 100644 packages/opencode/test/altimate/telemetry/opt-out.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index d4116b5eca..2bb8b0a4b1 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -6,6 +6,7 @@ import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "@/altimate/util/machine-id" import { TelemetryIdentity } from "./identity" +import { OptOutMarker } from "./opt-out" import { createHash, randomUUID } from "crypto" // altimate_change — first-run health: the event-loop stall monitor labels which thread stalled import { isMainThread } from "node:worker_threads" @@ -65,9 +66,17 @@ const log = Log.create({ service: "telemetry" }) */ function isAutomatedRun(): boolean { if (process.env.ALTIMATE_TELEMETRY_FORCE === "true") return false + return underTestRunner() +} + +// altimate_change start — the raw test-runner check, without the force flag. The opt-out marker +// reads and writes files in the real home directory, so under a test runner it stays inert unless +// a suite opts in through Telemetry.allowOptOutUnderTestRunner(); the force flag must not reach it. +function underTestRunner(): boolean { if (process.env.NODE_ENV === "test") return true return Boolean(process.env.BUN_TEST || process.env.VITEST || process.env.JEST_WORKER_ID) } +// altimate_change end // altimate_change start — composed path-masking rules (shared fragments) // R: one path-run character — anything but whitespace/separators/quotes, @@ -668,6 +677,14 @@ export namespace Telemetry { // The 0.11.0 first-run freeze (an in-process @npmcli/arborist install blocking the loop for minutes) // was invisible for two months: nothing timed startup or registration, and a blocked loop cannot // flush, so a killed process left only `session_start`. These events close that gap. + | { + /** The last event an opted-out machine sends; nothing follows it until telemetry is + * turned back on. Built directly in recordOptOut(), never through track(). */ + type: "telemetry_opted_out" + timestamp: number + /** Where the opt-out was set: an environment variable or the user config. */ + opt_out_source: "env" | "config" + } | { type: "startup_ready" timestamp: number @@ -2068,6 +2085,7 @@ export namespace Telemetry { // (previously only wired in test fixtures, silent no-op in product). if (Flag.truthyEnv("ALTIMATE_TELEMETRY_DISABLED") || Flag.truthyEnv("OPENCODE_DISABLE_TELEMETRY")) { buffer = [] + void recordOptOut("env") return } // Config.get() may throw outside Instance context (e.g. CLI middleware @@ -2077,6 +2095,7 @@ export namespace Telemetry { const userConfig = (await Config.get()) as any if (userConfig.telemetry?.disabled) { buffer = [] + void recordOptOut("config") return } } catch { @@ -2112,6 +2131,9 @@ export namespace Telemetry { // logs + returns ""). No try/catch needed — all paths are handled inside. machineId = getOrCreateMachineId() enabled = true + // altimate_change — telemetry is on: forget the opt-out marker, so turning it off again is + // recorded again. + OptOutMarker.clear() log.info("telemetry initialized", { mode: "appinsights" }) // altimate_change — clear any existing interval before installing a new one. doInit() can // run more than once per process (init/shutdown cycles per session in prompt.ts), and @@ -2175,6 +2197,59 @@ export namespace Telemetry { } // altimate_change end + // altimate_change start — the final event of an opted-out machine. Sent once, only from a + // machine that has sent telemetry before (a machine id exists; none is minted here), under the + // same sink rules as doInit(), with the identity cached by an earlier signed-in run (no request + // to resolve it). The marker is written only once the ingest endpoint accepted the event, so an + // offline launch tries again next time; after the marker, nothing is sent until telemetry is + // re-enabled. Not awaited: an opted-out launch must not wait on the network. + const OPT_OUT_TIMEOUT_MS = 3_000 + let optOutUnderTestRunner = false + /** Test seam: let a suite that redirects the home directory exercise recordOptOut(). */ + export function allowOptOutUnderTestRunner(on: boolean) { + optOutUnderTestRunner = on + } + async function recordOptOut(source: "env" | "config"): Promise { + try { + if (underTestRunner() && !optOutUnderTestRunner) return + // The force flag is a developer knob for running telemetry under a test runner; the opt-out + // marker is about real users, and "disabled" must keep winning over "force" to the letter. + if (process.env.ALTIMATE_TELEMETRY_FORCE === "true") return + if (OptOutMarker.exists()) return + const id = OptOutMarker.readMachineId() + if (!id) return + const explicit = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + const cfg = parseConnectionString(explicit ?? DEFAULT_CONNECTION_STRING) + if (!cfg) return + machineId = id + identity = await TelemetryIdentity.cached() + const envelopes = toAppInsightsEnvelopes( + [{ type: "telemetry_opted_out", timestamp: Date.now(), opt_out_source: source }], + cfg, + ) + machineId = "" + identity = undefined + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) + try { + const res = await fetch(cfg.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(envelopes), + signal: controller.signal, + }) + if (res.ok) OptOutMarker.write() + } finally { + clearTimeout(timeout) + } + } catch { + // The opt-out path never surfaces: a missed marker only means one more attempt next launch. + machineId = "" + identity = undefined + } + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string }) { sessionId = opts.sessionId projectId = opts.projectId diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts new file mode 100644 index 0000000000..4cca1eb5fa --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -0,0 +1,52 @@ +import fs from "fs" +import path from "path" +import { Global } from "../../global" + +/** + * The marker that says this machine already sent its final ``telemetry_opted_out`` event. + * + * A machine that turns telemetry off goes silent, which on the dashboards looks exactly like a + * client that stopped emitting. So the last event an opted-out machine sends is the opt-out + * itself, once; the marker is what makes it once. It lives next to the machine id because both + * describe the machine, not the user, and it is removed when telemetry is turned back on so a + * later opt-out is recorded again. See ``recordOptOut`` in index.ts. + * + * Paths go through ``Global.Path.home`` so a test that sets ``OPENCODE_TEST_HOME`` never reads + * or writes the real home directory; in a real session that is the same directory the machine + * id lives in. + */ +export namespace OptOutMarker { + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + function dir(): string { + return path.join(Global.Path.home, ".altimate") + } + + export function markerPath(): string { + return path.join(dir(), "telemetry-opted-out") + } + + /** The machine id minted by an earlier telemetry-enabled run, if any. Never mints one: a machine + * that never sent telemetry has nothing to mark. */ + export function readMachineId(): string | undefined { + try { + const raw = fs.readFileSync(path.join(dir(), "machine-id"), "utf8").trim() + return UUID.test(raw) ? raw : undefined + } catch { + return undefined + } + } + + export function exists(): boolean { + return fs.existsSync(markerPath()) + } + + export function write(): void { + fs.mkdirSync(dir(), { recursive: true }) + fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600 }) + } + + export function clear(): void { + fs.rmSync(markerPath(), { force: true }) + } +} diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts new file mode 100644 index 0000000000..c56ba64810 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" +import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" +import { OptOutMarker } from "../../../src/altimate/telemetry/opt-out" + +const MACHINE = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" +const UUID = "0f3c2a1e-6b7d-4c8e-9f10-1a2b3c4d5e6f" +const API = "https://api.example.invalid" +const CS = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + +type Call = { url: string; body: string } + +async function waitFor(pred: () => boolean, ms = 2_000) { + const until = Date.now() + ms + while (!pred() && Date.now() < until) await new Promise((r) => setTimeout(r, 20)) + return pred() +} + +describe("telemetry opt-out marker", () => { + let home: string + let calls: Call[] + let trackStatus = 200 + let fetchSpy: ReturnType + let homedirSpy: ReturnType + const saved: Record = {} + const ENV = [ + "OPENCODE_TEST_HOME", + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "ALTIMATE_TELEMETRY_DISABLED", + "OPENCODE_DISABLE_TELEMETRY", + ] + + const optOuts = () => + calls + .filter((c) => c.url.includes("/v2/track")) + .flatMap((c) => JSON.parse(c.body) as any[]) + .filter((e) => e.data?.baseData?.name === "telemetry_opted_out") + const trackPosts = () => calls.filter((c) => c.url.includes("/v2/track")) + + function writeMachineId() { + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync(path.join(home, ".altimate", "machine-id"), MACHINE) + } + function writeCreds() { + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync( + path.join(home, ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API, altimateInstanceName: "acme", altimateApiKey: "key-1" }), + ) + } + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-opt-out-")) + for (const k of ENV) saved[k] = process.env[k] + process.env.OPENCODE_TEST_HOME = home + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = CS + delete process.env.ALTIMATE_TELEMETRY_DISABLED + delete process.env.OPENCODE_DISABLE_TELEMETRY + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + Telemetry.allowOptOutUnderTestRunner(true) + calls = [] + trackStatus = 200 + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push({ url, body: String(init?.body ?? "") }) + if (url === `${API}/users/me`) { + return new Response(JSON.stringify({ id: 7, uuid: UUID }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (url.includes("/v2/track")) return new Response("", { status: trackStatus }) + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + Telemetry.allowOptOutUnderTestRunner(false) + fetchSpy.mockRestore() + homedirSpy.mockRestore() + for (const k of ENV) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + rmSync(home, { recursive: true, force: true }) + }) + + test("an opted-out machine sends one final event with its identity, then nothing", async () => { + writeMachineId() + writeCreds() + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + const meCallsBefore = calls.filter((c) => c.url.endsWith("/users/me")).length + + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => optOuts().length > 0)).toBe(true) + const [event] = optOuts() + expect(event.data.baseData.properties.machine_id).toBe(MACHINE) + expect(event.data.baseData.properties.opt_out_source).toBe("env") + expect(event.data.baseData.properties.user_id).toBe(UUID) + expect(event.data.baseData.properties.tenant).toBe("acme") + expect(typeof event.data.baseData.properties.cli_version).toBe("string") + expect(event.tags["ai.user.id"]).toBe(UUID) + // Identity came from the cache: no request to resolve it while opted out. + expect(calls.filter((c) => c.url.endsWith("/users/me")).length).toBe(meCallsBefore) + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + + // Nothing after it: tracked events are dropped, and a new init does not send again. + const posts = trackPosts().length + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "test", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 150)) + expect(trackPosts().length).toBe(posts) + expect(optOuts()).toHaveLength(1) + }) + + test("a machine that never sent telemetry sends nothing and gets no marker", async () => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 150)) + expect(trackPosts()).toHaveLength(0) + expect(OptOutMarker.exists()).toBe(false) + expect(existsSync(path.join(home, ".altimate", "machine-id"))).toBe(false) + }) + + test("the marker is written only once the sink accepted the event", async () => { + writeMachineId() + process.env.OPENCODE_DISABLE_TELEMETRY = "true" + trackStatus = 500 + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) + await new Promise((r) => setTimeout(r, 50)) + expect(OptOutMarker.exists()).toBe(false) + + trackStatus = 200 + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 2)).toBe(true) + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + expect(optOuts()).toHaveLength(2) + expect(optOuts()[1].data.baseData.properties.user_id).toBeUndefined() + expect(optOuts()[1].tags["ai.user.id"]).toBe(MACHINE) + }) + + test("turning telemetry back on clears the marker", async () => { + writeMachineId() + OptOutMarker.write() + expect(OptOutMarker.exists()).toBe(true) + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(true) + expect(OptOutMarker.exists()).toBe(false) + expect(optOuts()).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/telemetry/automated-run.test.ts b/packages/opencode/test/telemetry/automated-run.test.ts index 1820ba7002..40fb252b6f 100644 --- a/packages/opencode/test/telemetry/automated-run.test.ts +++ b/packages/opencode/test/telemetry/automated-run.test.ts @@ -37,7 +37,9 @@ async function withEnv(env: Partial>, saved.set(key, process.env[key]) delete process.env[key] } - const homeKeys = ["HOME", "USERPROFILE"] as const + // OPENCODE_TEST_HOME: Global.Path.home honours it, and Bun's os.homedir() does not follow a + // changed HOME, so without it code keyed on Global.Path.home would read the real home. + const homeKeys = ["HOME", "USERPROFILE", "OPENCODE_TEST_HOME"] as const for (const key of homeKeys) saved.set(key, process.env[key]) const home = await mkdtemp(path.join(tmpdir(), "telemetry-home-")) for (const key of homeKeys) process.env[key] = home From 15a75c4cabcdf6f491e0443cda366566d9f534f9 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 15:37:08 +0800 Subject: [PATCH 08/16] fix: opt-out marker reads the machine id bounded and leaves telemetry state alone readExistingMachineId() shares the regular-file, size-cap and UUID checks of the minting read; the envelope builder takes the machine id and identity as arguments so recordOptOut() never mutates module state an enabled init may use. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 7 +------ .../opencode/src/altimate/telemetry/opt-out.ts | 12 +++--------- .../opencode/src/altimate/util/machine-id.ts | 18 ++++++++++++++++++ .../test/altimate/telemetry/opt-out.test.ts | 11 +++++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2bb8b0a4b1..ae7b2611b0 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2221,14 +2221,11 @@ export namespace Telemetry { const explicit = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING const cfg = parseConnectionString(explicit ?? DEFAULT_CONNECTION_STRING) if (!cfg) return - machineId = id - identity = await TelemetryIdentity.cached() const envelopes = toAppInsightsEnvelopes( [{ type: "telemetry_opted_out", timestamp: Date.now(), opt_out_source: source }], cfg, + { machineId: id, identity: await TelemetryIdentity.cached() }, ) - machineId = "" - identity = undefined const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) try { @@ -2244,8 +2241,6 @@ export namespace Telemetry { } } catch { // The opt-out path never surfaces: a missed marker only means one more attempt next launch. - machineId = "" - identity = undefined } } // altimate_change end diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts index 4cca1eb5fa..1bc1f71f3f 100644 --- a/packages/opencode/src/altimate/telemetry/opt-out.ts +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -1,6 +1,7 @@ import fs from "fs" import path from "path" import { Global } from "../../global" +import { readExistingMachineId } from "@/altimate/util/machine-id" /** * The marker that says this machine already sent its final ``telemetry_opted_out`` event. @@ -16,8 +17,6 @@ import { Global } from "../../global" * id lives in. */ export namespace OptOutMarker { - const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - function dir(): string { return path.join(Global.Path.home, ".altimate") } @@ -27,14 +26,9 @@ export namespace OptOutMarker { } /** The machine id minted by an earlier telemetry-enabled run, if any. Never mints one: a machine - * that never sent telemetry has nothing to mark. */ + * that never sent telemetry has nothing to mark. Bounded and symlink-safe, like the minting read. */ export function readMachineId(): string | undefined { - try { - const raw = fs.readFileSync(path.join(dir(), "machine-id"), "utf8").trim() - return UUID.test(raw) ? raw : undefined - } catch { - return undefined - } + return readExistingMachineId(path.join(dir(), "machine-id")) || undefined } export function exists(): boolean { diff --git a/packages/opencode/src/altimate/util/machine-id.ts b/packages/opencode/src/altimate/util/machine-id.ts index 30956ceb03..f6a6d23bb6 100644 --- a/packages/opencode/src/altimate/util/machine-id.ts +++ b/packages/opencode/src/altimate/util/machine-id.ts @@ -54,6 +54,24 @@ function readCappedUtf8(idPath: string): string { * @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`. * @returns A v4 UUID string, or `""` if the value is invalid or unreadable. */ +/** + * Read an existing machine id without minting one. Same regular-file, size-cap and UUID + * checks as {@link getOrCreateMachineId}; returns `""` when the file is absent, unreadable, + * not a regular file, oversized or not a UUID. For callers that must not create a machine + * identity as a side effect (the telemetry opt-out marker). + */ +export function readExistingMachineId(machineIdPath?: string): string { + const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id") + try { + const stat = fs.lstatSync(idPath) + if (!stat.isFile() || stat.size > MAX_BYTES) return "" + const raw = readCappedUtf8(idPath) + return UUID_RE.test(raw) ? raw : "" + } catch { + return "" + } +} + export function getOrCreateMachineId(machineIdPath?: string): string { const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id") diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index c56ba64810..92de294f78 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -138,6 +138,17 @@ describe("telemetry opt-out marker", () => { expect(existsSync(path.join(home, ".altimate", "machine-id"))).toBe(false) }) + test("a corrupt or oversized machine-id file sends nothing", async () => { + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync(path.join(home, ".altimate", "machine-id"), "not-a-uuid\n" + "x".repeat(4096)) + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 150)) + expect(trackPosts()).toHaveLength(0) + expect(OptOutMarker.exists()).toBe(false) + }) + test("the marker is written only once the sink accepted the event", async () => { writeMachineId() process.env.OPENCODE_DISABLE_TELEMETRY = "true" From c85847062b8a668cea6642ff1edb585009433545 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 15:44:37 +0800 Subject: [PATCH 09/16] fix: a stale opt-out send never posts or writes the marker after a shutdown The send carries the lifecycle it started in; shutdown() bumps it, so a task still pending when telemetry is re-enabled neither posts nor recreates the marker. Marker removal is best effort, and the suite drops the force flag. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 10 +++++- .../src/altimate/telemetry/opt-out.ts | 7 +++- .../test/altimate/telemetry/opt-out.test.ts | 36 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index ae7b2611b0..7498e4bf73 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1887,6 +1887,9 @@ export namespace Telemetry { // once by the stamp comparison; the backoff only throttles retries of the unchanged one. const IDENTITY_RETRY_MS = 60_000 let identityRetryAt = 0 // 0: no retry pending + // altimate_change — bumped by shutdown(); a detached opt-out send started under an earlier + // lifecycle neither posts nor writes its marker once a later init may have cleared it. + let lifecycleGeneration = 0 let machineId = "" let sessionId = "" let projectId = "" @@ -2210,6 +2213,7 @@ export namespace Telemetry { optOutUnderTestRunner = on } async function recordOptOut(source: "env" | "config"): Promise { + const lifecycle = lifecycleGeneration try { if (underTestRunner() && !optOutUnderTestRunner) return // The force flag is a developer knob for running telemetry under a test runner; the opt-out @@ -2226,6 +2230,9 @@ export namespace Telemetry { cfg, { machineId: id, identity: await TelemetryIdentity.cached() }, ) + // A shutdown since this task started means a later init may own the marker now: neither + // send nor write, and let the next disabled init start over. + if (lifecycle !== lifecycleGeneration) return const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) try { @@ -2235,7 +2242,7 @@ export namespace Telemetry { body: JSON.stringify(envelopes), signal: controller.signal, }) - if (res.ok) OptOutMarker.write() + if (res.ok && lifecycle === lifecycleGeneration) OptOutMarker.write() } finally { clearTimeout(timeout) } @@ -2616,6 +2623,7 @@ export namespace Telemetry { // previous account on events; doInit() resolves it again from whatever credential is on disk. // The generation bump makes any resolution still in flight a no-op when it settles. identityGeneration++ + lifecycleGeneration++ identity = undefined identityStamp = "" identityRetryAt = 0 diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts index 1bc1f71f3f..d2cfafc3f1 100644 --- a/packages/opencode/src/altimate/telemetry/opt-out.ts +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -40,7 +40,12 @@ export namespace OptOutMarker { fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600 }) } + /** Best effort: an unremovable marker (read-only directory) must never fail an init. */ export function clear(): void { - fs.rmSync(markerPath(), { force: true }) + try { + fs.rmSync(markerPath(), { force: true }) + } catch { + // leave it; the next opt-out is then not recorded again, which is the lesser failure + } } } diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index 92de294f78..63e0a6dd55 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -23,6 +23,8 @@ describe("telemetry opt-out marker", () => { let home: string let calls: Call[] let trackStatus = 200 + /** When set, the next /v2/track POST waits on it before answering. */ + let holdTrack: Promise | undefined let fetchSpy: ReturnType let homedirSpy: ReturnType const saved: Record = {} @@ -31,6 +33,7 @@ describe("telemetry opt-out marker", () => { "APPLICATIONINSIGHTS_CONNECTION_STRING", "ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY", + "ALTIMATE_TELEMETRY_FORCE", ] const optOuts = () => @@ -59,10 +62,12 @@ describe("telemetry opt-out marker", () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = CS delete process.env.ALTIMATE_TELEMETRY_DISABLED delete process.env.OPENCODE_DISABLE_TELEMETRY + delete process.env.ALTIMATE_TELEMETRY_FORCE homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) Telemetry.allowOptOutUnderTestRunner(true) calls = [] trackStatus = 200 + holdTrack = undefined fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url calls.push({ url, body: String(init?.body ?? "") }) @@ -72,7 +77,10 @@ describe("telemetry opt-out marker", () => { headers: { "content-type": "application/json" }, }) } - if (url.includes("/v2/track")) return new Response("", { status: trackStatus }) + if (url.includes("/v2/track")) { + if (holdTrack) await holdTrack + return new Response("", { status: trackStatus }) + } return new Response("", { status: 200 }) }) as any) }) @@ -169,6 +177,32 @@ describe("telemetry opt-out marker", () => { expect(optOuts()[1].tags["ai.user.id"]).toBe(MACHINE) }) + test("a send still pending when telemetry is re-enabled never recreates the marker", async () => { + writeMachineId() + let release: () => void = () => {} + holdTrack = new Promise((r) => (release = r)) + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) // the POST is in flight + + delete process.env.ALTIMATE_TELEMETRY_DISABLED + await Telemetry.shutdown() + await Telemetry.init() // telemetry on again; this lifecycle owns the marker now + expect(Telemetry.isEnabled()).toBe(true) + release() + await new Promise((r) => setTimeout(r, 100)) + expect(OptOutMarker.exists()).toBe(false) + + // Opting out again is recorded afresh. + holdTrack = undefined + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => optOuts().length === 2)).toBe(true) + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + }) + test("turning telemetry back on clears the marker", async () => { writeMachineId() OptOutMarker.write() From c523841116421b11c26d17647981e1f1c587c3ff Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 26 Oct 2026 23:44:53 +0800 Subject: [PATCH 10/16] test: telemetry identity and opt-out fixtures carry `surface` Main made `surface` required on `startup_ready`; the fixtures that track it now set it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- packages/opencode/test/altimate/telemetry/identity.test.ts | 4 ++++ packages/opencode/test/altimate/telemetry/opt-out.test.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index 5d0747155c..9549b8495f 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -209,6 +209,7 @@ describe("telemetry identity", () => { type: "startup_ready", session_id: "", command: "a", + surface: "a", duration_ms: 1, fresh_install: false, timestamp: Date.now(), @@ -234,6 +235,7 @@ describe("telemetry identity", () => { type: "startup_ready", session_id: "", command: "b", + surface: "b", duration_ms: 1, fresh_install: false, timestamp: Date.now(), @@ -342,6 +344,7 @@ describe("telemetry identity", () => { type: "startup_ready", session_id: "", command: "test", + surface: "test", duration_ms: 1, fresh_install: false, timestamp: Date.now(), @@ -368,6 +371,7 @@ describe("telemetry identity", () => { type: "startup_ready", session_id: "", command: "test", + surface: "test", duration_ms: 1, fresh_install: false, timestamp: Date.now(), diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index 63e0a6dd55..49603a2b05 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -124,6 +124,7 @@ describe("telemetry opt-out marker", () => { type: "startup_ready", session_id: "", command: "test", + surface: "test", duration_ms: 1, fresh_install: false, timestamp: Date.now(), From fbb0e7a339877a7e4abbc705789bf4cdbe80c5e9 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 26 Oct 2026 23:53:29 +0800 Subject: [PATCH 11/16] fix: an opt-out send accepted across a shutdown keeps its marker The marker write keys on a generation that only an enabled init bumps, so a shutdown while still opted out no longer leaves the accepted event unmarked and sent again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../opencode/src/altimate/telemetry/index.ts | 18 ++++++++++++------ .../test/altimate/telemetry/opt-out.test.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 23578ad463..e890c0331c 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1903,9 +1903,12 @@ export namespace Telemetry { // once by the stamp comparison; the backoff only throttles retries of the unchanged one. const IDENTITY_RETRY_MS = 60_000 let identityRetryAt = 0 // 0: no retry pending - // altimate_change — bumped by shutdown(); a detached opt-out send started under an earlier - // lifecycle neither posts nor writes its marker once a later init may have cleared it. + // altimate_change start — bumped by shutdown(); a detached opt-out send started under an earlier + // lifecycle does not post. Its marker write keys on markerGeneration instead, which only an + // enabled init bumps: a send accepted across a shutdown while still opted out keeps its marker. let lifecycleGeneration = 0 + let markerGeneration = 0 + // altimate_change end let machineId = "" let sessionId = "" let projectId = "" @@ -2151,7 +2154,8 @@ export namespace Telemetry { machineId = getOrCreateMachineId() enabled = true // altimate_change — telemetry is on: forget the opt-out marker, so turning it off again is - // recorded again. + // recorded again. The bump first, so a send still in flight never recreates it. + markerGeneration++ OptOutMarker.clear() log.info("telemetry initialized", { mode: "appinsights" }) // altimate_change — clear any existing interval before installing a new one. doInit() can @@ -2230,6 +2234,7 @@ export namespace Telemetry { } async function recordOptOut(source: "env" | "config"): Promise { const lifecycle = lifecycleGeneration + const marker = markerGeneration try { if (underTestRunner() && !optOutUnderTestRunner) return // The force flag is a developer knob for running telemetry under a test runner; the opt-out @@ -2246,8 +2251,8 @@ export namespace Telemetry { cfg, { machineId: id, identity: await TelemetryIdentity.cached() }, ) - // A shutdown since this task started means a later init may own the marker now: neither - // send nor write, and let the next disabled init start over. + // A shutdown since this task started means a later init may own the marker now: do not + // send, and let the next disabled init start over. if (lifecycle !== lifecycleGeneration) return const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) @@ -2258,7 +2263,8 @@ export namespace Telemetry { body: JSON.stringify(envelopes), signal: controller.signal, }) - if (res.ok && lifecycle === lifecycleGeneration) OptOutMarker.write() + // Accepted: write the marker unless telemetry was turned back on meanwhile. + if (res.ok && marker === markerGeneration) OptOutMarker.write() } finally { clearTimeout(timeout) } diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index 49603a2b05..203f477c7e 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -204,6 +204,25 @@ describe("telemetry opt-out marker", () => { expect(await waitFor(() => OptOutMarker.exists())).toBe(true) }) + test("a send accepted after a shutdown still writes the marker, so the next opted-out init sends nothing", async () => { + writeMachineId() + let release: () => void = () => {} + holdTrack = new Promise((r) => (release = r)) + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) // the POST is in flight + + await Telemetry.shutdown() // still opted out: nothing has cleared the marker + release() + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + + holdTrack = undefined + await Telemetry.init() + await new Promise((r) => setTimeout(r, 100)) + expect(optOuts()).toHaveLength(1) + }) + test("turning telemetry back on clears the marker", async () => { writeMachineId() OptOutMarker.write() From 3b412e021724d39bfb5a2796c5fe99027dabfe16 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 26 Oct 2026 23:59:57 +0800 Subject: [PATCH 12/16] fix: address cubic review of the identity and opt-out modules - `refreshIdentity()` makes no lookup while telemetry is disabled - marker created exclusively - flat exports in the new modules - machine-id docs on their own functions - telemetry tests isolated from disable flags, auth store and real home Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/telemetry/identity.ts | 280 +++++++++--------- .../opencode/src/altimate/telemetry/index.ts | 5 +- .../src/altimate/telemetry/opt-out.ts | 53 ++-- .../opencode/src/altimate/util/machine-id.ts | 34 +-- .../test/altimate/telemetry/identity.test.ts | 55 +++- .../test/altimate/telemetry/opt-out.test.ts | 5 + .../test/telemetry/automated-run.test.ts | 5 +- 7 files changed, 238 insertions(+), 199 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts index bca2437b4e..2831af577c 100644 --- a/packages/opencode/src/altimate/telemetry/identity.ts +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -25,173 +25,173 @@ const log = Log.create({ service: "telemetry-identity" }) * a process only hits the network when the credential changed. Every failure resolves to * ``undefined``: telemetry must never block or break on identity. */ -export namespace TelemetryIdentity { - export type Identity = { user_id: string; tenant: string } +export type Identity = { user_id: string; tenant: string } - type Credential = { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string } - type Cached = Identity & { fingerprint: string } +type Credential = { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string } +type Cached = Identity & { fingerprint: string } - const REQUEST_TIMEOUT_MS = 10_000 - const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - const AUTH_STORE_KEY = "altimate-backend" +const REQUEST_TIMEOUT_MS = 10_000 +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const AUTH_STORE_KEY = "altimate-backend" - export function cachePath(): string { - return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") - } +export function cachePath(): string { + return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") +} + +/** True when a credential is in use (file or auth store), whatever its validity. */ +export async function hasCredential(): Promise { + return (await loadCredential()) !== undefined +} - /** True when a credential is in use (file or auth store), whatever its validity. */ - export async function hasCredential(): Promise { - return (await loadCredential()) !== undefined +/** The credential the Altimate provider would use, or undefined when signed out. */ +export async function loadCredential(): Promise { + try { + if (await AltimateApi.isConfigured()) { + const c = await AltimateApi.getCredentials() + return { + altimateUrl: c.altimateUrl, + altimateInstanceName: c.altimateInstanceName, + altimateApiKey: c.altimateApiKey, + } + } + const auth = await Auth.get(AUTH_STORE_KEY) + if (auth?.type === "api") { + const parsed = AltimateApi.parseAltimateKey(auth.key) + // Same normalisation as getCredentials(): a trailing slash would yield "//users/me". + return parsed ? { ...parsed, altimateUrl: parsed.altimateUrl.replace(/\/+$/, "") } : undefined + } + return undefined + } catch { + return undefined } +} - /** The credential the Altimate provider would use, or undefined when signed out. */ - export async function loadCredential(): Promise { +/** + * A cheap stamp of the two credential files (mtime and size), so a flush can notice that a + * sign-in, sign-out or key change happened on disk, in this thread or another, without + * reading or parsing anything. + */ +export function credentialStamp(): string { + const stamp = (p: string) => { try { - if (await AltimateApi.isConfigured()) { - const c = await AltimateApi.getCredentials() - return { - altimateUrl: c.altimateUrl, - altimateInstanceName: c.altimateInstanceName, - altimateApiKey: c.altimateApiKey, - } - } - const auth = await Auth.get(AUTH_STORE_KEY) - if (auth?.type === "api") { - const parsed = AltimateApi.parseAltimateKey(auth.key) - // Same normalisation as getCredentials(): a trailing slash would yield "//users/me". - return parsed ? { ...parsed, altimateUrl: parsed.altimateUrl.replace(/\/+$/, "") } : undefined - } - return undefined + const s = fs.statSync(p) + return `${s.mtimeMs}:${s.size}` } catch { - return undefined + return "absent" } } + return `${stamp(AltimateApi.credentialsPath())}|${stamp(Auth.filepath())}` +} - /** - * A cheap stamp of the two credential files (mtime and size), so a flush can notice that a - * sign-in, sign-out or key change happened on disk, in this thread or another, without - * reading or parsing anything. - */ - export function credentialStamp(): string { - const stamp = (p: string) => { - try { - const s = fs.statSync(p) - return `${s.mtimeMs}:${s.size}` - } catch { - return "absent" - } - } - return `${stamp(AltimateApi.credentialsPath())}|${stamp(Auth.filepath())}` +function fingerprint(c: Credential): string { + return createHash("sha256") + .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) + .digest("hex") + .slice(0, 16) +} + +async function readCache(): Promise { + try { + const p = cachePath() + if (!(await Filesystem.exists(p))) return undefined + const raw = JSON.parse(await Filesystem.readText(p)) as Partial + if (typeof raw.fingerprint !== "string" || typeof raw.user_id !== "string" || typeof raw.tenant !== "string") + return undefined + if (!UUID.test(raw.user_id)) return undefined + return { fingerprint: raw.fingerprint, user_id: raw.user_id, tenant: raw.tenant } + } catch { + return undefined } +} - function fingerprint(c: Credential): string { - return createHash("sha256") - .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) - .digest("hex") - .slice(0, 16) +/** The cached identity, if it belongs to the credential currently in use. No network. */ +export async function cached(): Promise { + try { + const creds = await loadCredential() + if (!creds) return undefined + const hit = await readCache() + if (!hit || hit.fingerprint !== fingerprint(creds)) return undefined + return { user_id: hit.user_id, tenant: hit.tenant } + } catch { + return undefined } +} - async function readCache(): Promise { - try { - const p = cachePath() - if (!(await Filesystem.exists(p))) return undefined - const raw = JSON.parse(await Filesystem.readText(p)) as Partial - if (typeof raw.fingerprint !== "string" || typeof raw.user_id !== "string" || typeof raw.tenant !== "string") - return undefined - if (!UUID.test(raw.user_id)) return undefined - return { fingerprint: raw.fingerprint, user_id: raw.user_id, tenant: raw.tenant } - } catch { +/** + * Resolve the signed-in user. Uses the cache when the credential has not changed; otherwise + * asks the backend and caches the answer. ``refresh`` forces the request (after a sign-in). + * A backend rejection (401 or 403) drops the cache for that credential; a transient failure + * keeps it. The answer is written and returned only if the same credential is still in use. + */ +export async function resolve(opts: { refresh?: boolean } = {}): Promise { + try { + const creds = await loadCredential() + if (!creds) { + await clear() return undefined } - } - - /** The cached identity, if it belongs to the credential currently in use. No network. */ - export async function cached(): Promise { - try { - const creds = await loadCredential() - if (!creds) return undefined + const fp = fingerprint(creds) + if (!opts.refresh) { const hit = await readCache() - if (!hit || hit.fingerprint !== fingerprint(creds)) return undefined - return { user_id: hit.user_id, tenant: hit.tenant } - } catch { + if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } + } + const answer = await fetchMe(creds) + if (answer.kind === "rejected") { + const hit = await readCache() + if (hit && hit.fingerprint === fp) await clear() return undefined } - } - - /** - * Resolve the signed-in user. Uses the cache when the credential has not changed; otherwise - * asks the backend and caches the answer. ``refresh`` forces the request (after a sign-in). - * A backend rejection (401 or 403) drops the cache for that credential; a transient failure - * keeps it. The answer is written and returned only if the same credential is still in use. - */ - export async function resolve(opts: { refresh?: boolean } = {}): Promise { + if (answer.kind !== "ok") return undefined + const now = await loadCredential() + if (!now || fingerprint(now) !== fp) return undefined + const identity: Identity = { user_id: answer.uuid, tenant: creds.altimateInstanceName } try { - const creds = await loadCredential() - if (!creds) { - await clear() - return undefined - } - const fp = fingerprint(creds) - if (!opts.refresh) { - const hit = await readCache() - if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } - } - const answer = await fetchMe(creds) - if (answer.kind === "rejected") { - const hit = await readCache() - if (hit && hit.fingerprint === fp) await clear() - return undefined - } - if (answer.kind !== "ok") return undefined - const now = await loadCredential() - if (!now || fingerprint(now) !== fp) return undefined - const identity: Identity = { user_id: answer.uuid, tenant: creds.altimateInstanceName } - try { - await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) - } catch (err) { - log.warn("telemetry identity cache not written", { err: String(err) }) - } - return identity + await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) } catch (err) { - log.warn("telemetry identity unresolved", { err: String(err) }) - return undefined + log.warn("telemetry identity cache not written", { err: String(err) }) } + return identity + } catch (err) { + log.warn("telemetry identity unresolved", { err: String(err) }) + return undefined } +} - /** Forget the cached identity (sign-out). */ - export async function clear(): Promise { - try { - await rm(cachePath(), { force: true }) - } catch { - // nothing to forget - } +/** Forget the cached identity (sign-out). */ +export async function clear(): Promise { + try { + await rm(cachePath(), { force: true }) + } catch { + // nothing to forget } +} - type Answer = { kind: "ok"; uuid: string } | { kind: "rejected" } | { kind: "error" } +type Answer = { kind: "ok"; uuid: string } | { kind: "rejected" } | { kind: "error" } - async function fetchMe(c: Credential): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) - try { - const res = await fetch(`${c.altimateUrl}/users/me`, { - method: "GET", - headers: { - Authorization: `Bearer ${c.altimateApiKey}`, - "x-tenant": c.altimateInstanceName, - ...AltimateApi.clientHeaders(), - }, - signal: controller.signal, - }) - if (res.status === 401 || res.status === 403) return { kind: "rejected" } - if (!res.ok) return { kind: "error" } - const body = (await res.json()) as { uuid?: unknown } - return typeof body?.uuid === "string" && UUID.test(body.uuid) - ? { kind: "ok", uuid: body.uuid.toLowerCase() } - : { kind: "error" } - } catch { - return { kind: "error" } - } finally { - clearTimeout(timeout) - } +async function fetchMe(c: Credential): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const res = await fetch(`${c.altimateUrl}/users/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${c.altimateApiKey}`, + "x-tenant": c.altimateInstanceName, + ...AltimateApi.clientHeaders(), + }, + signal: controller.signal, + }) + if (res.status === 401 || res.status === 403) return { kind: "rejected" } + if (!res.ok) return { kind: "error" } + const body = (await res.json()) as { uuid?: unknown } + return typeof body?.uuid === "string" && UUID.test(body.uuid) + ? { kind: "ok", uuid: body.uuid.toLowerCase() } + : { kind: "error" } + } catch { + return { kind: "error" } + } finally { + clearTimeout(timeout) } } + +export * as TelemetryIdentity from "./identity" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index e890c0331c..14a345750f 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2202,8 +2202,11 @@ export namespace Telemetry { /** Re-resolve who the machine is signed in as; clears it when no credential is stored. Events * already buffered are flushed under the identity they were tracked with first, and events - * tracked while the new answer is pending carry no account rather than the previous one. */ + * tracked while the new answer is pending carry no account rather than the previous one. + * Does nothing while telemetry is disabled. */ export async function refreshIdentity(): Promise { + // Opted out (or not initialised): no lookup; an enabled init resolves from the stored credential. + if (!enabled) return await flush() identity = undefined await resolveIdentity({ refresh: true }) diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts index d2cfafc3f1..af2a84bb2a 100644 --- a/packages/opencode/src/altimate/telemetry/opt-out.ts +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -16,36 +16,37 @@ import { readExistingMachineId } from "@/altimate/util/machine-id" * or writes the real home directory; in a real session that is the same directory the machine * id lives in. */ -export namespace OptOutMarker { - function dir(): string { - return path.join(Global.Path.home, ".altimate") - } +function dir(): string { + return path.join(Global.Path.home, ".altimate") +} - export function markerPath(): string { - return path.join(dir(), "telemetry-opted-out") - } +export function markerPath(): string { + return path.join(dir(), "telemetry-opted-out") +} - /** The machine id minted by an earlier telemetry-enabled run, if any. Never mints one: a machine - * that never sent telemetry has nothing to mark. Bounded and symlink-safe, like the minting read. */ - export function readMachineId(): string | undefined { - return readExistingMachineId(path.join(dir(), "machine-id")) || undefined - } +/** The machine id minted by an earlier telemetry-enabled run, if any. Never mints one: a machine + * that never sent telemetry has nothing to mark. Bounded and symlink-safe, like the minting read. */ +export function readMachineId(): string | undefined { + return readExistingMachineId(path.join(dir(), "machine-id")) || undefined +} - export function exists(): boolean { - return fs.existsSync(markerPath()) - } +export function exists(): boolean { + return fs.existsSync(markerPath()) +} - export function write(): void { - fs.mkdirSync(dir(), { recursive: true }) - fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600 }) - } +export function write(): void { + fs.mkdirSync(dir(), { recursive: true }) + // Exclusive create: never follow a planted symlink; an existing entry fails the write. + fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600, flag: "wx" }) +} - /** Best effort: an unremovable marker (read-only directory) must never fail an init. */ - export function clear(): void { - try { - fs.rmSync(markerPath(), { force: true }) - } catch { - // leave it; the next opt-out is then not recorded again, which is the lesser failure - } +/** Best effort: an unremovable marker (read-only directory) must never fail an init. */ +export function clear(): void { + try { + fs.rmSync(markerPath(), { force: true }) + } catch { + // leave it; the next opt-out is then not recorded again, which is the lesser failure } } + +export * as OptOutMarker from "./opt-out" diff --git a/packages/opencode/src/altimate/util/machine-id.ts b/packages/opencode/src/altimate/util/machine-id.ts index f6a6d23bb6..d65a9b96f5 100644 --- a/packages/opencode/src/altimate/util/machine-id.ts +++ b/packages/opencode/src/altimate/util/machine-id.ts @@ -37,23 +37,6 @@ function readCappedUtf8(idPath: string): string { } } -/** - * Read the machine-id from `~/.altimate/machine-id`, minting a new random UUID - * with `flag: "wx"` (exclusive create) if the file is absent. - * - * - **Race-safe**: two concurrent callers on a fresh install converge on the - * same UUID — the winner writes, the loser re-reads. - * - **Regular-file-only**: uses `lstat` and rejects symlinks / non-regular - * files rather than following them to an attacker-chosen target. - * - **Size-capped**: reads at most 512 bytes through a descriptor (enforced at - * read time, not an advisory pre-check) to avoid multi-MB file reads. - * - **UUID-validated**: rejects content that does not match RFC 4122 v4 UUID - * format (corrupt file, injected content, etc.) and returns `""` with a warn - * log so callers can omit the field rather than propagate garbage. - * - * @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`. - * @returns A v4 UUID string, or `""` if the value is invalid or unreadable. - */ /** * Read an existing machine id without minting one. Same regular-file, size-cap and UUID * checks as {@link getOrCreateMachineId}; returns `""` when the file is absent, unreadable, @@ -72,6 +55,23 @@ export function readExistingMachineId(machineIdPath?: string): string { } } +/** + * Read the machine-id from `~/.altimate/machine-id`, minting a new random UUID + * with `flag: "wx"` (exclusive create) if the file is absent. + * + * - **Race-safe**: two concurrent callers on a fresh install converge on the + * same UUID — the winner writes, the loser re-reads. + * - **Regular-file-only**: uses `lstat` and rejects symlinks / non-regular + * files rather than following them to an attacker-chosen target. + * - **Size-capped**: reads at most 512 bytes through a descriptor (enforced at + * read time, not an advisory pre-check) to avoid multi-MB file reads. + * - **UUID-validated**: rejects content that does not match RFC 4122 v4 UUID + * format (corrupt file, injected content, etc.) and returns `""` with a warn + * log so callers can omit the field rather than propagate garbage. + * + * @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`. + * @returns A v4 UUID string, or `""` if the value is invalid or unreadable. + */ export function getOrCreateMachineId(machineIdPath?: string): string { const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id") diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index 9549b8495f..cb4002e463 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -23,7 +23,16 @@ function headersOf(init?: RequestInit): Record { return out } +async function until(pred: () => boolean, ms = 2_000) { + const end = Date.now() + ms + while (!pred() && Date.now() < end) await new Promise((r) => setTimeout(r, 10)) + return pred() +} + +const DISABLE_FLAGS = ["ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY"] as const + describe("telemetry identity", () => { + const savedFlags: Record = {} let home: string let calls: Call[] let fetchSpy: ReturnType @@ -57,6 +66,10 @@ describe("telemetry identity", () => { home = mkdtempSync(path.join(os.tmpdir(), "telemetry-identity-")) savedTestHome = process.env.OPENCODE_TEST_HOME savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + for (const k of DISABLE_FLAGS) { + savedFlags[k] = process.env[k] + delete process.env[k] + } process.env.OPENCODE_TEST_HOME = home homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) calls = [] @@ -96,6 +109,10 @@ describe("telemetry identity", () => { else process.env.OPENCODE_TEST_HOME = savedTestHome if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + for (const k of DISABLE_FLAGS) { + if (savedFlags[k] === undefined) delete process.env[k] + else process.env[k] = savedFlags[k] + } rmSync(home, { recursive: true, force: true }) }) @@ -254,6 +271,19 @@ describe("telemetry identity", () => { expect(Telemetry.identityForTest()).toBeUndefined() }) + test("a sign-in while telemetry is disabled makes no identity request", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(false) + writeCreds() + await Telemetry.refreshIdentity() + expect(meCalls()).toHaveLength(0) + expect(Telemetry.identityForTest()).toBeUndefined() + }) + test("a transient failure at startup is retried at a later flush, not on every flush", async () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" @@ -261,10 +291,9 @@ describe("telemetry identity", () => { meStatus = 503 await Telemetry.shutdown() await Telemetry.init() - await new Promise((r) => setTimeout(r, 60)) + expect(await until(() => meCalls().length >= 1)).toBe(true) expect(Telemetry.identityForTest()).toBeUndefined() const after = meCalls().length - expect(after).toBeGreaterThanOrEqual(1) await Telemetry.flush() await Telemetry.flush() await new Promise((r) => setTimeout(r, 30)) @@ -289,21 +318,19 @@ describe("telemetry identity", () => { await new Promise((r) => setTimeout(r, 15)) writeCreds("key-A") meStatus = 503 + const beforeA = meCalls().length await Telemetry.flush() - await new Promise((r) => setTimeout(r, 60)) + expect(await until(() => meCalls().length > beforeA)).toBe(true) expect(Telemetry.identityForTest()).toBeUndefined() meStatus = 200 - Telemetry.retryIdentityNowForTest() - await Telemetry.flush() - const ok = await (async () => { - const until = Date.now() + 2000 - while (Date.now() < until) { - if (Telemetry.identityForTest()?.user_id === UUID) return true - await new Promise((r) => setTimeout(r, 20)) - } - return false - })() - expect(ok).toBe(true) + // The failed answer settles asynchronously; end its backoff and flush until the retry lands. + const end = Date.now() + 2000 + while (Telemetry.identityForTest()?.user_id !== UUID && Date.now() < end) { + Telemetry.retryIdentityNowForTest() + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 20)) + } + expect(Telemetry.identityForTest()?.user_id).toBe(UUID) }) test("a resolution that started under the old credential never overwrites a newer sign-in", async () => { diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index 203f477c7e..c3ffb8b6d5 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" import os from "os" import path from "path" +import { Auth } from "../../../src/auth" import { Telemetry } from "../../../src/altimate/telemetry" import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" import { OptOutMarker } from "../../../src/altimate/telemetry/opt-out" @@ -27,6 +28,7 @@ describe("telemetry opt-out marker", () => { let holdTrack: Promise | undefined let fetchSpy: ReturnType let homedirSpy: ReturnType + let authSpy: ReturnType const saved: Record = {} const ENV = [ "OPENCODE_TEST_HOME", @@ -64,6 +66,8 @@ describe("telemetry opt-out marker", () => { delete process.env.OPENCODE_DISABLE_TELEMETRY delete process.env.ALTIMATE_TELEMETRY_FORCE homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + // The auth store lives in the real data directory; never let a test read it. + authSpy = spyOn(Auth, "get").mockResolvedValue(undefined) Telemetry.allowOptOutUnderTestRunner(true) calls = [] trackStatus = 200 @@ -90,6 +94,7 @@ describe("telemetry opt-out marker", () => { Telemetry.allowOptOutUnderTestRunner(false) fetchSpy.mockRestore() homedirSpy.mockRestore() + authSpy.mockRestore() for (const k of ENV) { if (saved[k] === undefined) delete process.env[k] else process.env[k] = saved[k] diff --git a/packages/opencode/test/telemetry/automated-run.test.ts b/packages/opencode/test/telemetry/automated-run.test.ts index 40fb252b6f..41ca5548a0 100644 --- a/packages/opencode/test/telemetry/automated-run.test.ts +++ b/packages/opencode/test/telemetry/automated-run.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" +import os, { tmpdir } from "node:os" import path from "node:path" import { Telemetry } from "../../src/altimate/telemetry" @@ -43,11 +43,14 @@ async function withEnv(env: Partial>, for (const key of homeKeys) saved.set(key, process.env[key]) const home = await mkdtemp(path.join(tmpdir(), "telemetry-home-")) for (const key of homeKeys) process.env[key] = home + // The machine-id helper reads os.homedir(), which Bun does not derive from HOME. + const homedirSpy = spyOn(os, "homedir").mockReturnValue(home) Object.assign(process.env, env) try { await fn() } finally { + homedirSpy.mockRestore() for (const key of [...ENV_KEYS, ...homeKeys]) { const value = saved.get(key) if (value === undefined) delete process.env[key] From f7512552bd9d4e24a4bd04b042846fb2a87bb296 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 21:17:42 +0800 Subject: [PATCH 13/16] fix: honour a config telemetry opt-out when init runs without an Instance `TelemetryConfig.disabledState()` reads `telemetry.disabled` from the config files. When `Config.get()` throws, init uses it; when the files cannot be read, init keeps the opt-out marker and resolves no identity. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/telemetry/config-state.ts | 114 ++++++++++++++++++ .../opencode/src/altimate/telemetry/index.ts | 36 ++++-- .../altimate/telemetry/config-state.test.ts | 57 +++++++++ .../test/altimate/telemetry/opt-out.test.ts | 52 ++++++++ 4 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/altimate/telemetry/config-state.ts create mode 100644 packages/opencode/test/altimate/telemetry/config-state.test.ts diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts new file mode 100644 index 0000000000..8ae688dbfa --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -0,0 +1,114 @@ +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" +import * as Effect from "effect/Effect" +import { Flag, env as flagEnv } from "@opencode-ai/core/flag/flag" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "../../global" +import { ConfigPaths } from "@/config/paths" +import { ConfigManaged } from "@/config/managed" + +/** + * Whether the user config turns telemetry off, read without an Instance. + * + * `Config.get()` needs an Instance, and the CLI's main thread initialises telemetry in middleware + * before one exists, so there it throws. Treating that as "enabled" let an opted-out user's main + * thread clear the opt-out marker and resolve their identity. This reads the same files `Config` + * merges, in the same order (a later source overrides an earlier one), straight from disk: + * the global config files, `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode` + * directories found from `directory`, the inline `OPENCODE_CONFIG_CONTENT`, the managed config + * directory and macOS managed preferences. Remote and account configs are not read (they need + * the network). + * + * - `disabled`: the last source that sets `telemetry.disabled` sets it truthy. + * - `enabled`: no source turns it off. + * - `unreadable`: a source that could not be read or parsed comes after the last one that set + * the value, so the answer is unknown. Callers fail closed for anything an opt-out forbids. + */ +export type State = "disabled" | "enabled" | "unreadable" + +const DIR_FILES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"] +const GLOBAL_FILES = ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"] + +/** The git root above `directory`, which `Config` uses as the stop for its upward search. */ +function worktreeOf(directory: string): string | undefined { + let dir = path.resolve(directory) + while (true) { + if (existsSync(path.join(dir, ".git"))) return dir + const parent = path.dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + +export async function disabledState(directory: string = process.cwd()): Promise { + try { + let value: boolean | undefined + let unknown = false + const apply = (data: unknown) => { + const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled + if (disabled === undefined) return + value = Boolean(disabled) + unknown = false + } + const readFile = async (file: string) => { + let text: string + try { + text = await fs.readFile(file, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true + return + } + try { + apply(await ConfigPaths.parseText(text, file, "empty")) + } catch { + unknown = true + } + } + const readText = async (text: string, source: string, dir: string) => { + try { + apply(await ConfigPaths.parseText(text, { source, dir }, "empty")) + } catch { + unknown = true + } + } + + for (const file of GLOBAL_FILES) await readFile(path.join(Global.Path.config, file)) + if (Flag.OPENCODE_CONFIG) await readFile(Flag.OPENCODE_CONFIG) + + const worktree = worktreeOf(directory) + const located = await Effect.runPromise( + Effect.all([ + Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? Effect.succeed([] as string[]) + : ConfigPaths.files("opencode", directory, worktree), + ConfigPaths.directories(directory, worktree), + ]).pipe(Effect.provide(FSUtil.defaultLayer)), + ) + const [projectFiles, directories] = located + for (const file of projectFiles) await readFile(file) + for (const dir of directories) { + if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + for (const file of DIR_FILES) await readFile(path.join(dir, file)) + } + } + + const inline = flagEnv("OPENCODE_CONFIG_CONTENT") + if (inline) await readText(inline, "OPENCODE_CONFIG_CONTENT", directory) + + const managedDir = ConfigManaged.managedConfigDir() + if (existsSync(managedDir)) for (const file of DIR_FILES) await readFile(path.join(managedDir, file)) + const managed = await ConfigManaged.readManagedPreferences().catch(() => { + unknown = true + return undefined + }) + if (managed) await readText(managed.text, managed.source, path.dirname(managed.source)) + + if (unknown) return "unreadable" + return value ? "disabled" : "enabled" + } catch { + return "unreadable" + } +} + +export * as TelemetryConfig from "./config-state" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 14a345750f..17c48269ab 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -7,6 +7,7 @@ import { TraceContext } from "@/altimate/observability/trace-context" import { getOrCreateMachineId } from "@/altimate/util/machine-id" import { TelemetryIdentity } from "./identity" import { OptOutMarker } from "./opt-out" +import { TelemetryConfig } from "./config-state" import { createHash, randomUUID } from "crypto" // altimate_change — first-run health: the event-loop stall monitor labels which thread stalled import { isMainThread } from "node:worker_threads" @@ -1909,6 +1910,9 @@ export namespace Telemetry { let lifecycleGeneration = 0 let markerGeneration = 0 // altimate_change end + // altimate_change — false when this init could not read the user config: an opted-out user may be + // behind it, so no identity is resolved (at init, at a flush or on refresh) until an init that can. + let identityAllowed = true let machineId = "" let sessionId = "" let projectId = "" @@ -2111,8 +2115,11 @@ export namespace Telemetry { return } // Config.get() may throw outside Instance context (e.g. CLI middleware - // before Instance.provide()). Treat config failures as "not disabled" — - // the env var check above is the early-init escape hatch. + // before Instance.provide()). Telemetry stays enabled then, as before. + // altimate_change start — but the opt-out still counts: read `telemetry.disabled` from the + // config files directly, and when even that fails, skip what an opt-out forbids (clearing the + // opt-out marker, resolving the account) while ordinary rows keep flowing. + let configUnreadable = false try { const userConfig = (await Config.get()) as any if (userConfig.telemetry?.disabled) { @@ -2121,8 +2128,15 @@ export namespace Telemetry { return } } catch { - // Config unavailable — proceed with telemetry enabled + const state = await TelemetryConfig.disabledState() + if (state === "disabled") { + buffer = [] + void recordOptOut("config") + return + } + configUnreadable = state === "unreadable" } + // altimate_change end // App Insights: env var overrides default (for dev/testing), otherwise use the baked-in key. // The baked-in key is refused under a test runner so suites never ship to the production // resource. Note this deliberately does NOT key on CI — see isAutomatedRun. @@ -2142,11 +2156,12 @@ export namespace Telemetry { return } appInsights = cfg + identityAllowed = !configUnreadable // altimate_change start — identity for the funnel join: the backend user uuid and tenant from // the stored Altimate credential, cached per credential so this costs no network unless the // credential changed. Replaces the hashed account email. Not awaited: events flushed before it // resolves carry only the machine id, and telemetry never waits on the backend. - void resolveIdentity() + if (identityAllowed) void resolveIdentity() // altimate_change end // altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts. // Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized: @@ -2154,9 +2169,12 @@ export namespace Telemetry { machineId = getOrCreateMachineId() enabled = true // altimate_change — telemetry is on: forget the opt-out marker, so turning it off again is - // recorded again. The bump first, so a send still in flight never recreates it. - markerGeneration++ - OptOutMarker.clear() + // recorded again. The bump first, so a send still in flight never recreates it. Not when the + // config could not be read: the user may have opted out there. + if (!configUnreadable) { + markerGeneration++ + OptOutMarker.clear() + } log.info("telemetry initialized", { mode: "appinsights" }) // altimate_change — clear any existing interval before installing a new one. doInit() can // run more than once per process (init/shutdown cycles per session in prompt.ts), and @@ -2206,7 +2224,7 @@ export namespace Telemetry { * Does nothing while telemetry is disabled. */ export async function refreshIdentity(): Promise { // Opted out (or not initialised): no lookup; an enabled init resolves from the stored credential. - if (!enabled) return + if (!enabled || !identityAllowed) return await flush() identity = undefined await resolveIdentity({ refresh: true }) @@ -2491,7 +2509,7 @@ export namespace Telemetry { // change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed). const who = identity const mid = machineId - if (enabled) { + if (enabled && identityAllowed) { const stamp = TelemetryIdentity.credentialStamp() const retryDue = identityRetryAt !== 0 && Date.now() >= identityRetryAt if (stamp !== identityStamp || retryDue) { diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts new file mode 100644 index 0000000000..329e05e3ea --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import os from "os" +import path from "path" +import { Global } from "../../../src/global" +import { TelemetryConfig } from "../../../src/altimate/telemetry/config-state" + +describe("TelemetryConfig.disabledState (no Instance)", () => { + let dir: string + let savedContent: string | undefined + const globalFile = () => path.join(Global.Path.config, "altimate-code.json") + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-")) + savedContent = process.env.OPENCODE_CONFIG_CONTENT + delete process.env.OPENCODE_CONFIG_CONTENT + }) + afterEach(() => { + if (savedContent === undefined) delete process.env.OPENCODE_CONFIG_CONTENT + else process.env.OPENCODE_CONFIG_CONTENT = savedContent + rmSync(globalFile(), { force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + test("nothing sets it: enabled", async () => { + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + }) + + test("the global config turns it off", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) + + test("a project's .altimate-code directory turns it off", async () => { + mkdirSync(path.join(dir, ".altimate-code")) + writeFileSync(path.join(dir, ".altimate-code", "altimate-code.json"), '{ "telemetry": { "disabled": true } }') + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) + + test("a later source overrides an earlier one, as in Config", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: false } }) + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + }) + + test("an unparseable source after the last setting makes it unreadable", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: false } })) + process.env.OPENCODE_CONFIG_CONTENT = "{ not json" + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a later source that sets the value settles an earlier unreadable one", async () => { + writeFileSync(globalFile(), "{ not json") + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } }) + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) +}) diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index c3ffb8b6d5..9124024663 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" import os from "os" import path from "path" import { Auth } from "../../../src/auth" +import { Config } from "../../../src/config/config" import { Telemetry } from "../../../src/altimate/telemetry" import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" import { OptOutMarker } from "../../../src/altimate/telemetry/opt-out" @@ -36,6 +37,7 @@ describe("telemetry opt-out marker", () => { "ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY", "ALTIMATE_TELEMETRY_FORCE", + "OPENCODE_CONFIG_CONTENT", ] const optOuts = () => @@ -228,6 +230,56 @@ describe("telemetry opt-out marker", () => { expect(optOuts()).toHaveLength(1) }) + test("an init that cannot read the config keeps the marker and resolves no account", async () => { + writeMachineId() + writeCreds() + OptOutMarker.write() + const configSpy = spyOn(Config, "get") + try { + // The main thread's init: no Instance, so Config.get() throws, and the files cannot be read. + configSpy.mockRejectedValue(new Error("InstanceRef not provided")) + process.env.OPENCODE_CONFIG_CONTENT = "{ not json" + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(true) // ordinary rows keep flowing, as before + await Telemetry.flush() + await Telemetry.refreshIdentity() + await new Promise((r) => setTimeout(r, 50)) + expect(OptOutMarker.exists()).toBe(true) + expect(calls.filter((c) => c.url.endsWith("/users/me"))).toHaveLength(0) + expect(Telemetry.identityForTest()).toBeUndefined() + expect(optOuts()).toHaveLength(0) + + // The worker's init, inside an Instance: the config says disabled, and the marker held. + delete process.env.OPENCODE_CONFIG_CONTENT + configSpy.mockResolvedValue({ telemetry: { disabled: true } } as never) + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.isEnabled()).toBe(false) + expect(optOuts()).toHaveLength(0) + expect(OptOutMarker.exists()).toBe(true) + } finally { + configSpy.mockRestore() + } + }) + + test("an init without an Instance honours a config opt-out read from the files", async () => { + writeMachineId() + const configSpy = spyOn(Config, "get").mockRejectedValue(new Error("InstanceRef not provided")) + try { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } }) + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(false) + expect(await waitFor(() => optOuts().length === 1)).toBe(true) + expect(optOuts()[0].data.baseData.properties.opt_out_source).toBe("config") + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + } finally { + configSpy.mockRestore() + } + }) + test("turning telemetry back on clears the marker", async () => { writeMachineId() OptOutMarker.write() From 57e99cffd33737b9cb6b9d2aeae669447593ed6b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 22:30:10 +0800 Subject: [PATCH 14/16] fix: one opt-out event without the account; no polling of a rejected key - claim the opt-out send with an in-flight file, so two inits send once - the event carries the machine id and source only - retry identity only after a transient failure - refresh identity on TUI /logout - create ~/.altimate owner-only Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/telemetry/identity.ts | 46 +++++--- .../opencode/src/altimate/telemetry/index.ts | 32 ++++-- .../src/altimate/telemetry/opt-out.ts | 44 +++++++- .../tui/altimate/provider-credentials.tsx | 3 + .../test/altimate/telemetry/identity.test.ts | 24 ++++ .../test/altimate/telemetry/opt-out.test.ts | 104 +++++++++++++++++- .../test/upstream/altimate-features.test.ts | 2 +- 7 files changed, 220 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts index 2831af577c..23625b2cf5 100644 --- a/packages/opencode/src/altimate/telemetry/identity.ts +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -1,6 +1,6 @@ import fs from "fs" import path from "path" -import { rm } from "node:fs/promises" +import fsp, { rm } from "node:fs/promises" import { createHash } from "crypto" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" @@ -38,11 +38,6 @@ export function cachePath(): string { return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") } -/** True when a credential is in use (file or auth store), whatever its validity. */ -export async function hasCredential(): Promise { - return (await loadCredential()) !== undefined -} - /** The credential the Altimate provider would use, or undefined when signed out. */ export async function loadCredential(): Promise { try { @@ -69,7 +64,8 @@ export async function loadCredential(): Promise { /** * A cheap stamp of the two credential files (mtime and size), so a flush can notice that a * sign-in, sign-out or key change happened on disk, in this thread or another, without - * reading or parsing anything. + * reading or parsing anything. Synchronous on purpose: two `stat` calls on small local files, + * once per flush (every 5 s at most), cost less than the async round trips would. */ export function credentialStamp(): string { const stamp = (p: string) => { @@ -83,6 +79,9 @@ export function credentialStamp(): string { return `${stamp(AltimateApi.credentialsPath())}|${stamp(Auth.filepath())}` } +/** A cache key, not a secret: 64 bits of SHA-256 over a high-entropy API key leave no realistic + * collision between the credentials one machine sees, and the cache holds nothing the key's + * owner could not already read. */ function fingerprint(c: Credential): string { return createHash("sha256") .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) @@ -124,36 +123,53 @@ export async function cached(): Promise { * keeps it. The answer is written and returned only if the same credential is still in use. */ export async function resolve(opts: { refresh?: boolean } = {}): Promise { + return (await resolveWithOutcome(opts)).identity +} + +/** + * How a resolution ended: `ok` with an identity; `none` when no credential is stored (or it + * changed during the request); `rejected` when the backend refused the credential (401/403), + * which only a new credential can change; `error` for a transient failure worth retrying. + */ +export type Outcome = "ok" | "none" | "rejected" | "error" + +/** {@link resolve}, also saying how it ended, so a caller retries only a transient failure. */ +export async function resolveWithOutcome( + opts: { refresh?: boolean } = {}, +): Promise<{ identity?: Identity; outcome: Outcome }> { try { const creds = await loadCredential() if (!creds) { await clear() - return undefined + return { outcome: "none" } } const fp = fingerprint(creds) if (!opts.refresh) { const hit = await readCache() - if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } + if (hit && hit.fingerprint === fp) + return { identity: { user_id: hit.user_id, tenant: hit.tenant }, outcome: "ok" } } const answer = await fetchMe(creds) if (answer.kind === "rejected") { const hit = await readCache() if (hit && hit.fingerprint === fp) await clear() - return undefined + return { outcome: "rejected" } } - if (answer.kind !== "ok") return undefined + if (answer.kind !== "ok") return { outcome: "error" } const now = await loadCredential() - if (!now || fingerprint(now) !== fp) return undefined - const identity: Identity = { user_id: answer.uuid, tenant: creds.altimateInstanceName } + if (!now || fingerprint(now) !== fp) return { outcome: "none" } + const identity: Identity = { user_id: answer.uuid, tenant: now.altimateInstanceName } try { + // The directory holds the machine id and this cache: owner-only when this creates it. + await fsp.mkdir(path.dirname(cachePath()), { recursive: true, mode: 0o700 }) await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) } catch (err) { log.warn("telemetry identity cache not written", { err: String(err) }) } - return identity + return { identity, outcome: "ok" } } catch (err) { log.warn("telemetry identity unresolved", { err: String(err) }) - return undefined + return { outcome: "error" } } } diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 17c48269ab..09e69cf731 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2206,14 +2206,13 @@ export namespace Telemetry { const generation = ++identityGeneration identityStamp = TelemetryIdentity.credentialStamp() identityRetryAt = 0 - return TelemetryIdentity.resolve(opts) - .then(async (resolved) => { + return TelemetryIdentity.resolveWithOutcome(opts) + .then(({ identity: resolved, outcome }) => { if (generation !== identityGeneration) return identity = resolved - if (resolved === undefined && (await TelemetryIdentity.hasCredential())) { - // Signed in but unresolved: a transient failure. Retry at a later flush. - if (generation === identityGeneration) identityRetryAt = Date.now() + IDENTITY_RETRY_MS - } + // Only a transient failure is retried, at a later flush. A rejected credential (401/403) + // stays rejected until it changes, which the stamp comparison notices without polling. + if (outcome === "error") identityRetryAt = Date.now() + IDENTITY_RETRY_MS }) .catch(() => {}) } @@ -2243,10 +2242,11 @@ export namespace Telemetry { // altimate_change start — the final event of an opted-out machine. Sent once, only from a // machine that has sent telemetry before (a machine id exists; none is minted here), under the - // same sink rules as doInit(), with the identity cached by an earlier signed-in run (no request - // to resolve it). The marker is written only once the ingest endpoint accepted the event, so an - // offline launch tries again next time; after the marker, nothing is sent until telemetry is - // re-enabled. Not awaited: an opted-out launch must not wait on the network. + // same sink rules as doInit(). It carries the machine id and the opt-out source, never the account, + // project or launch. One send at a time across threads and processes (OptOutMarker.claim). The + // marker is written only once the ingest endpoint accepted the event, so an offline launch tries + // again next time; after the marker, nothing is sent until telemetry is re-enabled. Not awaited: + // an opted-out launch must not wait on the network. const OPT_OUT_TIMEOUT_MS = 3_000 let optOutUnderTestRunner = false /** Test seam: let a suite that redirects the home directory exercise recordOptOut(). */ @@ -2270,14 +2270,21 @@ export namespace Telemetry { const envelopes = toAppInsightsEnvelopes( [{ type: "telemetry_opted_out", timestamp: Date.now(), opt_out_source: source }], cfg, - { machineId: id, identity: await TelemetryIdentity.cached() }, - ) + { machineId: id, identity: undefined }, + ) as Array<{ data: { baseData: { properties: Record } } }> + for (const envelope of envelopes) { + delete envelope.data.baseData.properties.project_id + delete envelope.data.baseData.properties.launch_id + } // A shutdown since this task started means a later init may own the marker now: do not // send, and let the next disabled init start over. if (lifecycle !== lifecycleGeneration) return + if (!OptOutMarker.claim()) return const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) try { + // Another send may have finished between the marker check above and the claim. + if (OptOutMarker.exists()) return const res = await fetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2288,6 +2295,7 @@ export namespace Telemetry { if (res.ok && marker === markerGeneration) OptOutMarker.write() } finally { clearTimeout(timeout) + OptOutMarker.release() } } catch { // The opt-out path never surfaces: a missed marker only means one more attempt next launch. diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts index af2a84bb2a..f11eebc6db 100644 --- a/packages/opencode/src/altimate/telemetry/opt-out.ts +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -35,11 +35,53 @@ export function exists(): boolean { } export function write(): void { - fs.mkdirSync(dir(), { recursive: true }) + fs.mkdirSync(dir(), { recursive: true, mode: 0o700 }) // Exclusive create: never follow a planted symlink; an existing entry fails the write. fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600, flag: "wx" }) } +/** A send older than this is a process that died mid-send; its claim no longer holds. The send + * itself is capped at 3 s. */ +const CLAIM_STALE_MS = 30_000 + +function claimPath(): string { + return path.join(dir(), "telemetry-opted-out.sending") +} + +/** + * Claim the one opt-out send, across threads and processes: an exclusive create of an in-flight + * file next to the marker. The TUI's main thread and its worker both init telemetry, so without + * this the first opted-out launch sent the event twice. False when another send holds a fresh + * claim (or the file cannot be created); a claim left by a process that died is taken over. + */ +export function claim(): boolean { + const create = () => fs.writeFileSync(claimPath(), `${process.pid}\n`, { mode: 0o600, flag: "wx" }) + try { + create() + return true + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") return false + } + try { + const age = Date.now() - fs.lstatSync(claimPath()).mtimeMs + if (age < CLAIM_STALE_MS) return false + fs.rmSync(claimPath(), { force: true }) + create() + return true + } catch { + return false + } +} + +/** Give the claim back: after the marker is written, or when the send failed so a later launch tries. */ +export function release(): void { + try { + fs.rmSync(claimPath(), { force: true }) + } catch { + // a leftover claim goes stale and is taken over + } +} + /** Best effort: an unremovable marker (read-only directory) must never fail an init. */ export function clear(): void { try { diff --git a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx index 9dc4741945..b5609c1f16 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -15,6 +15,7 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createSignal, Show } from "solid-js" import { AltimateApi } from "@/altimate/api/client" +import { Telemetry } from "@/altimate/telemetry" const id = "altimate:provider-credentials" @@ -87,6 +88,8 @@ function show(api: TuiPluginApi) { async function logout(api: TuiPluginApi) { try { await AltimateApi.clearCredentials() + // Rows from here on carry no account; the batch already buffered keeps the one it was tracked under. + void Telemetry.refreshIdentity().catch(() => {}) await api.client.instance.dispose() api.ui.toast({ variant: "success", message: "Signed out of Altimate LLM Gateway" }) } catch (err) { diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts index cb4002e463..25735b8a72 100644 --- a/packages/opencode/test/altimate/telemetry/identity.test.ts +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -284,6 +284,30 @@ describe("telemetry identity", () => { expect(Telemetry.identityForTest()).toBeUndefined() }) + test("a rejected key is not polled; only a new credential is looked up again", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds("key-revoked") + meStatus = 401 + await Telemetry.shutdown() + await Telemetry.init() + expect(await until(() => meCalls().length === 1)).toBe(true) + await new Promise((r) => setTimeout(r, 50)) // let the rejection settle + for (let i = 0; i < 3; i++) { + Telemetry.retryIdentityNowForTest() // a pending backoff would end here; there is none + await Telemetry.flush() + } + await new Promise((r) => setTimeout(r, 50)) + expect(meCalls()).toHaveLength(1) + + meStatus = 200 + await new Promise((r) => setTimeout(r, 15)) + writeCreds("key-new-and-longer") + await Telemetry.flush() + expect(await until(() => Telemetry.identityForTest()?.user_id !== undefined)).toBe(true) + expect(meCalls()).toHaveLength(2) + }) + test("a transient failure at startup is retried at a later flush, not on every flush", async () => { process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index 9124024663..f3393b2394 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from "fs" import os from "os" import path from "path" import { Auth } from "../../../src/auth" @@ -104,7 +104,7 @@ describe("telemetry opt-out marker", () => { rmSync(home, { recursive: true, force: true }) }) - test("an opted-out machine sends one final event with its identity, then nothing", async () => { + test("an opted-out machine sends one final event with its machine id only, then nothing", async () => { writeMachineId() writeCreds() expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) @@ -117,11 +117,12 @@ describe("telemetry opt-out marker", () => { const [event] = optOuts() expect(event.data.baseData.properties.machine_id).toBe(MACHINE) expect(event.data.baseData.properties.opt_out_source).toBe("env") - expect(event.data.baseData.properties.user_id).toBe(UUID) - expect(event.data.baseData.properties.tenant).toBe("acme") + // Never the account, the project or the launch, even with a cached identity on disk. + for (const key of ["user_id", "tenant", "project_id", "launch_id"]) + expect(event.data.baseData.properties[key]).toBeUndefined() expect(typeof event.data.baseData.properties.cli_version).toBe("string") - expect(event.tags["ai.user.id"]).toBe(UUID) - // Identity came from the cache: no request to resolve it while opted out. + expect(event.tags["ai.user.id"]).toBe(MACHINE) + // No request to resolve an identity while opted out. expect(calls.filter((c) => c.url.endsWith("/users/me")).length).toBe(meCallsBefore) expect(await waitFor(() => OptOutMarker.exists())).toBe(true) @@ -280,6 +281,97 @@ describe("telemetry opt-out marker", () => { } }) + test("OPENCODE_DISABLE_TELEMETRY records the opt-out with source env", async () => { + writeMachineId() + process.env.OPENCODE_DISABLE_TELEMETRY = "true" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => optOuts().length === 1)).toBe(true) + expect(optOuts()[0].data.baseData.properties.opt_out_source).toBe("env") + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + }) + + test("the config opt-out records the event with source config", async () => { + writeMachineId() + const configSpy = spyOn(Config, "get").mockResolvedValue({ telemetry: { disabled: true } } as never) + try { + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(false) + expect(await waitFor(() => optOuts().length === 1)).toBe(true) + expect(optOuts()[0].data.baseData.properties.opt_out_source).toBe("config") + } finally { + configSpy.mockRestore() + } + }) + + test("ALTIMATE_TELEMETRY_FORCE never sends the opt-out event", async () => { + writeMachineId() + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + process.env.ALTIMATE_TELEMETRY_FORCE = "true" + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 100)) + expect(trackPosts()).toHaveLength(0) + expect(OptOutMarker.exists()).toBe(false) + }) + + test("two disabled inits while the first send is in flight send the event once", async () => { + writeMachineId() + let release: () => void = () => {} + holdTrack = new Promise((r) => (release = r)) + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) // the first send holds the claim + + await Telemetry.shutdown() + await Telemetry.init() // a second init, as the TUI's worker beside its main thread + await new Promise((r) => setTimeout(r, 100)) + expect(trackPosts()).toHaveLength(1) + + release() + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + expect(existsSync(path.join(home, ".altimate", "telemetry-opted-out.sending"))).toBe(false) + expect(optOuts()).toHaveLength(1) + }) + + test("another process's fresh claim holds the send; a stale one is taken over", async () => { + writeMachineId() + const claimFile = path.join(home, ".altimate", "telemetry-opted-out.sending") + writeFileSync(claimFile, "123\n") + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 100)) + expect(trackPosts()).toHaveLength(0) + + const old = (Date.now() - 60_000) / 1000 + utimesSync(claimFile, old, old) // the process that held it died mid-send + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => optOuts().length === 1)).toBe(true) + expect(await waitFor(() => OptOutMarker.exists())).toBe(true) + }) + + test("a failed send gives the claim back, so the next launch tries again", async () => { + writeMachineId() + trackStatus = 500 + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) + const claimFile = path.join(home, ".altimate", "telemetry-opted-out.sending") + expect(await waitFor(() => !existsSync(claimFile))).toBe(true) + expect(OptOutMarker.exists()).toBe(false) + }) + + test("the marker creates a missing ~/.altimate owner-only", () => { + expect(existsSync(path.join(home, ".altimate"))).toBe(false) + OptOutMarker.write() + expect(statSync(path.join(home, ".altimate")).mode & 0o777).toBe(0o700) + }) + test("turning telemetry back on clears the marker", async () => { writeMachineId() OptOutMarker.write() diff --git a/packages/opencode/test/upstream/altimate-features.test.ts b/packages/opencode/test/upstream/altimate-features.test.ts index 6f5fad55fc..e7c66fa8d9 100644 --- a/packages/opencode/test/upstream/altimate-features.test.ts +++ b/packages/opencode/test/upstream/altimate-features.test.ts @@ -414,7 +414,7 @@ describe("altimate features: telemetry fail-safe init", () => { // user uuid and tenant resolved by TelemetryIdentity, so there is no Account.active() caller // here any more (it used to be awaited; Account.active() became async in v1.4.0). expect(src).not.toMatch(/Account\.active\s*\(/) - expect(src).toMatch(/TelemetryIdentity\.resolve\(/) + expect(src).toMatch(/TelemetryIdentity\.resolve(WithOutcome)?\(/) }) }) From 57eed56e28ccff3c753395869a610645ea4cab38 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 22:30:10 +0800 Subject: [PATCH 15/16] docs: telemetry page matches the identity and opt-out behaviour Signed-in events carry the user id and tenant; the identity cache and lookup; the one final `telemetry_opted_out` event, its marker and claim files; no email hash. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- docs/docs/reference/telemetry.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 0f32d31dcd..c7e629ef01 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -1,10 +1,10 @@ --- title: "Telemetry — Altimate Code" -description: "Altimate Code collects anonymous usage telemetry by default. See what's collected and how to opt out." +description: "Altimate Code collects usage telemetry by default. See what's collected, what identifies you when you sign in, and how to opt out." --- # Telemetry -Altimate Code collects anonymous usage data to help us improve the product. This page describes what we collect, why, and how to opt out. +Altimate Code collects usage data to help us improve the product. Events from a machine that is not signed in to Altimate are anonymous; once you sign in, events also carry your Altimate user ID and tenant (see [User Identification](#new-user-identification)). This page describes what we collect, why, and how to opt out. ## What We Collect @@ -62,8 +62,9 @@ We collect the following categories of events: | `altimate_base_register_result` | The registration outcome from a picker-driven selection (the welcome picker, the full catalogue, or the provider dialog): `success`, `rate_limited`, `unavailable`, `network`, or `error`. Optional `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base). Auto-registration at startup is reported separately, by `altimate_base_registration` above. No credential or gateway response body is included. | | `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. | | `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. | -| `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. | +| `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. The event names no instance itself; like every event sent after sign-in, it carries the account's `user_id` and `tenant` once they are resolved (see [User Identification](#new-user-identification)). | | `onboarding_completed` | A model is ready and chat is live. | +| `telemetry_opted_out` | The last event from a machine that turns telemetry off, sent once (see [Disabling Telemetry](#disabling-telemetry)). Contains `opt_out_source` (`env` for an environment variable, `config` for `telemetry.disabled`), the machine ID, the CLI version and the client. It carries no account (`user_id`, `tenant`), project, session or launch ID. | | `scan_gate_shown` / `scan_gate_choice` | The "scan your environment?" gate appeared, and what the user did — `scan`, `skip`, or `dismissed` (esc / click-away). | | `environment_scan_completed` | A `project_scan` finished during onboarding — `has_dbt`, `has_warehouse`, `is_repo`, `connections_found`, and a bounded list of short `degraded` detection keys. No paths, hostnames, or connection details. Emitted only inside an onboarding session, and only once per session, so scans from `/discover` or a model-initiated call are excluded. | | `sample_setup_completed` | The sample dbt project was materialised. `success`, `models`, `tables`, and `reused` — the tool is deliberately re-callable, so this is per invocation. The target path is never sent. | @@ -74,7 +75,7 @@ We collect the following categories of events: | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | -Each event includes a timestamp, anonymous session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information). +Each event includes a timestamp, session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and a machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and not derived from your hardware or identity). When the machine is signed in to Altimate, each event also carries `user_id` and `tenant`, described under [User Identification](#new-user-identification). ### Notes on the review events @@ -133,7 +134,13 @@ You can also set the environment variable: export ALTIMATE_TELEMETRY_DISABLED=true ``` -When telemetry is disabled, no events are sent and no network requests are made to the telemetry endpoint. +When telemetry is disabled, a machine that has sent telemetry before sends one final `telemetry_opted_out` event, then nothing: no further events and no requests to the telemetry endpoint until telemetry is turned back on. The event carries only the machine ID, the opt-out source, the CLI version and the client — see the event table above. With it, an opted-out machine can be counted instead of looking like a client that stopped working. + +- The event is sent only if `~/.altimate/machine-id` already exists. A machine that never ran with telemetry on sends nothing. +- Once the telemetry endpoint accepts the event, Altimate Code writes the marker file `~/.altimate/telemetry-opted-out`, and later launches send nothing. If the endpoint cannot be reached, no marker is written and a later launch tries again. +- While the event is being sent, a short-lived `~/.altimate/telemetry-opted-out.sending` file stops a second process from sending it too. +- Turning telemetry back on removes the marker, so a later opt-out is recorded again. +- No account lookup is made while telemetry is disabled. ### Test runs are excluded @@ -161,7 +168,7 @@ We take your privacy seriously. Altimate Code telemetry **never** collects: - Code content, file contents, or file paths - Credentials, API keys, or tokens - Database connection strings or hostnames -- Personally identifiable information (your email is SHA-256 hashed before sending and is used only for anonymous user correlation) +- Your email address, in any form (when you are signed in, events carry your Altimate user ID and tenant instead — see below) - Tool arguments or outputs - AI prompt content or responses @@ -169,12 +176,12 @@ Error messages are truncated to 500 characters and scrubbed of file paths before ### New User Identification -Altimate Code uses two types of anonymous identifiers for analytics, depending on whether you are logged in: +What identifies the sender of an event depends on whether the machine is signed in to Altimate: -- **Anonymous users (not logged in):** A random UUID is generated using `crypto.randomUUID()` on first run and stored at `~/.altimate/machine-id`. This ID is not tied to your hardware, operating system, or identity — it is purely random and serves only to distinguish one machine from another in aggregate analytics. -- **Logged-in users (OAuth):** Your email address is SHA-256 hashed before sending. The raw email is never transmitted. +- **Not signed in:** A random UUID is generated using `crypto.randomUUID()` on first run and stored at `~/.altimate/machine-id`. This ID is not tied to your hardware, operating system, or identity — it is purely random and serves only to distinguish one machine from another in aggregate analytics. It is also the Application Insights user ID (`ai.user.id`). +- **Signed in to Altimate:** Events also carry `user_id`, your Altimate user ID (a UUID), and `tenant`, the name of your Altimate instance; `ai.user.id` is your user ID instead of the machine ID. These identify your account, so signed-in events are not anonymous. To find them, Altimate Code makes one `GET /users/me` request to the Altimate API with the credential you signed in with, and caches the answer at `~/.altimate/telemetry-identity.json` (readable only by you). The cache holds the user ID, the tenant and a fingerprint of the credential — no API key or token. A new credential is looked up again; a sign-out, or a credential the API rejects, removes the cache. No email address, hashed or not, is sent. -Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above. +These identifiers are sent only while telemetry is enabled. The final `telemetry_opted_out` event carries the machine ID and nothing that identifies your account, and no `/users/me` lookup is made while telemetry is disabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above. ### CLI Authentication Flow From 49a3dbe09af377eb71249cd28b598847a37c1331 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 22:42:18 +0800 Subject: [PATCH 16/16] fix: read the opt-out from the command's project; guard the marker across processes - init reads the config of the run/attach `--dir` or TUI project - legacy TOML global config read; a non-boolean value is unreadable - the marker is written only while this send still holds its claim - docs: eligibility is an existing machine id Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- docs/docs/reference/telemetry.md | 2 +- .../src/altimate/telemetry/config-state.ts | 16 +++++++- .../opencode/src/altimate/telemetry/index.ts | 18 +++++--- .../src/altimate/telemetry/opt-out.ts | 41 +++++++++++++------ packages/opencode/src/index.ts | 13 ++++++ .../altimate/telemetry/config-state.test.ts | 27 +++++++++++- .../test/altimate/telemetry/opt-out.test.ts | 37 ++++++++++++++++- 7 files changed, 131 insertions(+), 23 deletions(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index c7e629ef01..46732fca88 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -134,7 +134,7 @@ You can also set the environment variable: export ALTIMATE_TELEMETRY_DISABLED=true ``` -When telemetry is disabled, a machine that has sent telemetry before sends one final `telemetry_opted_out` event, then nothing: no further events and no requests to the telemetry endpoint until telemetry is turned back on. The event carries only the machine ID, the opt-out source, the CLI version and the client — see the event table above. With it, an opted-out machine can be counted instead of looking like a client that stopped working. +When telemetry is disabled, a machine with an existing machine ID sends one final `telemetry_opted_out` event, then nothing: no further events and no requests to the telemetry endpoint until telemetry is turned back on. The event carries only the machine ID, the opt-out source, the CLI version and the client — see the event table above. With it, an opted-out machine can be counted instead of looking like a client that stopped working. - The event is sent only if `~/.altimate/machine-id` already exists. A machine that never ran with telemetry on sends nothing. - Once the telemetry endpoint accepts the event, Altimate Code writes the marker file `~/.altimate/telemetry-opted-out`, and later launches send nothing. If the endpoint cannot be reached, no marker is written and a later launch tries again. diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts index 8ae688dbfa..ab89fd6186 100644 --- a/packages/opencode/src/altimate/telemetry/config-state.ts +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -15,7 +15,7 @@ import { ConfigManaged } from "@/config/managed" * before one exists, so there it throws. Treating that as "enabled" let an opted-out user's main * thread clear the opt-out marker and resolve their identity. This reads the same files `Config` * merges, in the same order (a later source overrides an earlier one), straight from disk: - * the global config files, `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode` + * the global config files (the legacy TOML `config` file included), `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode` * directories found from `directory`, the inline `OPENCODE_CONFIG_CONTENT`, the managed config * directory and macOS managed preferences. Remote and account configs are not read (they need * the network). @@ -48,7 +48,12 @@ export async function disabledState(directory: string = process.cwd()): Promise< const apply = (data: unknown) => { const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled if (disabled === undefined) return - value = Boolean(disabled) + // Config's schema accepts only a boolean; anything else makes the whole config invalid there. + if (typeof disabled !== "boolean") { + unknown = true + return + } + value = disabled unknown = false } const readFile = async (file: string) => { @@ -74,6 +79,13 @@ export async function disabledState(directory: string = process.cwd()): Promise< } for (const file of GLOBAL_FILES) await readFile(path.join(Global.Path.config, file)) + // The legacy TOML global config, which Config merges after the JSON files (and migrates). + const legacy = path.join(Global.Path.config, "config") + try { + apply(Bun.TOML.parse(await fs.readFile(legacy, "utf-8"))) + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true + } if (Flag.OPENCODE_CONFIG) await readFile(Flag.OPENCODE_CONFIG) const worktree = worktreeOf(directory) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 09e69cf731..ff983b90f4 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1913,6 +1913,12 @@ export namespace Telemetry { // altimate_change — false when this init could not read the user config: an opted-out user may be // behind it, so no identity is resolved (at init, at a flush or on refresh) until an init that can. let identityAllowed = true + // altimate_change — the project the command will run in, for reading the config without an + // Instance: the CLI initialises telemetry before a command changes into its `--dir` or project. + let configDirectory: string | undefined + export function setConfigDirectory(directory: string | undefined) { + configDirectory = directory + } let machineId = "" let sessionId = "" let projectId = "" @@ -2128,7 +2134,7 @@ export namespace Telemetry { return } } catch { - const state = await TelemetryConfig.disabledState() + const state = await TelemetryConfig.disabledState(configDirectory) if (state === "disabled") { buffer = [] void recordOptOut("config") @@ -2279,7 +2285,8 @@ export namespace Telemetry { // A shutdown since this task started means a later init may own the marker now: do not // send, and let the next disabled init start over. if (lifecycle !== lifecycleGeneration) return - if (!OptOutMarker.claim()) return + const claim = OptOutMarker.claim() + if (!claim) return const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS) try { @@ -2291,11 +2298,12 @@ export namespace Telemetry { body: JSON.stringify(envelopes), signal: controller.signal, }) - // Accepted: write the marker unless telemetry was turned back on meanwhile. - if (res.ok && marker === markerGeneration) OptOutMarker.write() + // Accepted: write the marker unless telemetry was turned back on meanwhile, in this process + // (the generation) or another one (an enabled init removes the claim). + if (res.ok && marker === markerGeneration && OptOutMarker.holds(claim)) OptOutMarker.write() } finally { clearTimeout(timeout) - OptOutMarker.release() + OptOutMarker.release(claim) } } catch { // The opt-out path never surfaces: a missed marker only means one more attempt next launch. diff --git a/packages/opencode/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts index f11eebc6db..92a86ffba3 100644 --- a/packages/opencode/src/altimate/telemetry/opt-out.ts +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -1,4 +1,5 @@ import fs from "fs" +import { randomUUID } from "crypto" import path from "path" import { Global } from "../../global" import { readExistingMachineId } from "@/altimate/util/machine-id" @@ -51,41 +52,57 @@ function claimPath(): string { /** * Claim the one opt-out send, across threads and processes: an exclusive create of an in-flight * file next to the marker. The TUI's main thread and its worker both init telemetry, so without - * this the first opted-out launch sent the event twice. False when another send holds a fresh - * claim (or the file cannot be created); a claim left by a process that died is taken over. + * this the first opted-out launch sent the event twice. Returns this send's token, or undefined when + * another send holds a fresh claim (or the file cannot be created); a claim left by a process that + * died is taken over. */ -export function claim(): boolean { - const create = () => fs.writeFileSync(claimPath(), `${process.pid}\n`, { mode: 0o600, flag: "wx" }) +export function claim(): string | undefined { + const token = `${process.pid}:${randomUUID()}` + const create = () => fs.writeFileSync(claimPath(), `${token}\n`, { mode: 0o600, flag: "wx" }) try { create() - return true + return token } catch (err) { - if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") return false + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") return undefined } try { const age = Date.now() - fs.lstatSync(claimPath()).mtimeMs - if (age < CLAIM_STALE_MS) return false + if (age < CLAIM_STALE_MS) return undefined fs.rmSync(claimPath(), { force: true }) create() - return true + return token + } catch { + return undefined + } +} + +/** Whether `token`'s claim still stands. An enabled init in any process removes the claim with the + * marker (see `clear`), so a send it outlived must not write the marker back. */ +export function holds(token: string): boolean { + try { + return fs.readFileSync(claimPath(), "utf-8").trim() === token } catch { return false } } -/** Give the claim back: after the marker is written, or when the send failed so a later launch tries. */ -export function release(): void { +/** Give the claim back, if it is still this send's: after the marker is written, or when the send + * failed so a later launch tries. */ +export function release(token: string): void { try { - fs.rmSync(claimPath(), { force: true }) + if (holds(token)) fs.rmSync(claimPath(), { force: true }) } catch { // a leftover claim goes stale and is taken over } } -/** Best effort: an unremovable marker (read-only directory) must never fail an init. */ +/** Telemetry is on again: remove the marker and any in-flight claim, so a send still running in + * another process does not write the marker back. Best effort: an unremovable marker (read-only + * directory) must never fail an init. */ export function clear(): void { try { fs.rmSync(markerPath(), { force: true }) + fs.rmSync(claimPath(), { force: true }) } catch { // leave it; the next opt-out is then not recorded again, which is the lesser failure } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a9adcc16bb..35a81853b6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -30,6 +30,10 @@ import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" import { TuiThreadCommand } from "./cli/cmd/tui" +// altimate_change start — telemetry: the command's project directory for the config opt-out +import path from "path" +import { resolveThreadDirectory } from "./cli/cmd/tui" +// altimate_change end import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" @@ -188,6 +192,15 @@ let cli = yargs(args) // `debug bundle --no-network` promises no network access; telemetry would send its events. // Any form of "no network" counts (`--no-network`, `--network=false`, `--network false`), as parsed by yargs. const noNetwork = firstPositional === "debug" && (opts as { network?: unknown }).network === false + // The command changes into its project only after this; the config opt-out is read from there. + const target = opts as { dir?: unknown; project?: unknown; attach?: unknown } + Telemetry.setConfigDirectory( + (firstPositional === "attach" || (firstPositional === "run" && !target.attach)) && typeof target.dir === "string" + ? path.resolve(target.dir) + : !CLI_COMMAND_NAMES.has(firstPositional) && typeof target.project === "string" + ? resolveThreadDirectory(target.project) + : undefined, + ) if (!noNetwork) Telemetry.init().catch(() => {}) // altimate_change end }) diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts index 329e05e3ea..9137b47f79 100644 --- a/packages/opencode/test/altimate/telemetry/config-state.test.ts +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" import os from "os" import path from "path" import { Global } from "../../../src/global" @@ -8,17 +8,28 @@ import { TelemetryConfig } from "../../../src/altimate/telemetry/config-state" describe("TelemetryConfig.disabledState (no Instance)", () => { let dir: string let savedContent: string | undefined + // The preload points the global config at a per-run temp directory; other suites may have + // seeded these files there, so each test puts back what it found. const globalFile = () => path.join(Global.Path.config, "altimate-code.json") + const legacyFile = () => path.join(Global.Path.config, "config") + const savedFiles = new Map() beforeEach(() => { dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-")) savedContent = process.env.OPENCODE_CONFIG_CONTENT delete process.env.OPENCODE_CONFIG_CONTENT + for (const file of [globalFile(), legacyFile()]) { + savedFiles.set(file, existsSync(file) ? readFileSync(file, "utf-8") : undefined) + rmSync(file, { force: true }) + } }) afterEach(() => { if (savedContent === undefined) delete process.env.OPENCODE_CONFIG_CONTENT else process.env.OPENCODE_CONFIG_CONTENT = savedContent - rmSync(globalFile(), { force: true }) + for (const [file, text] of savedFiles) { + if (text === undefined) rmSync(file, { force: true }) + else writeFileSync(file, text) + } rmSync(dir, { recursive: true, force: true }) }) @@ -31,6 +42,18 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") }) + test("the legacy TOML global config turns it off, and a broken one is unreadable", async () => { + writeFileSync(legacyFile(), "[telemetry]\ndisabled = true\n") + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + writeFileSync(legacyFile(), "[telemetry\ndisabled = \n") + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a value Config's schema would reject is unreadable, not enabled", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: 0 } })) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + test("a project's .altimate-code directory turns it off", async () => { mkdirSync(path.join(dir, ".altimate-code")) writeFileSync(path.join(dir, ".altimate-code", "altimate-code.json"), '{ "telemetry": { "disabled": true } }') diff --git a/packages/opencode/test/altimate/telemetry/opt-out.test.ts b/packages/opencode/test/altimate/telemetry/opt-out.test.ts index f3393b2394..fd785c1d08 100644 --- a/packages/opencode/test/altimate/telemetry/opt-out.test.ts +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -311,9 +311,10 @@ describe("telemetry opt-out marker", () => { process.env.ALTIMATE_TELEMETRY_FORCE = "true" await Telemetry.shutdown() await Telemetry.init() - await new Promise((r) => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)) // nothing to wait for: the force check returns first expect(trackPosts()).toHaveLength(0) expect(OptOutMarker.exists()).toBe(false) + expect(existsSync(path.join(home, ".altimate", "telemetry-opted-out.sending"))).toBe(false) }) test("two disabled inits while the first send is in flight send the event once", async () => { @@ -366,6 +367,40 @@ describe("telemetry opt-out marker", () => { expect(OptOutMarker.exists()).toBe(false) }) + test("telemetry turned back on in another process stops an in-flight send from writing the marker", async () => { + writeMachineId() + let release: () => void = () => {} + holdTrack = new Promise((r) => (release = r)) + process.env.ALTIMATE_TELEMETRY_DISABLED = "1" + await Telemetry.shutdown() + await Telemetry.init() + expect(await waitFor(() => trackPosts().length === 1)).toBe(true) + OptOutMarker.clear() // what an enabled init in another process does + release() + await new Promise((r) => setTimeout(r, 100)) + expect(OptOutMarker.exists()).toBe(false) + }) + + test("an init without an Instance reads the opt-out from the command's project directory", async () => { + writeMachineId() + const project = mkdtempSync(path.join(os.tmpdir(), "telemetry-opt-out-project-")) + mkdirSync(path.join(project, ".altimate-code")) + writeFileSync(path.join(project, ".altimate-code", "altimate-code.json"), '{ "telemetry": { "disabled": true } }') + const configSpy = spyOn(Config, "get").mockRejectedValue(new Error("InstanceRef not provided")) + try { + Telemetry.setConfigDirectory(project) + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(false) + expect(await waitFor(() => optOuts().length === 1)).toBe(true) + expect(optOuts()[0].data.baseData.properties.opt_out_source).toBe("config") + } finally { + Telemetry.setConfigDirectory(undefined) + configSpy.mockRestore() + rmSync(project, { recursive: true, force: true }) + } + }) + test("the marker creates a missing ~/.altimate owner-only", () => { expect(existsSync(path.join(home, ".altimate"))).toBe(false) OptOutMarker.write()