mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
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 <saoudrizwan@users.noreply.github.com> * fix(desktop): reject non-http OTLP endpoints in the telemetry selfcheck --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { $ } from "bun";
|
||||
import { telemetryDefineArgs } from "./telemetry-define-args";
|
||||
|
||||
const resolveTargetTriple = async (): Promise<string> => {
|
||||
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<string> => {
|
||||
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 `<binary> --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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { telemetryDefineArgs } from "./telemetry-define-args";
|
||||
|
||||
function defineMap(args: string[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined> = 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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()) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user