From ddfb67515bea0834d19c5292359dbd0d5626dfef Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:43:01 -0700 Subject: [PATCH] fix(desktop): inline telemetry config into the packaged sidecar binary (#12925) * fix(desktop): inline telemetry config into the packaged sidecar binary Co-authored-by: Saoud Rizwan * fix(desktop): reject non-http OTLP endpoints in the telemetry selfcheck --------- Co-authored-by: Saoud Rizwan --- .github/workflows/desktop-publish.yml | 42 ++++++++++ .../desktop-app/scripts/build-sidecar-bin.ts | 10 ++- .../scripts/telemetry-define-args.test.ts | 81 +++++++++++++++++++ .../scripts/telemetry-define-args.ts | 55 +++++++++++++ apps/examples/desktop-app/sidecar/index.ts | 27 ++++++- .../sidecar/telemetry-selfcheck.test.ts | 64 +++++++++++++++ .../sidecar/telemetry-selfcheck.ts | 58 +++++++++++++ 7 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 apps/examples/desktop-app/scripts/telemetry-define-args.test.ts create mode 100644 apps/examples/desktop-app/scripts/telemetry-define-args.ts create mode 100644 apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts create mode 100644 apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 0d14920714..108666c7fc 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -225,6 +225,20 @@ jobs: working-directory: apps/examples/desktop-app run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json env: + # Telemetry config for the sidecar binary. Tauri's beforeBuildCommand + # (`bun run build` -> build:sidecar:bin) compiles the sidecar during + # this step and inlines these values into the binary via `--define` + # (scripts/telemetry-define-args.ts); a packaged app launched from + # Finder/the Dock has no runtime env, so build-time inlining is the + # only way the shipped sidecar can ever report telemetry. + TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} + ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} + OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} + OTEL_LOGS_EXPORTER: otlp + OTEL_METRICS_EXPORTER: otlp + OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }} + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} # Developer ID signing (Tauri imports the cert into a temp keychain) APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -262,6 +276,34 @@ jobs: esac done + # Guardrail: assert the telemetry config actually made it into the + # compiled sidecar. Missing env on the build step (or a regression in + # the --define inlining) would otherwise ship a release with telemetry + # silently disabled — exactly what happened for every release before + # this check existed. Being enabled is not enough on its own: an empty, + # malformed, or non-http(s) OTLP endpoint would still drop every event + # at runtime (the SDK exporters speak OTLP http/json only), so the + # selfcheck must also report a usable endpoint host. + - name: Verify sidecar telemetry config was inlined + working-directory: apps/examples/desktop-app + run: | + SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck) + echo "$SELFCHECK" + if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then + echo "Packaged sidecar reports telemetry disabled." + echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" + echo "'Build, sign, and notarize desktop bundle' step and the" + echo "--define inlining in scripts/build-sidecar-bin.ts." + exit 1 + fi + if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then + echo "Packaged sidecar reports telemetry enabled but its OTLP" + echo "endpoint is missing, unparseable, or not an http(s) URL, so" + echo "every event would be dropped at runtime. Check the" + echo "OTEL_EXPORTER_OTLP_ENDPOINT secret." + exit 1 + fi + - name: Collect artifacts working-directory: apps/examples/desktop-app env: diff --git a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts index 78b54772ea..17ac5c68ff 100644 --- a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts +++ b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts @@ -1,4 +1,5 @@ import { $ } from "bun"; +import { telemetryDefineArgs } from "./telemetry-define-args"; const resolveTargetTriple = async (): Promise => { const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET; @@ -40,10 +41,15 @@ const sidecarOutfile = (targetTriple: string): string => { const buildSidecar = async (targetTriple: string): Promise => { const outfile = sidecarOutfile(targetTriple); const bunTarget = resolveBunCompileTarget(targetTriple); + // Telemetry config must be inlined into the compiled binary: a packaged + // app launched from Finder/the Dock has no OTEL_* env at runtime, so + // without this the sidecar silently ships with telemetry disabled. + // Verify with ` --telemetry-selfcheck` after building. + const defines = telemetryDefineArgs(); if (bunTarget) { - await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`; + await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`; } else { - await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`; + await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`; } return outfile; }; diff --git a/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts b/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts new file mode 100644 index 0000000000..19041e2b1b --- /dev/null +++ b/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { telemetryDefineArgs } from "./telemetry-define-args"; + +function defineMap(args: string[]): Record { + const map: Record = {}; + for (let index = 0; index < args.length; index += 2) { + expect(args[index]).toBe("--define"); + const pair = args[index + 1]; + const separator = pair.indexOf("="); + map[pair.slice(0, separator)] = pair.slice(separator + 1); + } + return map; +} + +describe("telemetryDefineArgs", () => { + it("inlines every OTEL var getTelemetryBuildTimeConfig reads", () => { + const defines = defineMap( + telemetryDefineArgs({ + OTEL_TELEMETRY_ENABLED: "1", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.com:4318", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", + OTEL_EXPORTER_OTLP_HEADERS: "x-api-key=secret", + OTEL_LOGS_EXPORTER: "otlp", + OTEL_METRICS_EXPORTER: "otlp", + OTEL_TRACES_EXPORTER: "otlp", + OTEL_METRIC_EXPORT_INTERVAL: "60000", + }), + ); + expect(defines["process.env.OTEL_TELEMETRY_ENABLED"]).toBe('"1"'); + expect(defines["process.env.OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe( + '"https://otel.example.com:4318"', + ); + expect(defines["process.env.OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe( + '"http/json"', + ); + expect(defines["process.env.OTEL_EXPORTER_OTLP_HEADERS"]).toBe( + '"x-api-key=secret"', + ); + expect(defines["process.env.OTEL_LOGS_EXPORTER"]).toBe('"otlp"'); + expect(defines["process.env.OTEL_METRICS_EXPORTER"]).toBe('"otlp"'); + expect(defines["process.env.OTEL_TRACES_EXPORTER"]).toBe('"otlp"'); + expect(defines["process.env.OTEL_METRIC_EXPORT_INTERVAL"]).toBe('"60000"'); + }); + + it("inlines unset OTEL vars as empty strings so binaries never fall back to runtime env", () => { + const defines = defineMap(telemetryDefineArgs({})); + expect(defines["process.env.OTEL_TELEMETRY_ENABLED"]).toBe('""'); + expect(defines["process.env.OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe('""'); + }); + + it("includes API key defines only when the vars are set", () => { + const withoutKeys = defineMap(telemetryDefineArgs({})); + expect(withoutKeys).not.toHaveProperty( + "process.env.TELEMETRY_SERVICE_API_KEY", + ); + expect(withoutKeys).not.toHaveProperty("process.env.ERROR_SERVICE_API_KEY"); + + const withKeys = defineMap( + telemetryDefineArgs({ + TELEMETRY_SERVICE_API_KEY: "tk", + ERROR_SERVICE_API_KEY: "ek", + }), + ); + expect(withKeys["process.env.TELEMETRY_SERVICE_API_KEY"]).toBe('"tk"'); + expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"'); + }); + + it("JSON-escapes values so headers with quotes survive the define", () => { + const defines = defineMap( + telemetryDefineArgs({ + OTEL_EXPORTER_OTLP_HEADERS: 'x-quote="quoted",y=2', + }), + ); + expect(defines["process.env.OTEL_EXPORTER_OTLP_HEADERS"]).toBe( + JSON.stringify('x-quote="quoted",y=2'), + ); + expect(JSON.parse(defines["process.env.OTEL_EXPORTER_OTLP_HEADERS"])).toBe( + 'x-quote="quoted",y=2', + ); + }); +}); diff --git a/apps/examples/desktop-app/scripts/telemetry-define-args.ts b/apps/examples/desktop-app/scripts/telemetry-define-args.ts new file mode 100644 index 0000000000..b3db1683fb --- /dev/null +++ b/apps/examples/desktop-app/scripts/telemetry-define-args.ts @@ -0,0 +1,55 @@ +/** + * Build-time inlining of the telemetry configuration, mirroring the CLI + * bundle (`apps/cli/bun.mts`). `getTelemetryBuildTimeConfig` in + * `@cline/shared` reads `process.env.OTEL_*` at runtime, which is always + * empty for a packaged app launched from Finder/the Dock — so the packaged + * sidecar binary must have these values substituted into the bundle at + * build time. Vars that are unset at build time inline as "" (telemetry + * stays disabled), which keeps local/dev builds opted out without any + * keys living in the repo. + */ + +/** Inlined only when present so secrets never inline as empty markers. */ +const OPTIONAL_SECRET_ENV_VARS = [ + "TELEMETRY_SERVICE_API_KEY", + "ERROR_SERVICE_API_KEY", +] as const; + +/** + * Every env var `getTelemetryBuildTimeConfig` reads + * (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined, + * defaulting to "", so a packaged binary never falls back to runtime env. + */ +const OTEL_ENV_VARS = [ + "OTEL_TELEMETRY_ENABLED", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", + "OTEL_TRACES_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_METRIC_EXPORT_INTERVAL", +] as const; + +/** + * `--define` argument pairs for `bun build`, e.g. + * `["--define", 'process.env.OTEL_TELEMETRY_ENABLED="1"', ...]`. + */ +export function telemetryDefineArgs( + env: Record = process.env, +): string[] { + const args: string[] = []; + const define = (name: string, value: string) => { + args.push("--define", `process.env.${name}=${JSON.stringify(value)}`); + }; + for (const name of OPTIONAL_SECRET_ENV_VARS) { + const value = env[name]; + if (value) { + define(name, value); + } + } + for (const name of OTEL_ENV_VARS) { + define(name, env[name] ?? ""); + } + return args; +} diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index be9a7570e6..b2a27a99e5 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -1,5 +1,8 @@ import { homedir } from "node:os"; -import { setHomeDirIfUnset } from "@cline/core"; +import { + createClineTelemetryServiceConfig, + setHomeDirIfUnset, +} from "@cline/core"; import { captureSdkError, claimHubDaemonProcess } from "@cline/shared"; import { prewarmWorkspaceMetadata } from "./chat-session"; import { configureConnectorCliLaunch } from "./connectors"; @@ -12,6 +15,7 @@ import { createDesktopObservability } from "./observability"; import { resolveWorkspaceRoot } from "./paths"; import { startServer } from "./server"; import { ensureLoginShellPath } from "./shell-path"; +import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck"; import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types"; const SHUTDOWN_TIMEOUT_MS = 5_000; @@ -143,7 +147,28 @@ async function main() { ); } +/** + * Prints whether the telemetry configuration that was inlined at build time + * (see scripts/telemetry-define-args.ts) actually made it into this binary, + * then exits. CI runs this against the packaged sidecar and fails the + * publish when a release-grade build reports `"enabled":false` or an + * unusable OTLP endpoint, so a regression in the build-time inlining can + * never ship silently again. + */ +function runTelemetrySelfcheck(): void { + const report = buildTelemetrySelfcheckReport( + createClineTelemetryServiceConfig(), + ); + process.stdout.write(`${JSON.stringify(report)}\n`); +} + async function runEntrypoint(): Promise { + // Before the daemon-sentinel claim: the selfcheck only inspects build-time + // config and must not consume the sentinel or start anything. + if (process.argv.includes("--telemetry-selfcheck")) { + runTelemetrySelfcheck(); + return; + } // Claim rather than read: consuming the sentinel keeps daemon-hosted sessions // from handing it to every process they spawn. if (claimHubDaemonProcess()) { diff --git a/apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts b/apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts new file mode 100644 index 0000000000..b0bdda6a90 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck"; + +describe("buildTelemetrySelfcheckReport", () => { + it("reports the endpoint host for usable http(s) endpoints", () => { + expect( + buildTelemetrySelfcheckReport({ + enabled: true, + otlpEndpoint: "https://otel.example.com:4318", + logsExporter: "otlp", + metricsExporter: "otlp", + }), + ).toEqual({ + telemetry_selfcheck: true, + enabled: true, + otlp_endpoint_host: "otel.example.com:4318", + logs_exporter: "otlp", + metrics_exporter: "otlp", + }); + expect( + buildTelemetrySelfcheckReport({ + enabled: true, + otlpEndpoint: "http://127.0.0.1:4319", + }).otlp_endpoint_host, + ).toBe("127.0.0.1:4319"); + }); + + it("reports an empty host when no endpoint was inlined", () => { + expect( + buildTelemetrySelfcheckReport({ enabled: false }).otlp_endpoint_host, + ).toBe(""); + expect( + buildTelemetrySelfcheckReport({ enabled: true, otlpEndpoint: "" }) + .otlp_endpoint_host, + ).toBe(""); + }); + + it("flags unparseable endpoints", () => { + expect( + buildTelemetrySelfcheckReport({ + enabled: true, + otlpEndpoint: "not a url at all", + }).otlp_endpoint_host, + ).toBe("invalid-endpoint-url"); + }); + + it("flags endpoints the OTLP http exporters cannot speak", () => { + for (const otlpEndpoint of [ + "ftp://collector.example.com:4318", + "grpc://collector.example.com:4317", + "file:///tmp/otel", + ]) { + expect( + buildTelemetrySelfcheckReport({ enabled: true, otlpEndpoint }) + .otlp_endpoint_host, + ).toBe("invalid-endpoint-url"); + } + }); + + it("only treats an explicit true as enabled", () => { + expect(buildTelemetrySelfcheckReport({}).enabled).toBe(false); + expect(buildTelemetrySelfcheckReport({ enabled: true }).enabled).toBe(true); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts b/apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts new file mode 100644 index 0000000000..374f467e1f --- /dev/null +++ b/apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts @@ -0,0 +1,58 @@ +/** + * Builds the `--telemetry-selfcheck` report (see index.ts). CI runs the + * packaged sidecar with that flag and fails the publish unless the report + * shows telemetry enabled with a usable OTLP endpoint, so the checks here + * define what a release-grade build must prove. + */ + +export type TelemetrySelfcheckConfig = { + enabled?: boolean; + otlpEndpoint?: string; + logsExporter?: string; + metricsExporter?: string; +}; + +export type TelemetrySelfcheckReport = { + telemetry_selfcheck: true; + enabled: boolean; + /** + * The OTLP endpoint host when the endpoint is a usable http(s) URL, + * `""` when no endpoint was inlined, or `"invalid-endpoint-url"` when + * the endpoint cannot work at runtime (unparseable, hostless, or a + * non-http scheme the OTLP http/json exporters cannot speak). + */ + otlp_endpoint_host: string; + logs_exporter: string; + metrics_exporter: string; +}; + +// The SDK's OTLP exporters are the http/json ones +// (@opentelemetry/exporter-*-otlp-http); any other scheme would be accepted +// here only to fail at runtime. +const USABLE_OTLP_PROTOCOLS = new Set(["http:", "https:"]); + +function otlpEndpointHost(otlpEndpoint: string | undefined): string { + if (!otlpEndpoint) { + return ""; + } + try { + const url = new URL(otlpEndpoint); + return USABLE_OTLP_PROTOCOLS.has(url.protocol) && url.host + ? url.host + : "invalid-endpoint-url"; + } catch { + return "invalid-endpoint-url"; + } +} + +export function buildTelemetrySelfcheckReport( + config: TelemetrySelfcheckConfig, +): TelemetrySelfcheckReport { + return { + telemetry_selfcheck: true, + enabled: config.enabled === true, + otlp_endpoint_host: otlpEndpointHost(config.otlpEndpoint), + logs_exporter: config.logsExporter ?? "", + metrics_exporter: config.metricsExporter ?? "", + }; +}