Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 78 additions & 0 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TraceContext } from "@/altimate/observability/trace-context"
// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped)
import { getOrCreateMachineId } from "@/altimate/util/machine-id"
import { TelemetryIdentity } from "./identity"
import { OptOutMarker } from "./opt-out"
import { createHash, randomUUID } from "crypto"
// altimate_change — first-run health: the event-loop stall monitor labels which thread stalled
import { isMainThread } from "node:worker_threads"
Expand Down Expand Up @@ -65,9 +66,17 @@ const log = Log.create({ service: "telemetry" })
*/
function isAutomatedRun(): boolean {
if (process.env.ALTIMATE_TELEMETRY_FORCE === "true") return false
return underTestRunner()
}

// altimate_change start — the raw test-runner check, without the force flag. The opt-out marker
// reads and writes files in the real home directory, so under a test runner it stays inert unless
// a suite opts in through Telemetry.allowOptOutUnderTestRunner(); the force flag must not reach it.
function underTestRunner(): boolean {
if (process.env.NODE_ENV === "test") return true
return Boolean(process.env.BUN_TEST || process.env.VITEST || process.env.JEST_WORKER_ID)
}
// altimate_change end

// altimate_change start — composed path-masking rules (shared fragments)
// R: one path-run character — anything but whitespace/separators/quotes,
Expand Down Expand Up @@ -668,6 +677,14 @@ export namespace Telemetry {
// The 0.11.0 first-run freeze (an in-process @npmcli/arborist install blocking the loop for minutes)
// was invisible for two months: nothing timed startup or registration, and a blocked loop cannot
// flush, so a killed process left only `session_start`. These events close that gap.
| {
/** The last event an opted-out machine sends; nothing follows it until telemetry is
* turned back on. Built directly in recordOptOut(), never through track(). */
type: "telemetry_opted_out"
timestamp: number
/** Where the opt-out was set: an environment variable or the user config. */
opt_out_source: "env" | "config"
}
| {
type: "startup_ready"
timestamp: number
Expand Down Expand Up @@ -1870,6 +1887,9 @@ export namespace Telemetry {
// once by the stamp comparison; the backoff only throttles retries of the unchanged one.
const IDENTITY_RETRY_MS = 60_000
let identityRetryAt = 0 // 0: no retry pending
// altimate_change — bumped by shutdown(); a detached opt-out send started under an earlier
// lifecycle neither posts nor writes its marker once a later init may have cleared it.
let lifecycleGeneration = 0
let machineId = ""
let sessionId = ""
let projectId = ""
Expand Down Expand Up @@ -2068,6 +2088,7 @@ export namespace Telemetry {
// (previously only wired in test fixtures, silent no-op in product).
if (Flag.truthyEnv("ALTIMATE_TELEMETRY_DISABLED") || Flag.truthyEnv("OPENCODE_DISABLE_TELEMETRY")) {
buffer = []
void recordOptOut("env")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return
Comment thread
ralphstodomingo marked this conversation as resolved.
}
// Config.get() may throw outside Instance context (e.g. CLI middleware
Expand All @@ -2077,6 +2098,7 @@ export namespace Telemetry {
const userConfig = (await Config.get()) as any
if (userConfig.telemetry?.disabled) {
buffer = []
void recordOptOut("config")
return
}
} catch {
Expand Down Expand Up @@ -2112,6 +2134,9 @@ export namespace Telemetry {
// logs + returns ""). No try/catch needed — all paths are handled inside.
machineId = getOrCreateMachineId()
enabled = true
// altimate_change — telemetry is on: forget the opt-out marker, so turning it off again is
// recorded again.
OptOutMarker.clear()
log.info("telemetry initialized", { mode: "appinsights" })
// altimate_change — clear any existing interval before installing a new one. doInit() can
// run more than once per process (init/shutdown cycles per session in prompt.ts), and
Expand Down Expand Up @@ -2175,6 +2200,58 @@ export namespace Telemetry {
}
// altimate_change end

// altimate_change start — the final event of an opted-out machine. Sent once, only from a
// machine that has sent telemetry before (a machine id exists; none is minted here), under the
// same sink rules as doInit(), with the identity cached by an earlier signed-in run (no request
// to resolve it). The marker is written only once the ingest endpoint accepted the event, so an
// offline launch tries again next time; after the marker, nothing is sent until telemetry is
// re-enabled. Not awaited: an opted-out launch must not wait on the network.
const OPT_OUT_TIMEOUT_MS = 3_000
let optOutUnderTestRunner = false
/** Test seam: let a suite that redirects the home directory exercise recordOptOut(). */
export function allowOptOutUnderTestRunner(on: boolean) {
optOutUnderTestRunner = on
}
async function recordOptOut(source: "env" | "config"): Promise<void> {
const lifecycle = lifecycleGeneration
try {
if (underTestRunner() && !optOutUnderTestRunner) return
// The force flag is a developer knob for running telemetry under a test runner; the opt-out
// 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
Comment thread
ralphstodomingo marked this conversation as resolved.
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: await TelemetryIdentity.cached() },
)
// A shutdown since this task started means a later init may own the marker now: neither
// send nor write, and let the next disabled init start over.
if (lifecycle !== lifecycleGeneration) return
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), OPT_OUT_TIMEOUT_MS)
try {
const res = await fetch(cfg.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(envelopes),
signal: controller.signal,
})
if (res.ok && lifecycle === lifecycleGeneration) OptOutMarker.write()
} finally {
clearTimeout(timeout)
}
} 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
Expand Down Expand Up @@ -2546,6 +2623,7 @@ export namespace Telemetry {
// previous account on events; doInit() resolves it again from whatever credential is on disk.
// The generation bump makes any resolution still in flight a no-op when it settles.
identityGeneration++
lifecycleGeneration++
identity = undefined
identityStamp = ""
identityRetryAt = 0
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/src/altimate/telemetry/opt-out.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from "fs"
import path from "path"
import { Global } from "../../global"
import { readExistingMachineId } from "@/altimate/util/machine-id"

/**
* The marker that says this machine already sent its final ``telemetry_opted_out`` event.
*
* A machine that turns telemetry off goes silent, which on the dashboards looks exactly like a
* client that stopped emitting. So the last event an opted-out machine sends is the opt-out
* itself, once; the marker is what makes it once. It lives next to the machine id because both
* describe the machine, not the user, and it is removed when telemetry is turned back on so a
* later opt-out is recorded again. See ``recordOptOut`` in index.ts.
*
* Paths go through ``Global.Path.home`` so a test that sets ``OPENCODE_TEST_HOME`` never reads
* or writes the real home directory; in a real session that is the same directory the machine
* id lives in.
*/
export namespace OptOutMarker {
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 })
fs.writeFileSync(markerPath(), `${new Date().toISOString()}\n`, { mode: 0o600 })
}

/** Best effort: an unremovable marker (read-only directory) must never fail an init. */
export function clear(): void {
try {
fs.rmSync(markerPath(), { force: true })
} catch {
// leave it; the next opt-out is then not recorded again, which is the lesser failure
}
}
}
18 changes: 18 additions & 0 deletions packages/opencode/src/altimate/util/machine-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ function readCappedUtf8(idPath: string): string {
* @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`.
* @returns A v4 UUID string, or `""` if the value is invalid or unreadable.
*/
/**
* Read an existing machine id without minting one. Same regular-file, size-cap and UUID
* checks as {@link getOrCreateMachineId}; returns `""` when the file is absent, unreadable,
* not a regular file, oversized or not a UUID. For callers that must not create a machine
* identity as a side effect (the telemetry opt-out marker).
*/
export function readExistingMachineId(machineIdPath?: string): string {
const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id")
try {
const stat = fs.lstatSync(idPath)
if (!stat.isFile() || stat.size > MAX_BYTES) return ""
const raw = readCappedUtf8(idPath)
return UUID_RE.test(raw) ? raw : ""
} catch {
return ""
}
}

export function getOrCreateMachineId(machineIdPath?: string): string {
const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id")

Expand Down
Loading
Loading