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
111 changes: 104 additions & 7 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,24 @@ export namespace Telemetry {
let reinitPromise: Promise<void> | undefined
// altimate_change — the currently running flush, so shutdown waits rather than racing it.
let inFlightFlush: Promise<void> | undefined
// altimate_change start — first-run privacy: doInit() can run outside Instance context (the TUI
// server worker calls Telemetry.init() at module load, before Instance.provide() has run on that
// thread), where Config.get() throws and is treated as "not disabled" so telemetry doesn't hang
// the worker forever waiting on config it cannot read yet. That is correct for the FIRST init —
// the env var checked above is the only opt-out this thread can honor before config is readable —
// but it must not be the LAST word: a user with `telemetry.disabled: true` in opencode.json and no
// env var would otherwise ship telemetry for the rest of the process, because init() is idempotent
// and every later call (notably session/prompt.ts's init() inside Instance context, where config IS
// readable) just joins the already-settled promise instead of re-evaluating config.
//
// `configOptOutUnverified` tracks whether THIS generation's config check actually ran; when it
// didn't, the next init() call (regardless of caller) re-checks config once config becomes
// readable — see recheckConfigOptOut() and init() below.
let configOptOutUnverified = false
// altimate_change — memoises the in-flight recheck so concurrent init() callers racing a pending
// recheckConfigOptOut() share one Config.get() instead of each starting their own.
let recheckPromise: Promise<void> | undefined
// altimate_change end

// altimate_change start — per-launch correlation id, shared across threads via the environment.
// The TUI worker is spawned after the CLI middleware has already initialised telemetry on the
Expand Down Expand Up @@ -1999,11 +2017,77 @@ export namespace Telemetry {
}
return reinitPromise
}
// altimate_change — late config recheck. See configOptOutUnverified above: if this
// generation's doInit() proceeded without ever reading config (Config.get() threw — no
// Instance context yet), every subsequent init() call is a chance to read it now that the
// caller may be inside Instance context (the prompt loop always is). Once a recheck has
// actually read config, configOptOutUnverified is false and this falls through to the plain
// "join the settled promise" behavior, unchanged from before.
if (initPromise && configOptOutUnverified) {
// altimate_change — generation token. Capture the CURRENT initPromise before any await so a
// shutdown()+re-init() that races this recheck can be detected: doShutdown() clears
// initPromise and a later init() assigns a new one, so by the time Config.get() resolves
// `initPromise !== generation` means this completion belongs to a dead generation and must
// not touch state (see recheckConfigOptOut()). Also capture the memoised promise itself in
// a local so its `.finally` only clears `recheckPromise` if nothing newer has replaced it.
const generation = initPromise
const pending: Promise<void> = (recheckPromise ??= initPromise.then(() => recheckConfigOptOut(generation)))
pending.finally(() => {
if (recheckPromise === pending) recheckPromise = undefined
})
return pending
}
return (initPromise ??= doInit())
// altimate_change end
}

// altimate_change start — see configOptOutUnverified / init() above.
//
// `generation` is the initPromise captured by the caller BEFORE this function's only await. A
// shutdown()+re-init() can complete while Config.get() is still pending here, which clears
// initPromise and then assigns a NEW one; without this check, this stale completion would go on
// to mutate the new generation's state (clearing its buffer/timer/appInsights/enabled flag) as
// if it were still describing the generation it started on. Comparing initPromise to the
// captured token after the await — and before touching ANY state — makes a stale completion a
// no-op instead.
async function recheckConfigOptOut(generation: Promise<void> | undefined) {
if (!enabled) {
// Already disabled (env var, bad connection string, automated-run guard, or an earlier
// successful config read) — nothing live to gate, and nothing config could add.
configOptOutUnverified = false
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return
}
let cfg: any
try {
cfg = await Config.get()
} catch {
// Still unreadable — stay unverified and try again on the next init() call.
return
}
if (initPromise !== generation) return
configOptOutUnverified = false
if (cfg.telemetry?.disabled) {
// Disable for the rest of this generation. initDone stays true so track()'s existing
// "initialized and disabled -> drop" rule takes over; initPromise is left untouched so
// shutdown()/reinit semantics are unaffected by a disable that happens between them.
stopLoopMonitor()
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = undefined
}
enabled = false
appInsights = undefined
buffer = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: The privacy clear here can be silently undone by doFlush's retry write-back

If a flush is mid-request when the recheck disables telemetry (anchor flushes and the 5 s timer flush are fire-and-forget) and that fetch then fails, doFlush's catch re-inserts the events into buffer — the shuttingDown guard (line 2349) does not cover this new disable path, so the just-cleared buffer is refilled with pre-opt-out events. They are inert today (doFlush early-returns on !enabled, and doShutdown()'s unconditional buffer = [] runs before any re-init can re-enable), but that safety relies on a distant coupling rather than a local invariant. Extending the guard to if (shuttingDown || !enabled) return makes the privacy clear airtight, matching the rationale already documented for shuttingDown.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

droppedEvents = 0
log.info("telemetry disabled by config after late init")
}
}
// altimate_change end

async function doInit() {
// altimate_change — reset for this generation; see configOptOutUnverified above. Set back to
// true below only if Config.get() actually fails to read.
configOptOutUnverified = false
try {
// altimate_change — accept "true"/"TRUE"/"1" (case-insensitive) via truthyEnv,
// and honor the OPENCODE_DISABLE_TELEMETRY fallback promised by v0.9.4's CHANGELOG
Expand All @@ -2013,16 +2097,21 @@ export namespace Telemetry {
return
}
// Config.get() may throw outside Instance context (e.g. CLI middleware
// before Instance.provide()). Treat config failures as "not disabled" —
// the env var check above is the early-init escape hatch.
// before Instance.provide(), or the TUI server worker which calls init() at module load).
// Treat config failures as "not disabled" — the env var check above is the only opt-out this
// generation can honor before config is readable. That is not the end of the story: flagging
// configOptOutUnverified lets a later init() call (e.g. the prompt loop's, made inside
// Instance context) read config once and retroactively honor a config-file opt-out —
// see recheckConfigOptOut().
try {
const userConfig = (await Config.get()) as any
if (userConfig.telemetry?.disabled) {
buffer = []
return
}
} catch {
// Config unavailable — proceed with telemetry enabled
// Config unavailable — proceed with telemetry enabled for now; recheck on next init().
configOptOutUnverified = true
}
// App Insights: env var overrides default (for dev/testing), otherwise use the baked-in key.
// The baked-in key is refused under a test runner so suites never ship to the production
Expand Down Expand Up @@ -2272,10 +2361,10 @@ export namespace Telemetry {
log.debug("telemetry flush failed", { status: response.status })
}
} catch {
// altimate_change — no write-back during shutdown. The buffer is cleared a few lines later
// regardless, so re-inserting here does not save these events; it only leaves them to be
// shipped by whatever lifecycle comes next, under a different launch id.
if (shuttingDown) return
// altimate_change — no write-back during shutdown, or once a concurrent config recheck has
// disabled telemetry: the buffer is cleared (or about to be) regardless, so re-inserting
// here would only refill it behind the disable, not save these events.
if (shuttingDown || !enabled) return
// Re-add events that haven't been retried yet to avoid data loss
const retriable = events.filter((e) => !(e as any)._retried)
for (const e of retriable) {
Expand Down Expand Up @@ -2401,6 +2490,14 @@ export namespace Telemetry {
appInsights = undefined
buffer = []
droppedEvents = 0
// altimate_change — reset alongside the rest of this generation's state so a stale "recheck
// config on next init()" flag does not leak into the next init/shutdown cycle. recheckPromise
// is reset too: the generation-token check in recheckConfigOptOut() makes a stale completion
// a no-op, but without clearing this, a NEW init() in the next generation would see a leftover
// (already-settling, now-inert) recheckPromise from the dead generation and memo onto it
// instead of starting its own recheck.
configOptOutUnverified = false
recheckPromise = undefined
sessionId = ""
projectId = ""
machineId = ""
Expand Down
Loading
Loading