fix(vscode): close runtime telemetry consent gap with the CLI

The previous `watchTelemetryState` fix updated the webview UI in real
time but left the CLI subprocess's PostHog client stuck on its
spawn-time `KILO_TELEMETRY_LEVEL` value. A user who started VS Code
with telemetry off and toggled it on at runtime saw the thumbs UI
appear (good) but every webview event was silently dropped at the
CLI's `Client.capture()` gate (bad).

Add a runtime sync channel:

- New `POST /telemetry/setEnabled` Hono route on the CLI server that
  calls `Telemetry.setEnabled(enabled)` to flip the `posthog-node`
  client's opt state.
- New `TelemetryProxy.setEnabled(enabled)` method that POSTs to it,
  using the same fire-and-forget pattern as `capture`.
- Extension calls `telemetry.setEnabled(vscode.env.isTelemetryEnabled)`
  immediately after `telemetry.configure(...)` on every `connected`
  state change, so a freshly-spawned CLI gets corrected even when its
  spawn-time env var is stale.
- Extension subscribes to `vscode.env.onDidChangeTelemetryEnabled` to
  forward runtime consent changes to the CLI as they happen.

All three changes live in Kilo-owned files (the route is already a
`kilocode_change - new file`, and the extension is Kilo-only). Zero
upstream OpenCode merge surface.

Closes #9872 fully (the previous `d67c5e307c` covered only the webview
UI).
This commit is contained in:
Josh Lambert
2026-05-06 12:42:27 -04:00
parent 0d64636b89
commit bef0183738
5 changed files with 163 additions and 30 deletions
+13
View File
@@ -57,6 +57,11 @@ export function activate(context: vscode.ExtensionContext) {
const config = connectionService.getServerConfig()
if (config) {
telemetry.configure(config.baseUrl, config.password)
// Sync the CLI's PostHog client with the current consent state. The
// CLI reads KILO_TELEMETRY_LEVEL once at spawn, so without this call
// a fresh CLI started while VS Code telemetry was off would stay
// opted out for the rest of the session.
telemetry.setEnabled(vscode.env.isTelemetryEnabled)
}
try {
remoteService.setClient(connectionService.getClient())
@@ -72,6 +77,14 @@ export function activate(context: vscode.ExtensionContext) {
}
})
// Propagate runtime telemetry consent changes to the CLI subprocess so its
// PostHog client stays in sync with the user's VS Code telemetry setting.
context.subscriptions.push(
vscode.env.onDidChangeTelemetryEnabled((enabled) => {
telemetry.setEnabled(enabled)
}),
)
// Prewarm the CLI backend early so autocomplete is ready before first editor use.
ensureBackendForAutocomplete(connectionService)
@@ -61,6 +61,26 @@ export class TelemetryProxy {
}).catch((err) => console.error("[Kilo New] Telemetry capture failed:", err))
}
/**
* Propagate runtime telemetry consent changes to the CLI. The CLI subprocess
* reads `KILO_TELEMETRY_LEVEL` once at spawn — without this call, toggling
* VS Code telemetry consent leaves the CLI's PostHog client stuck on its
* spawn-time state until the process restarts.
*/
setEnabled(enabled: boolean) {
if (!this.url || !this.password) return
const auth = buildTelemetryAuthHeader(this.password)
fetch(`${this.url}/telemetry/setEnabled`, {
method: "POST",
headers: {
Authorization: auth,
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
}).catch((err) => console.error("[Kilo New] Telemetry setEnabled failed:", err))
}
/**
* No-op — the CLI server handles PostHog shutdown.
*/
@@ -7,39 +7,68 @@ import { lazy } from "@/util/lazy"
import { errors } from "../../error"
export const TelemetryRoutes = lazy(() =>
new Hono().post(
"/capture",
describeRoute({
summary: "Capture telemetry event",
description: "Forward a telemetry event to PostHog via kilo-telemetry.",
operationId: "telemetry.capture",
responses: {
200: {
description: "Event captured",
content: {
"application/json": {
schema: resolver(z.boolean()),
new Hono()
.post(
"/capture",
describeRoute({
summary: "Capture telemetry event",
description: "Forward a telemetry event to PostHog via kilo-telemetry.",
operationId: "telemetry.capture",
responses: {
200: {
description: "Event captured",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
...errors(400),
},
}),
validator(
"json",
z.object({
event: z.string().meta({ description: "Event name" }),
properties: z.record(z.string(), z.any()).optional().meta({ description: "Event properties" }),
}),
validator(
"json",
z.object({
event: z.string().meta({ description: "Event name" }),
properties: z.record(z.string(), z.any()).optional().meta({ description: "Event properties" }),
}),
),
async (c) => {
const body = c.req.valid("json")
try {
Telemetry.track(body.event as any, body.properties)
} catch {
// fire-and-forget: swallow errors
}
return c.json(true)
},
)
.post(
"/setEnabled",
describeRoute({
summary: "Set PostHog telemetry enabled state",
description:
"Update the PostHog client's opt-in/out state at runtime. " +
"The CLI reads KILO_TELEMETRY_LEVEL once at spawn — this route lets clients " +
"(e.g. the VS Code extension) propagate runtime telemetry consent changes.",
operationId: "telemetry.setEnabled",
responses: {
200: {
description: "State updated",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", z.object({ enabled: z.boolean() })),
async (c) => {
const body = c.req.valid("json")
Telemetry.setEnabled(body.enabled)
return c.json(true)
},
),
async (c) => {
const body = c.req.valid("json")
try {
Telemetry.track(body.event as any, body.properties)
} catch {
// fire-and-forget: swallow errors
}
return c.json(true)
},
),
)
+41
View File
@@ -221,6 +221,8 @@ import type {
SyncStartResponses,
TelemetryCaptureErrors,
TelemetryCaptureResponses,
TelemetrySetEnabledErrors,
TelemetrySetEnabledResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -4936,6 +4938,45 @@ export class Telemetry extends HeyApiClient {
},
})
}
/**
* Set PostHog telemetry enabled state
*
* Update the PostHog client's opt-in/out state at runtime. The CLI reads KILO_TELEMETRY_LEVEL once at spawn — this route lets clients (e.g. the VS Code extension) propagate runtime telemetry consent changes.
*/
public setEnabled<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
enabled?: boolean
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "enabled" },
],
},
],
)
return (options?.client ?? this.client).post<TelemetrySetEnabledResponses, TelemetrySetEnabledErrors, ThrowOnError>(
{
url: "/telemetry/setEnabled",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
},
)
}
}
export class Remote extends HeyApiClient {
+30
View File
@@ -6465,6 +6465,36 @@ export type TelemetryCaptureResponses = {
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
export type TelemetrySetEnabledData = {
body?: {
enabled: boolean
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/telemetry/setEnabled"
}
export type TelemetrySetEnabledErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type TelemetrySetEnabledError = TelemetrySetEnabledErrors[keyof TelemetrySetEnabledErrors]
export type TelemetrySetEnabledResponses = {
/**
* State updated
*/
200: boolean
}
export type TelemetrySetEnabledResponse = TelemetrySetEnabledResponses[keyof TelemetrySetEnabledResponses]
export type RemoteEnableData = {
body?: never
path?: never