Skip to content

Commit 23e5903

Browse files
feat: [AI-8448] count installs from the shell installers, not just npm (#1096)
1 parent 8e76c90 commit 23e5903

13 files changed

Lines changed: 918 additions & 24 deletions

File tree

docs/docs/reference/security-faq.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,16 @@ A single `first_launch` event is sent containing only:
145145

146146
- The installed version (e.g., "0.5.9")
147147
- Whether this is a fresh install or upgrade (boolean)
148+
- Which installer was used (`curl`, `powershell`, `npm`, `vscode-extension`, `local`, or `unknown` — what every upgrade from a version predating this field reports)
148149
- Your anonymous machine ID (random UUID)
149150

150151
No code, queries, file paths, or personal information is included. This event helps us understand adoption and is fully opt-out-able.
151152

153+
The install scripts (`altimate.sh/install`, `install.ps1`), the npm postinstall, and the VS Code extension's installer send nothing themselves and contact no telemetry endpoint. They only record the version and installer name to a local file that the CLI reads on its next run, so the opt-out above decides whether anything is ever transmitted.
154+
155+
!!! warning "One caveat on the config-file opt-out"
156+
The environment variables (`ALTIMATE_TELEMETRY_DISABLED`, `OPENCODE_DISABLE_TELEMETRY`) are always honoured. The `telemetry.disabled` **config key** is read during telemetry startup, which can run before the CLI's config is resolvable — and in that case startup currently proceeds with telemetry enabled. A user who has opted out via the config key alone may therefore still have this event transmitted. Use an environment variable if you need a guarantee.
157+
152158
## What happens when I authenticate via a well-known URL?
153159

154160
When you run `altimate auth login <url>`, the CLI fetches `<url>/.well-known/altimate-code` to discover the server's auth command. Before executing anything:

docs/docs/reference/telemetry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ We collect the following categories of events:
4242
| `feature_suggestion` | A post-connection feature suggestion is shown (suggestion_type, suggestions_shown, warehouse_type — no user input) |
4343
| `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) |
4444
| `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) |
45-
| `first_launch` | Fired once on first CLI run after installation. Contains version and is_upgrade flag. No PII. |
45+
| `first_launch` | Fired once on the first CLI run after an install or upgrade, triggered by a marker file the installer wrote — the installers themselves send nothing and contact no telemetry endpoint. Contains the installed version, `is_upgrade`, and `install_method` (`curl`, `powershell`, `npm`, `vscode-extension`, `local` for `install --binary`, or `unknown` for markers written before the field existed). `vscode-extension` starts appearing only once an extension build containing the marker write ships, so a zero share for it means the extension has not rolled out yet rather than no extension installs. No PII. **Reading `is_upgrade`:** it means "this machine had run altimate-code before", probed as whether `~/.altimate/machine-id` already existed — *not* "a binary was already present". A reinstall onto a machine that ever ran the CLI reports `is_upgrade: true`, and `altimate uninstall` leaves `machine-id` in place, so a metric excluding upgrades counts installs **per previously-unseen machine** and undercounts reinstalls onto known ones. (`is_upgrade` is a boolean in the event schema; it arrives in Application Insights `customDimensions` as a string, so KQL filters read `tostring(customDimensions.is_upgrade) != "true"`.) Delivery is at-most-once: the marker is deleted before the event flushes, so a process that dies first loses that install rather than re-firing it every launch. Local `--binary` installs report `version: "local"`. |
4646
| `task_outcome_signal` | Behavioral quality signal at session end — accepted, error, abandoned, or cancelled. Includes tool count, step count, duration, and last tool category. No user content. |
4747
| `task_classified` | Intent classification of the first user message using keyword matching — category (e.g. `debug_dbt`, `write_sql`, `optimize_query`), confidence score, and detected warehouse type. No user text is sent — only the classified category. |
4848
| `tool_chain_outcome` | Aggregated tool execution sequence at session end — ordered tool names (capped at 50), error count, recovery count, final outcome, duration, and cost. No tool arguments or outputs. |

install

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,11 +487,55 @@ install_from_binary() {
487487
chmod 755 "$dest_path"
488488
}
489489

490+
# Write the same post-install marker that npm's postinstall.mjs writes, so the
491+
# CLI emits its `first_launch` telemetry event on the next run. Without this the
492+
# curl install path — the one advertised at altimate.sh/install — produces no
493+
# install event at all, and every curl user is invisible in install metrics.
494+
#
495+
# The path MUST match welcome.ts's data-dir resolution ($XDG_DATA_HOME, falling
496+
# back to ~/.local/share) on every platform, including Windows: the CLI reads it
497+
# via Node's os.homedir() and never consults %LOCALAPPDATA%.
498+
#
499+
# No network call and no identifier is written here — this only hands the CLI the
500+
# version it was installed at. Whether anything is ever sent remains entirely up
501+
# to the CLI's existing telemetry opt-out gates.
502+
# $1 — install_method to record. Must be a value in the CLI's allowlist
503+
# (packages/opencode/src/cli/welcome.ts); anything else reports as "unknown".
504+
write_install_marker() {
505+
local marker_source="$1"
506+
local data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/altimate-code"
507+
# An empty marker is deleted unread by the CLI, so fall back to "unknown"
508+
# rather than losing the install: $specific_version is empty whenever the
509+
# GitHub API could not be reached (see check_version).
510+
local marker_version="${specific_version:-unknown}"
511+
mkdir -p "$data_dir" 2>/dev/null || return 0
512+
# Companion first, trigger last: the CLI returns early unless .installed-version
513+
# exists, then consumes .install-source. Trigger-first would let a CLI starting in
514+
# between report install_method "unknown", and a truncated .installed-version is
515+
# deleted unread — losing the install rather than just its attribution.
516+
printf '%s' "$marker_source" > "$data_dir/.install-source" 2>/dev/null || return 0
517+
# The trigger is published atomically. Companion-first alone only closes the
518+
# "attribution lost" window; a plain redirect truncates before filling, so a CLI
519+
# starting mid-write can still observe an EMPTY .installed-version, which it
520+
# deletes unread — losing the install itself. mv within one directory is atomic.
521+
local tmp="$data_dir/.installed-version.$$"
522+
printf '%s' "${marker_version#v}" > "$tmp" 2>/dev/null || return 0
523+
mv -f "$tmp" "$data_dir/.installed-version" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
524+
}
525+
490526
if [ -n "$binary_path" ]; then
491527
install_from_binary
528+
# Attributed as "local", not "curl": --binary installs a file the caller already
529+
# had (dev build, air-gapped artifact) and sets specific_version="local", so
530+
# folding it into the curl metric would misreport both source and version.
531+
# Still recorded — it is a real install — just not a curl one.
532+
write_install_marker "local"
492533
else
493534
check_version
494535
download_and_install
536+
# Only reached when an install actually happened: check_version exits 0 early
537+
# when the requested version is already present.
538+
write_install_marker "curl"
495539
fi
496540

497541

install.ps1

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,71 @@ if (-not $needsBaseline) {
304304
}
305305
}
306306

307+
# ---------------------------------------------------------------------------
308+
# Post-install marker (install telemetry)
309+
# ---------------------------------------------------------------------------
310+
# Mirrors npm's postinstall.mjs (and ./install's write_install_marker) so the CLI
311+
# emits its `first_launch` event on the next run; without it this install path is
312+
# invisible in install metrics.
313+
#
314+
# A function, not inline code, so the Pester suite can AST-extract and execute it
315+
# against a temp profile the same way it does Test-Checksum. The subprocess tests
316+
# deliberately stop the installer before this point, so inline code here would have
317+
# no runtime coverage on the riskiest of the writers.
318+
function Write-InstallMarker {
319+
param([string]$Version)
320+
321+
# The directory MUST match welcome.ts's resolution - $XDG_DATA_HOME, else
322+
# <home>\.local\share - because the CLI reads it through Node's os.homedir() and
323+
# never looks at %LOCALAPPDATA%. Writing to LOCALAPPDATA here would be silently
324+
# ignored at read time.
325+
#
326+
# No network call and no identifier is written; only the installed version is
327+
# recorded. The CLI's existing telemetry opt-out gates still decide whether
328+
# anything is ever sent.
329+
#
330+
# EVERYTHING is inside the try, path computation included. $ErrorActionPreference is
331+
# "Stop", and Join-Path resolves provider-qualified paths - so a null or empty
332+
# $env:USERPROFILE (pwsh on non-Windows, a stripped service profile) or an
333+
# XDG_DATA_HOME naming a non-existent PSDrive raises a TERMINATING error. Computed
334+
# outside the try, that error would abort the installer after the binary is placed
335+
# but before the PATH registry write and the "Get started" output, leaving the user
336+
# with an installed binary that is not on PATH. [IO.Path]::Combine also keeps
337+
# PSDrive resolution out of it entirely.
338+
try {
339+
$dataRoot = if ($env:XDG_DATA_HOME) { $env:XDG_DATA_HOME } else { [IO.Path]::Combine($env:USERPROFILE, ".local", "share") }
340+
$dataDir = [IO.Path]::Combine($dataRoot, "altimate-code")
341+
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
342+
# The CLI deletes an empty marker without reporting, so fall back to "unknown"
343+
# when the version could not be resolved (GitHub API unreachable).
344+
$markerVersion = if ($Version) { $Version -replace '^v', '' } else { "unknown" }
345+
# -NoNewline: the CLI trims, but keep the file byte-identical to the npm path.
346+
#
347+
# -Encoding ascii, not utf8: the documented entrypoint is `powershell -c "irm ... | iex"`,
348+
# i.e. Windows PowerShell 5.1, where `-Encoding utf8` prepends a UTF-8 BOM. Both values are
349+
# ASCII by construction, so ascii is lossless here and cannot emit one. The CLI's .trim()
350+
# happens to strip a leading BOM (U+FEFF is JS whitespace), but the install-source value is
351+
# matched against a fixed allowlist and must not depend on that.
352+
#
353+
# Companion first, trigger last. The CLI returns early unless .installed-version
354+
# exists, then consumes .install-source - so writing the trigger first would let a
355+
# CLI starting in between report install_method "unknown". Set-Content also
356+
# truncates before writing, and an empty .installed-version is deleted unread,
357+
# which would lose the install outright.
358+
Set-Content -Path ([IO.Path]::Combine($dataDir, ".install-source")) -Value "powershell" -NoNewline -Encoding ascii
359+
# Trigger published atomically: Set-Content truncates before writing, so a CLI
360+
# starting mid-write could observe an EMPTY .installed-version and delete it
361+
# unread, losing the install. Move-Item within one directory is atomic.
362+
$tmpMarker = [IO.Path]::Combine($dataDir, ".installed-version.tmp")
363+
Set-Content -Path $tmpMarker -Value $markerVersion -NoNewline -Encoding ascii
364+
Move-Item -Force -Path $tmpMarker -Destination ([IO.Path]::Combine($dataDir, ".installed-version"))
365+
} catch {
366+
# Non-fatal - a missing marker only costs us the install event, never the install.
367+
}
368+
}
369+
370+
Write-InstallMarker -Version $specificVersion
371+
307372
# ---------------------------------------------------------------------------
308373
# PATH (user scope, via registry + broadcast)
309374
# ---------------------------------------------------------------------------

packages/opencode/script/postinstall.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,20 @@ function writeUpgradeMarker(version) {
238238
const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
239239
const dataDir = path.join(xdgData, "altimate-code")
240240
fs.mkdirSync(dataDir, { recursive: true })
241-
fs.writeFileSync(path.join(dataDir, ".installed-version"), version.replace(/^v/, ""))
241+
// Companion first, trigger last. `.installed-version` is what the CLI keys on:
242+
// it returns early unless that file exists, then consumes `.install-source`.
243+
// Trigger-first left two windows — a CLI starting in between reports
244+
// install_method "unknown", and writeFileSync truncates before writing, so a
245+
// reader could observe an EMPTY `.installed-version` and delete it unread,
246+
// losing the install outright. Matches `install` and `install.ps1`.
247+
fs.writeFileSync(path.join(dataDir, ".install-source"), "npm")
248+
// Trigger published atomically: writeFileSync truncates before filling, so a CLI
249+
// starting mid-write could observe an EMPTY `.installed-version` and delete it
250+
// unread, losing the install. renameSync within one directory is atomic.
251+
const versionPath = path.join(dataDir, ".installed-version")
252+
const tmpPath = `${versionPath}.${process.pid}.tmp`
253+
fs.writeFileSync(tmpPath, version.replace(/^v/, ""))
254+
fs.renameSync(tmpPath, versionPath)
242255
} catch {
243256
// Non-fatal — the CLI just won't show a welcome banner
244257
}

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,11 @@ export namespace Telemetry {
474474
session_id: string
475475
version: string
476476
is_upgrade: boolean
477+
// altimate_change — which installer wrote the marker. Recorded by the
478+
// installer itself; "unknown" when the marker predates this field or the
479+
// source file was unreadable. Without it, curl and npm installs are
480+
// indistinguishable in the same metric.
481+
install_method: "curl" | "powershell" | "npm" | "vscode-extension" | "local" | "unknown"
477482
}
478483
// altimate_change end
479484
// altimate_change start — telemetry for skill management operations

0 commit comments

Comments
 (0)