Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
db50e50
feat: telemetry carries the backend user id and tenant when signed in
ralphstodomingo Oct 15, 2026
a169ffc
test: telemetry no longer calls Account.active()
ralphstodomingo Oct 15, 2026
fc4f80c
fix: a stale identity resolution never overwrites a newer sign-in or …
ralphstodomingo Oct 15, 2026
d275dec
fix: telemetry identity follows credential changes from any path
ralphstodomingo Oct 15, 2026
6be240e
fix: retry a failed identity lookup later, normalise auth-store URLs
ralphstodomingo Oct 15, 2026
cbc3a6d
fix: a credential change during the retry backoff is noticed at once
ralphstodomingo Oct 15, 2026
46b678d
feat: send one final telemetry_opted_out event when telemetry is disa…
ralphstodomingo Oct 15, 2026
15a75c4
fix: opt-out marker reads the machine id bounded and leaves telemetry…
ralphstodomingo Oct 15, 2026
c858470
fix: a stale opt-out send never posts or writes the marker after a sh…
ralphstodomingo Oct 15, 2026
0699f90
chore: merge main into the telemetry identity and opt-out branch
ralphstodomingo Oct 26, 2026
c523841
test: telemetry identity and opt-out fixtures carry `surface`
ralphstodomingo Oct 26, 2026
fbb0e7a
fix: an opt-out send accepted across a shutdown keeps its marker
ralphstodomingo Oct 26, 2026
3b412e0
fix: address cubic review of the identity and opt-out modules
ralphstodomingo Oct 26, 2026
f751255
fix: honour a config telemetry opt-out when init runs without an Inst…
ralphstodomingo Oct 27, 2026
57e99cf
fix: one opt-out event without the account; no polling of a rejected key
ralphstodomingo Oct 27, 2026
57eed56
docs: telemetry page matches the identity and opt-out behaviour
ralphstodomingo Oct 27, 2026
49a3dbe
fix: read the opt-out from the command's project; guard the marker ac…
ralphstodomingo Oct 27, 2026
12b9f6e
fix: the config opt-out probe emits no telemetry of its own
ralphstodomingo Oct 27, 2026
38c790d
test: the probe's no-telemetry check waits on the import, not a timer
ralphstodomingo Oct 27, 2026
78babdd
fix: the opt-out probe rejects any source the config loader would reject
ralphstodomingo Oct 27, 2026
8c76390
fix: the opt-out probe parses a whitespace-only source, as the loader…
ralphstodomingo Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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. |
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -161,20 +168,20 @@ 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

Error messages are truncated to 500 characters and scrubbed of file paths before sending.

### 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

Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> {
return { "x-altimate-client": `altimate-code/${InstallationVersion}` }
}

export async function isConfigured(): Promise<boolean> {
return Filesystem.exists(credentialsPath())
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -224,6 +232,7 @@ export namespace AltimateApi {
headers: {
"x-tenant": instance,
"Content-Type": "application/json",
...clientHeaders(),
},
body: JSON.stringify({ token }),
signal: controller.signal,
Expand Down Expand Up @@ -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) } : {}),
})
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/altimate/plugin/altimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
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(() => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Telemetry.refreshIdentity() is detached, so the following onboarding events are tracked before the new credential identity resolves. Their next flush can omit user_id and tenant; await the refresh before emitting them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/plugin/altimate.ts, line 497:

<comment>`Telemetry.refreshIdentity()` is detached, so the following onboarding events are tracked before the new credential identity resolves. Their next flush can omit `user_id` and `tenant`; await the refresh before emitting them.</comment>

<file context>
@@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
                   })
+                  // 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
</file context>
Suggested change
void Telemetry.refreshIdentity().catch(() => {})
await Telemetry.refreshIdentity().catch(() => {})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined: by design the sign-in never waits on the backend (C8; awaiting would hold the sign-in for up to the 10 s lookup timeout). Events tracked while the lookup is pending take the identity in force at their flush, which is normally the new one; when the lookup is slower than the flush they carry no account, the disclosed R1 behaviour.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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
Expand Down
127 changes: 127 additions & 0 deletions packages/opencode/src/altimate/telemetry/config-state.ts
Original file line number Diff line number Diff line change
@@ -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"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

/** 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Linked worktree / submodule: the probe stops here, the loader walks to /. Blocker: no (edge input; the ordinary layouts are covered and tested).

worktreeOf() returns the first ancestor with a .git entry, file or directory. Instance derives worktree from git rev-parse --git-common-dir (src/project/project.ts:203-210). For a linked worktree or a submodule that is the main repo's .git, so Instance.worktree is not an ancestor of the directory, and FSUtil.up({ stop }) (core/src/fs-util.ts:146-160) never meets its stop: the loader reads opencode.json(c) and .altimate-code/.opencode directories all the way to /, while the probe stops at the linked worktree root.

Verified at this head with a fixture (git worktree add parent/linked, parent/opencode.json = {"telemetry":{"disabled":true}}): ConfigPaths.files("opencode", linked, mainRoot) returns [parent/opencode.json], TelemetryConfig.disabledState(linked) returns "enabled". For that layout the main thread runs enabled (clears the marker, resolves identity) while the worker sends telemetry_opted_out, so the repeated-opt-out symptom returns for this edge only.

Suggested fix: when the .git entry is a file, return undefined (walk to the root, as the loader effectively does), or resolve its gitdir: pointer to the common dir and use its parent. A config-state test using git worktree add would lock it in.

const parent = path.dirname(dir)
if (parent === dir) return undefined
dir = parent
}
}

export async function disabledState(directory: string = process.cwd()): Promise<State> {
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")))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Malformed legacy TOML: the probe says unreadable, the loader ignores the file. Blocker: no (edge input; the divergence fails closed and matches the PR body's "unreadable" promise; safe in a follow-up).

The loader wraps the legacy TOML import in .catch(() => {}) (src/config/config.ts:410-424), so a malformed config file is silently skipped (and never migrated, so it persists). Here a parse error sets unknown, so the main thread keeps telemetry on but never clears the marker nor resolves the account on any launch, while an Instance (the TUI worker, or serve/run once booted) loads fine. The module docstring claims loader parity; on this source the probe is stricter.

Suggested fix: ignore a TOML parse error as the loader does, or move both onto the shared per-source parser proposed in the PR description, plus a parity test for a malformed legacy TOML.

} 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"
Loading
Loading