diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 0f32d31dcd..46732fca88 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 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. +- 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 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/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts new file mode 100644 index 0000000000..a5d1789755 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -0,0 +1,127 @@ +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" +import { validateLoaded } from "@/config/config" + +/** + * 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 (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). The read emits no telemetry of its own: it + * runs before telemetry knows whether it may. + * + * - `disabled`: the last source that sets `telemetry.disabled` sets it to `true`. + * - `enabled`: no source turns it off. + * - `unreadable`: some source could not be read, parsed or validated as `Config` does (a missing + * `{file:}` reference, a value its schema rejects). `Config` would fail to load at all, 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 + // Any source that fails to read, parse or validate fails Config's whole load, so the answer + // is unknown however the other sources set it. + let unknown = false + const take = (data: unknown) => { + const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled + if (disabled === undefined) return + if (typeof disabled !== "boolean") unknown = true + else value = disabled + } + // Parse as the loader does (a missing `{file:}` reference is an error) and validate with the + // loader's own schema step, so a source Config would reject cannot set the answer. + const parse = async (text: string, input: string | { source: string; dir: string }) => { + if (!text) return // the loader skips only an empty file; whitespace still goes to its parser + const source = typeof input === "string" ? input : input.source + try { + take(validateLoaded(await ConfigPaths.parseText(text, input, "error", { track: false }), source)) + } catch { + unknown = true + } + } + 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 + } + await parse(text, file) + } + const readText = (text: string, source: string, dir: string) => parse(text, { source, dir }) + + 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 { + take(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) + 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/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts new file mode 100644 index 0000000000..23625b2cf5 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -0,0 +1,213 @@ +import fs from "fs" +import path from "path" +import fsp, { 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" + +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 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 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") +} + +/** 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 + } +} + +/** + * 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. 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) => { + try { + const s = fs.statSync(p) + return `${s.mtimeMs}:${s.size}` + } catch { + return "absent" + } + } + 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}`) + .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 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 + } +} + +/** + * 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 { + 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 { outcome: "none" } + } + const fp = fingerprint(creds) + if (!opts.refresh) { + const hit = await readCache() + 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 { outcome: "rejected" } + } + if (answer.kind !== "ok") return { outcome: "error" } + const now = await loadCredential() + 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, outcome: "ok" } + } catch (err) { + log.warn("telemetry identity unresolved", { err: String(err) }) + return { outcome: "error" } + } +} + +/** 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" } + +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 3bb60e717a..ff983b90f4 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,9 @@ 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 { 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" @@ -65,9 +67,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 +678,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 @@ -1867,7 +1885,40 @@ 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 + // 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 + // 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 = "" + // altimate_change — a resolution that failed while a credential exists (backend down, timeout) + // 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 // 0: no retry pending + // 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 + // 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 = "" @@ -1936,7 +1987,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. @@ -1953,7 +2013,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. + ...(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 — @@ -1978,12 +2041,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": who?.user_id || mid || "", // altimate_change end "ai.cloud.role": "altimate", "ai.application.ver": InstallationVersion, @@ -2056,20 +2117,32 @@ 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 - // 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) { buffer = [] + void recordOptOut("config") return } } catch { - // Config unavailable — proceed with telemetry enabled + const state = await TelemetryConfig.disabledState(configDirectory) + 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. @@ -2089,21 +2162,25 @@ 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 - } + 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. + 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: // 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. 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 @@ -2129,6 +2206,111 @@ 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 + identityStamp = TelemetryIdentity.credentialStamp() + identityRetryAt = 0 + return TelemetryIdentity.resolveWithOutcome(opts) + .then(({ identity: resolved, outcome }) => { + if (generation !== identityGeneration) return + identity = resolved + // 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(() => {}) + } + + /** 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. + * 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 || !identityAllowed) return + await flush() + identity = undefined + await resolveIdentity({ refresh: true }) + } + + /** Test seam. */ + export function identityForTest(): TelemetryIdentity.Identity | undefined { + return identity + } + + /** Test seam: end a pending retry backoff so the next flush re-resolves. */ + export function retryIdentityNowForTest() { + if (identityRetryAt !== 0) identityRetryAt = 1 + } + // 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(). 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(). */ + export function allowOptOutUnderTestRunner(on: boolean) { + optOutUnderTestRunner = on + } + 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 + // 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 + const envelopes = toAppInsightsEnvelopes( + [{ type: "telemetry_opted_out", timestamp: Date.now(), opt_out_source: source }], + cfg, + { 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 + const claim = OptOutMarker.claim() + if (!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" }, + body: JSON.stringify(envelopes), + signal: controller.signal, + }) + // 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(claim) + } + } catch { + // The opt-out path never surfaces: a missed marker only means one more attempt next launch. + } + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string }) { sessionId = opts.sessionId projectId = opts.projectId @@ -2338,6 +2520,20 @@ 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 && identityAllowed) { + const stamp = TelemetryIdentity.credentialStamp() + const retryDue = identityRetryAt !== 0 && Date.now() >= identityRetryAt + if (stamp !== identityStamp || retryDue) { + identity = undefined + void resolveIdentity() + } + } + // altimate_change end if (!enabled || buffer.length === 0 || !appInsights) return const events = buffer.splice(0, buffer.length) @@ -2361,7 +2557,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) { @@ -2500,10 +2696,14 @@ 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. + // The generation bump makes any resolution still in flight a no-op when it settles. + identityGeneration++ + lifecycleGeneration++ + 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/src/altimate/telemetry/opt-out.ts b/packages/opencode/src/altimate/telemetry/opt-out.ts new file mode 100644 index 0000000000..92a86ffba3 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/opt-out.ts @@ -0,0 +1,111 @@ +import fs from "fs" +import { randomUUID } from "crypto" +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. + * + * 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. + */ +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. 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 write(): void { + 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. 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(): string | undefined { + const token = `${process.pid}:${randomUUID()}` + const create = () => fs.writeFileSync(claimPath(), `${token}\n`, { mode: 0o600, flag: "wx" }) + try { + create() + return token + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") return undefined + } + try { + const age = Date.now() - fs.lstatSync(claimPath()).mtimeMs + if (age < CLAIM_STALE_MS) return undefined + fs.rmSync(claimPath(), { force: true }) + create() + 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, 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 { + if (holds(token)) fs.rmSync(claimPath(), { force: true }) + } catch { + // a leftover claim goes stale and is taken over + } +} + +/** 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 + } +} + +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 30956ceb03..d65a9b96f5 100644 --- a/packages/opencode/src/altimate/util/machine-id.ts +++ b/packages/opencode/src/altimate/util/machine-id.ts @@ -37,6 +37,24 @@ function readCappedUtf8(idPath: string): string { } } +/** + * 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 "" + } +} + /** * Read the machine-id from `~/.altimate/machine-id`, minting a new random UUID * with `flag: "wx"` (exclusive create) if the file is absent. 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/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/src/config/config.ts b/packages/opencode/src/config/config.ts index 63284ffce4..60f4399964 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -224,6 +224,13 @@ const LocalInfo = ConfigV1.Info type LocalInfo = ConfigV1.Info // altimate_change end +// altimate_change start — the loader's validation of one parsed source, side-effect free, for the +// telemetry opt-out probe that reads the config before an Instance exists. Throws as loadConfig does. +export function validateLoaded(parsed: unknown, source: string): LocalInfo { + return ConfigParse.schema(LocalInfo, normalizeLoadedConfig(parsed), source) +} +// altimate_change end + // altimate_change start — opencode config includes local learn settings type Info = LocalInfo & { // altimate_change end diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 2a0f8dc550..9c979ef90c 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -142,7 +142,7 @@ function dir(input: ParseSource) { } /** Apply ${VAR}/${VAR:-default}/$${VAR}/{env:VAR} and {file:path} substitutions to config text. */ -async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { +async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error", track = true) { // Single-pass substitution against the ORIGINAL text prevents output of one // pattern being re-matched by another (e.g. {env:A}="${B}" expanding B). // Uses the shared ENV_VAR_PATTERN grammar, adding JSON-escaping for values @@ -186,7 +186,7 @@ async function substitute(text: string, input: ParseSource, missing: "error" | " // the telemetry schema). Dynamic import avoids a circular dep with @/altimate/telemetry (which // imports @/config/config). Matches main's single emit site in substitute(). const { dollarRefs, dollarUnresolved, dollarDefaulted, dollarEscaped, legacyBraceRefs, legacyBraceUnresolved } = stats - if (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0) { + if (track && (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0)) { import("@/altimate/telemetry") .then(({ Telemetry }) => { Telemetry.track({ @@ -258,10 +258,16 @@ async function substitute(text: string, input: ParseSource, missing: "error" | " return out } -/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ -export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error"): Promise { +/** Substitute and parse JSONC text, throwing JsonError on syntax errors. `track: false` skips the + * interpolation telemetry row, for a read that must not emit telemetry (the opt-out probe). */ +export async function parseText( + text: string, + input: ParseSource, + missing: "error" | "empty" = "error", + opts: { track?: boolean } = {}, +): Promise { const configSource = source(input) - text = await substitute(text, input, missing) + text = await substitute(text, input, missing, opts.track !== false) const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) 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/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/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts new file mode 100644 index 0000000000..7be170f674 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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" +import { Telemetry } from "../../../src/altimate/telemetry" + +describe("TelemetryConfig.disabledState (no Instance)", () => { + let dir: string + // The preload points the global config at a per-run temp directory; other suites may have + // seeded files there, so every global source the reader takes is set aside and put back. + const GLOBAL_SOURCES = [ + "config.json", + "opencode.json", + "opencode.jsonc", + "altimate-code.json", + "altimate-code.jsonc", + "config", + ] + // Both names of the inline config (the documented alias is read first). + const INLINE_ENV = ["ALTIMATE_CLI_CONFIG_CONTENT", "OPENCODE_CONFIG_CONTENT"] + const globalFile = () => path.join(Global.Path.config, "altimate-code.json") + const legacyFile = () => path.join(Global.Path.config, "config") + const savedFiles = new Map() + const savedEnv = new Map() + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-")) + for (const key of INLINE_ENV) { + savedEnv.set(key, process.env[key]) + delete process.env[key] + } + for (const name of GLOBAL_SOURCES) { + const file = path.join(Global.Path.config, name) + savedFiles.set(file, existsSync(file) ? readFileSync(file, "utf-8") : undefined) + rmSync(file, { force: true }) + } + }) + afterEach(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + for (const [file, text] of savedFiles) { + if (text === undefined) rmSync(file, { force: true }) + else writeFileSync(file, text) + } + rmSync(dir, { recursive: true, force: true }) + }) + + test("reading a config that uses env interpolation emits no telemetry", async () => { + const track = spyOn(Telemetry, "track") + const savedFlag = process.env.CONFIG_STATE_PROBE_FLAG + try { + process.env.CONFIG_STATE_PROBE_FLAG = "true" + writeFileSync(globalFile(), '{ "telemetry": { "disabled": {env:CONFIG_STATE_PROBE_FLAG} }, "model": "${HOME}" }') + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + // The parser tracks its row behind `import("@/altimate/telemetry")`. That module is loaded, so + // awaiting the same import settles after any such callback queued during the read. + await import("../../../src/altimate/telemetry") + await Promise.resolve() + expect(track.mock.calls.filter(([event]) => event.type === "config_env_interpolation")).toHaveLength(0) + } finally { + if (savedFlag === undefined) delete process.env.CONFIG_STATE_PROBE_FLAG + else process.env.CONFIG_STATE_PROBE_FLAG = savedFlag + track.mockRestore() + } + }) + + test("a later source the loader would reject cannot override an earlier opt-out", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + // Config's schema rejects a numeric model, so the real loader fails on this source. + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: false }, model: 123 }) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a missing {file:} reference makes the read unreadable", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + telemetry: { disabled: false }, + model: `{file:${path.join(dir, "no-such-file")}}`, + }) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("an empty source is skipped but a whitespace-only one is unreadable, as in the loader", async () => { + writeFileSync(globalFile(), "") + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + process.env.OPENCODE_CONFIG_CONTENT = " " + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + delete process.env.OPENCODE_CONFIG_CONTENT + writeFileSync(globalFile(), " \n") + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + 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("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 } }') + 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 source that fails anywhere makes the read unreadable, as it fails Config's whole load", async () => { + writeFileSync(globalFile(), "{ not json") + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } }) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) +}) 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..25735b8a72 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -0,0 +1,438 @@ +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 { 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 } + +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 +} + +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 + let homedirSpy: ReturnType + 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> = {} + /** 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 }) + 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 + 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 = [] + 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) => { + 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` || 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) + 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" }, + }) + } + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + 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 + 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 }) + }) + + 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 + 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 reached = new Promise((r) => (entered["key-A"] = r)) + const pending = TelemetryIdentity.resolve() + await reached // the request is in flight at the gate + 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: `${API}///::acme::key-S` }) + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + 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") + 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", + surface: "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", + surface: "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 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 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/" + writeCreds() + meStatus = 503 + await Telemetry.shutdown() + await Telemetry.init() + expect(await until(() => meCalls().length >= 1)).toBe(true) + expect(Telemetry.identityForTest()).toBeUndefined() + const after = meCalls().length + await Telemetry.flush() + await Telemetry.flush() + 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 + const beforeA = meCalls().length + await Telemetry.flush() + expect(await until(() => meCalls().length > beforeA)).toBe(true) + expect(Telemetry.identityForTest()).toBeUndefined() + meStatus = 200 + // 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 () => { + 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/" + 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", + surface: "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", + surface: "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) + }) +}) 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..fd785c1d08 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/opt-out.test.ts @@ -0,0 +1,420 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, 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" + +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 + /** When set, the next /v2/track POST waits on it before answering. */ + let holdTrack: Promise | undefined + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let authSpy: ReturnType + const saved: Record = {} + const ENV = [ + "OPENCODE_TEST_HOME", + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "ALTIMATE_TELEMETRY_DISABLED", + "OPENCODE_DISABLE_TELEMETRY", + "ALTIMATE_TELEMETRY_FORCE", + "OPENCODE_CONFIG_CONTENT", + ] + + 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 + 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 + 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 ?? "") }) + 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")) { + if (holdTrack) await holdTrack + return new Response("", { status: trackStatus }) + } + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + 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] + } + rmSync(home, { recursive: true, force: true }) + }) + + 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" }) + 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") + // 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(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) + + // 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", + surface: "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("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" + 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("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("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("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("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)) // 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 () => { + 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("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() + expect(statSync(path.join(home, ".altimate")).mode & 0o777).toBe(0o700) + }) + + 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..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" @@ -37,15 +37,20 @@ 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 + // 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] diff --git a/packages/opencode/test/upstream/altimate-features.test.ts b/packages/opencode/test/upstream/altimate-features.test.ts index eb67869dc4..e7c66fa8d9 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\s*\(/) + expect(src).toMatch(/TelemetryIdentity\.resolve(WithOutcome)?\(/) }) }) diff --git a/packages/opencode/test/upstream/bridge-merge-v3.test.ts b/packages/opencode/test/upstream/bridge-merge-v3.test.ts index 5cd201bc01..1b3bd32568 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\s*\(/) }) test("no source file calls Account.active() without await or assignment", async () => {