-
Notifications
You must be signed in to change notification settings - Fork 134
fix(telemetry): re-check the config-file opt-out once config is readable #1295
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| 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 = [] | ||
|
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. [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, Reply with |
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) { | ||
|
|
@@ -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 = "" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.