Repository navigation
feat: carry the signed-in user and tenant on telemetry, and send a final event on opt-out #1501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
db50e50
a169ffc
fc4f80c
d275dec
6be240e
cbc3a6d
46b678d
15a75c4
c858470
0699f90
c523841
fbb0e7a
3b412e0
f751255
57e99cf
57eed56
49a3dbe
12b9f6e
38c790d
78babdd
8c76390
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"] | ||
|
coderabbitai[bot] marked this conversation as resolved.
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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR — Linked worktree / submodule: the probe stops here, the loader walks to
Verified at this head with a fixture ( Suggested fix: when the |
||
| 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"))) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR — Malformed legacy TOML: the probe says The loader wraps the legacy TOML import in 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" | ||
There was a problem hiding this comment.
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 omituser_idandtenant; await the refresh before emitting them.Prompt for AI agents
There was a problem hiding this comment.
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.