Skip to content

Commit f751255

Browse files
fix: honour a config telemetry opt-out when init runs without an Instance
`TelemetryConfig.disabledState()` reads `telemetry.disabled` from the config files. When `Config.get()` throws, init uses it; when the files cannot be read, init keeps the opt-out marker and resolves no identity. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X
1 parent 3b412e0 commit f751255

4 files changed

Lines changed: 250 additions & 9 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import path from "path"
2+
import fs from "fs/promises"
3+
import { existsSync } from "fs"
4+
import * as Effect from "effect/Effect"
5+
import { Flag, env as flagEnv } from "@opencode-ai/core/flag/flag"
6+
import { FSUtil } from "@opencode-ai/core/fs-util"
7+
import { Global } from "../../global"
8+
import { ConfigPaths } from "@/config/paths"
9+
import { ConfigManaged } from "@/config/managed"
10+
11+
/**
12+
* Whether the user config turns telemetry off, read without an Instance.
13+
*
14+
* `Config.get()` needs an Instance, and the CLI's main thread initialises telemetry in middleware
15+
* before one exists, so there it throws. Treating that as "enabled" let an opted-out user's main
16+
* thread clear the opt-out marker and resolve their identity. This reads the same files `Config`
17+
* merges, in the same order (a later source overrides an earlier one), straight from disk:
18+
* the global config files, `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode`
19+
* directories found from `directory`, the inline `OPENCODE_CONFIG_CONTENT`, the managed config
20+
* directory and macOS managed preferences. Remote and account configs are not read (they need
21+
* the network).
22+
*
23+
* - `disabled`: the last source that sets `telemetry.disabled` sets it truthy.
24+
* - `enabled`: no source turns it off.
25+
* - `unreadable`: a source that could not be read or parsed comes after the last one that set
26+
* the value, so the answer is unknown. Callers fail closed for anything an opt-out forbids.
27+
*/
28+
export type State = "disabled" | "enabled" | "unreadable"
29+
30+
const DIR_FILES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"]
31+
const GLOBAL_FILES = ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"]
32+
33+
/** The git root above `directory`, which `Config` uses as the stop for its upward search. */
34+
function worktreeOf(directory: string): string | undefined {
35+
let dir = path.resolve(directory)
36+
while (true) {
37+
if (existsSync(path.join(dir, ".git"))) return dir
38+
const parent = path.dirname(dir)
39+
if (parent === dir) return undefined
40+
dir = parent
41+
}
42+
}
43+
44+
export async function disabledState(directory: string = process.cwd()): Promise<State> {
45+
try {
46+
let value: boolean | undefined
47+
let unknown = false
48+
const apply = (data: unknown) => {
49+
const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled
50+
if (disabled === undefined) return
51+
value = Boolean(disabled)
52+
unknown = false
53+
}
54+
const readFile = async (file: string) => {
55+
let text: string
56+
try {
57+
text = await fs.readFile(file, "utf-8")
58+
} catch (err) {
59+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true
60+
return
61+
}
62+
try {
63+
apply(await ConfigPaths.parseText(text, file, "empty"))
64+
} catch {
65+
unknown = true
66+
}
67+
}
68+
const readText = async (text: string, source: string, dir: string) => {
69+
try {
70+
apply(await ConfigPaths.parseText(text, { source, dir }, "empty"))
71+
} catch {
72+
unknown = true
73+
}
74+
}
75+
76+
for (const file of GLOBAL_FILES) await readFile(path.join(Global.Path.config, file))
77+
if (Flag.OPENCODE_CONFIG) await readFile(Flag.OPENCODE_CONFIG)
78+
79+
const worktree = worktreeOf(directory)
80+
const located = await Effect.runPromise(
81+
Effect.all([
82+
Flag.OPENCODE_DISABLE_PROJECT_CONFIG
83+
? Effect.succeed([] as string[])
84+
: ConfigPaths.files("opencode", directory, worktree),
85+
ConfigPaths.directories(directory, worktree),
86+
]).pipe(Effect.provide(FSUtil.defaultLayer)),
87+
)
88+
const [projectFiles, directories] = located
89+
for (const file of projectFiles) await readFile(file)
90+
for (const dir of directories) {
91+
if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
92+
for (const file of DIR_FILES) await readFile(path.join(dir, file))
93+
}
94+
}
95+
96+
const inline = flagEnv("OPENCODE_CONFIG_CONTENT")
97+
if (inline) await readText(inline, "OPENCODE_CONFIG_CONTENT", directory)
98+
99+
const managedDir = ConfigManaged.managedConfigDir()
100+
if (existsSync(managedDir)) for (const file of DIR_FILES) await readFile(path.join(managedDir, file))
101+
const managed = await ConfigManaged.readManagedPreferences().catch(() => {
102+
unknown = true
103+
return undefined
104+
})
105+
if (managed) await readText(managed.text, managed.source, path.dirname(managed.source))
106+
107+
if (unknown) return "unreadable"
108+
return value ? "disabled" : "enabled"
109+
} catch {
110+
return "unreadable"
111+
}
112+
}
113+
114+
export * as TelemetryConfig from "./config-state"

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TraceContext } from "@/altimate/observability/trace-context"
77
import { getOrCreateMachineId } from "@/altimate/util/machine-id"
88
import { TelemetryIdentity } from "./identity"
99
import { OptOutMarker } from "./opt-out"
10+
import { TelemetryConfig } from "./config-state"
1011
import { createHash, randomUUID } from "crypto"
1112
// altimate_change — first-run health: the event-loop stall monitor labels which thread stalled
1213
import { isMainThread } from "node:worker_threads"
@@ -1909,6 +1910,9 @@ export namespace Telemetry {
19091910
let lifecycleGeneration = 0
19101911
let markerGeneration = 0
19111912
// altimate_change end
1913+
// altimate_change — false when this init could not read the user config: an opted-out user may be
1914+
// behind it, so no identity is resolved (at init, at a flush or on refresh) until an init that can.
1915+
let identityAllowed = true
19121916
let machineId = ""
19131917
let sessionId = ""
19141918
let projectId = ""
@@ -2111,8 +2115,11 @@ export namespace Telemetry {
21112115
return
21122116
}
21132117
// Config.get() may throw outside Instance context (e.g. CLI middleware
2114-
// before Instance.provide()). Treat config failures as "not disabled" —
2115-
// the env var check above is the early-init escape hatch.
2118+
// before Instance.provide()). Telemetry stays enabled then, as before.
2119+
// altimate_change start — but the opt-out still counts: read `telemetry.disabled` from the
2120+
// config files directly, and when even that fails, skip what an opt-out forbids (clearing the
2121+
// opt-out marker, resolving the account) while ordinary rows keep flowing.
2122+
let configUnreadable = false
21162123
try {
21172124
const userConfig = (await Config.get()) as any
21182125
if (userConfig.telemetry?.disabled) {
@@ -2121,8 +2128,15 @@ export namespace Telemetry {
21212128
return
21222129
}
21232130
} catch {
2124-
// Config unavailable — proceed with telemetry enabled
2131+
const state = await TelemetryConfig.disabledState()
2132+
if (state === "disabled") {
2133+
buffer = []
2134+
void recordOptOut("config")
2135+
return
2136+
}
2137+
configUnreadable = state === "unreadable"
21252138
}
2139+
// altimate_change end
21262140
// App Insights: env var overrides default (for dev/testing), otherwise use the baked-in key.
21272141
// The baked-in key is refused under a test runner so suites never ship to the production
21282142
// resource. Note this deliberately does NOT key on CI — see isAutomatedRun.
@@ -2142,21 +2156,25 @@ export namespace Telemetry {
21422156
return
21432157
}
21442158
appInsights = cfg
2159+
identityAllowed = !configUnreadable
21452160
// altimate_change start — identity for the funnel join: the backend user uuid and tenant from
21462161
// the stored Altimate credential, cached per credential so this costs no network unless the
21472162
// credential changed. Replaces the hashed account email. Not awaited: events flushed before it
21482163
// resolves carry only the machine id, and telemetry never waits on the backend.
2149-
void resolveIdentity()
2164+
if (identityAllowed) void resolveIdentity()
21502165
// altimate_change end
21512166
// altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts.
21522167
// Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized:
21532168
// logs + returns ""). No try/catch needed — all paths are handled inside.
21542169
machineId = getOrCreateMachineId()
21552170
enabled = true
21562171
// altimate_change — telemetry is on: forget the opt-out marker, so turning it off again is
2157-
// recorded again. The bump first, so a send still in flight never recreates it.
2158-
markerGeneration++
2159-
OptOutMarker.clear()
2172+
// recorded again. The bump first, so a send still in flight never recreates it. Not when the
2173+
// config could not be read: the user may have opted out there.
2174+
if (!configUnreadable) {
2175+
markerGeneration++
2176+
OptOutMarker.clear()
2177+
}
21602178
log.info("telemetry initialized", { mode: "appinsights" })
21612179
// altimate_change — clear any existing interval before installing a new one. doInit() can
21622180
// run more than once per process (init/shutdown cycles per session in prompt.ts), and
@@ -2206,7 +2224,7 @@ export namespace Telemetry {
22062224
* Does nothing while telemetry is disabled. */
22072225
export async function refreshIdentity(): Promise<void> {
22082226
// Opted out (or not initialised): no lookup; an enabled init resolves from the stored credential.
2209-
if (!enabled) return
2227+
if (!enabled || !identityAllowed) return
22102228
await flush()
22112229
identity = undefined
22122230
await resolveIdentity({ refresh: true })
@@ -2491,7 +2509,7 @@ export namespace Telemetry {
24912509
// change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed).
24922510
const who = identity
24932511
const mid = machineId
2494-
if (enabled) {
2512+
if (enabled && identityAllowed) {
24952513
const stamp = TelemetryIdentity.credentialStamp()
24962514
const retryDue = identityRetryAt !== 0 && Date.now() >= identityRetryAt
24972515
if (stamp !== identityStamp || retryDue) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
3+
import os from "os"
4+
import path from "path"
5+
import { Global } from "../../../src/global"
6+
import { TelemetryConfig } from "../../../src/altimate/telemetry/config-state"
7+
8+
describe("TelemetryConfig.disabledState (no Instance)", () => {
9+
let dir: string
10+
let savedContent: string | undefined
11+
const globalFile = () => path.join(Global.Path.config, "altimate-code.json")
12+
13+
beforeEach(() => {
14+
dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-"))
15+
savedContent = process.env.OPENCODE_CONFIG_CONTENT
16+
delete process.env.OPENCODE_CONFIG_CONTENT
17+
})
18+
afterEach(() => {
19+
if (savedContent === undefined) delete process.env.OPENCODE_CONFIG_CONTENT
20+
else process.env.OPENCODE_CONFIG_CONTENT = savedContent
21+
rmSync(globalFile(), { force: true })
22+
rmSync(dir, { recursive: true, force: true })
23+
})
24+
25+
test("nothing sets it: enabled", async () => {
26+
expect(await TelemetryConfig.disabledState(dir)).toBe("enabled")
27+
})
28+
29+
test("the global config turns it off", async () => {
30+
writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } }))
31+
expect(await TelemetryConfig.disabledState(dir)).toBe("disabled")
32+
})
33+
34+
test("a project's .altimate-code directory turns it off", async () => {
35+
mkdirSync(path.join(dir, ".altimate-code"))
36+
writeFileSync(path.join(dir, ".altimate-code", "altimate-code.json"), '{ "telemetry": { "disabled": true } }')
37+
expect(await TelemetryConfig.disabledState(dir)).toBe("disabled")
38+
})
39+
40+
test("a later source overrides an earlier one, as in Config", async () => {
41+
writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } }))
42+
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: false } })
43+
expect(await TelemetryConfig.disabledState(dir)).toBe("enabled")
44+
})
45+
46+
test("an unparseable source after the last setting makes it unreadable", async () => {
47+
writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: false } }))
48+
process.env.OPENCODE_CONFIG_CONTENT = "{ not json"
49+
expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable")
50+
})
51+
52+
test("a later source that sets the value settles an earlier unreadable one", async () => {
53+
writeFileSync(globalFile(), "{ not json")
54+
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } })
55+
expect(await TelemetryConfig.disabledState(dir)).toBe("disabled")
56+
})
57+
})

‎packages/opencode/test/altimate/telemetry/opt-out.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
33
import os from "os"
44
import path from "path"
55
import { Auth } from "../../../src/auth"
6+
import { Config } from "../../../src/config/config"
67
import { Telemetry } from "../../../src/altimate/telemetry"
78
import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity"
89
import { OptOutMarker } from "../../../src/altimate/telemetry/opt-out"
@@ -36,6 +37,7 @@ describe("telemetry opt-out marker", () => {
3637
"ALTIMATE_TELEMETRY_DISABLED",
3738
"OPENCODE_DISABLE_TELEMETRY",
3839
"ALTIMATE_TELEMETRY_FORCE",
40+
"OPENCODE_CONFIG_CONTENT",
3941
]
4042

4143
const optOuts = () =>
@@ -228,6 +230,56 @@ describe("telemetry opt-out marker", () => {
228230
expect(optOuts()).toHaveLength(1)
229231
})
230232

233+
test("an init that cannot read the config keeps the marker and resolves no account", async () => {
234+
writeMachineId()
235+
writeCreds()
236+
OptOutMarker.write()
237+
const configSpy = spyOn(Config, "get")
238+
try {
239+
// The main thread's init: no Instance, so Config.get() throws, and the files cannot be read.
240+
configSpy.mockRejectedValue(new Error("InstanceRef not provided"))
241+
process.env.OPENCODE_CONFIG_CONTENT = "{ not json"
242+
await Telemetry.shutdown()
243+
await Telemetry.init()
244+
expect(Telemetry.isEnabled()).toBe(true) // ordinary rows keep flowing, as before
245+
await Telemetry.flush()
246+
await Telemetry.refreshIdentity()
247+
await new Promise((r) => setTimeout(r, 50))
248+
expect(OptOutMarker.exists()).toBe(true)
249+
expect(calls.filter((c) => c.url.endsWith("/users/me"))).toHaveLength(0)
250+
expect(Telemetry.identityForTest()).toBeUndefined()
251+
expect(optOuts()).toHaveLength(0)
252+
253+
// The worker's init, inside an Instance: the config says disabled, and the marker held.
254+
delete process.env.OPENCODE_CONFIG_CONTENT
255+
configSpy.mockResolvedValue({ telemetry: { disabled: true } } as never)
256+
await Telemetry.shutdown()
257+
await Telemetry.init()
258+
await new Promise((r) => setTimeout(r, 50))
259+
expect(Telemetry.isEnabled()).toBe(false)
260+
expect(optOuts()).toHaveLength(0)
261+
expect(OptOutMarker.exists()).toBe(true)
262+
} finally {
263+
configSpy.mockRestore()
264+
}
265+
})
266+
267+
test("an init without an Instance honours a config opt-out read from the files", async () => {
268+
writeMachineId()
269+
const configSpy = spyOn(Config, "get").mockRejectedValue(new Error("InstanceRef not provided"))
270+
try {
271+
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } })
272+
await Telemetry.shutdown()
273+
await Telemetry.init()
274+
expect(Telemetry.isEnabled()).toBe(false)
275+
expect(await waitFor(() => optOuts().length === 1)).toBe(true)
276+
expect(optOuts()[0].data.baseData.properties.opt_out_source).toBe("config")
277+
expect(await waitFor(() => OptOutMarker.exists())).toBe(true)
278+
} finally {
279+
configSpy.mockRestore()
280+
}
281+
})
282+
231283
test("turning telemetry back on clears the marker", async () => {
232284
writeMachineId()
233285
OptOutMarker.write()

0 commit comments

Comments
 (0)