mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* fix(llms): limit Langfuse telemetry to Cline provider Refs ENG-2505 * fix(llms): include ClinePass in Langfuse telemetry Refs ENG-2505 * feat(llms): sampled cline-provider AI SDK tracing via the host OTLP exporter Enables the collector-relay tracing path: when the host telemetry service has registered an OTLP tracer provider, cline-provider streams can emit AI SDK spans without Langfuse credentials in the process. - CLINE_TRACE_SAMPLE_PERCENT (default 0 = off) gates emission; sampling is a deterministic FNV-1a hash of the task/session id, so whole tasks sample together and retries decide identically. - Metadata-only by default (recordInputs/recordOutputs false): models, tokens, timings, errors — no prompt or completion content. Content requires CLINE_TRACE_RECORD_CONTENT=true explicitly. - The direct Langfuse path (env credentials, hub/internal) is unchanged: full content, every request, provider-gated as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(telemetry): client OTLP trace pipeline for cline-provider AI SDK spans Extension side of the Langfuse collector relay: - OpenTelemetryClientProvider gains a TracerProvider: when tracesExporter is configured, spans batch-export over OTLP (grpc / http/json / http/protobuf) to the same collector endpoint as logs/metrics. register() also installs the async context manager span parenting needs. - tracesExporter plumbed through all three config sources: build constants (OTEL_TRACES_EXPORTER), runtime env (CLINE_OTEL_TRACES_EXPORTER), and remote config (openTelemetryTracesExporter) — the last is the no-release kill switch. - SDK: a host that registers a traces exporter now defaults to 100% task sampling; CLINE_TRACE_SAMPLE_PERCENT reduces it (0 disables). Env reads are literal so build-time inlining works. Metadata-only stays the default; content still requires CLINE_TRACE_RECORD_CONTENT=true. - CLI build inlines the new envs alongside the existing OTEL set. No publish workflow sets OTEL_TRACES_EXPORTER yet, so shipped builds keep tracing dark — activation is a separate one-line-per-workflow PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(llms): honor the global telemetry opt-out on the trace relay path AI SDK spans bypass the ITelemetryService wrapper that enforces the user's opt-out for events and metrics, so an opted-out user's prompts would still have traced once a host registered an OTLP exporter. resolveAiSdkTelemetry now re-checks the shared global settings file (telemetryOptOut) per stream — covering startup state and mid-session opt-outs across extension and CLI. The credentialed direct Langfuse path is unchanged: it only activates on explicit operator config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: biome format Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(llms): one trace export path per host — direct Langfuse declines when an OTLP tracer is registered Enablement is a per-stream boolean but export fan-out is processor-level: attaching the direct LangfuseSpanProcessor to a host-registered tracer provider ships every span twice (direct + collector relay). The direct path now declines instead of cooperating when a recording provider already owns the global slot; it only exports when it registers its own provider. A host therefore configures exactly one of LANGFUSE_* credentials or an OTLP traces exporter, and misconfiguring both is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(hub): daemon-distinct trace identity for Langfuse hub coverage (#14034) The detached hub daemon already has everything needed to emit AI SDK traces: it ships in the same compiled binary as the CLI (so the publish build's OTEL_TRACES_EXPORTER / CLINE_TRACE_RECORD_CONTENT inlining reaches it), its telemetry handle flows into the hub server and the scheduled-run handlers, core's OpenTelemetryProvider registers a global tracer when the exporter is configured, and shutdown already flushes with a hard deadline. What was missing is identity: spans carry only the OTel resource, not per-event metadata, so daemon-emitted traces were indistinguishable from CLI-local ones (both service.name=cline). The daemon now declares serviceName=cline-hub-daemon (+ serviceVersion), via new optional serviceName/serviceVersion fields on the shared OpenTelemetryClientConfig. Opt-out holds for the daemon unchanged: it is a same-machine, same-user process, so the shared global settings file the per-stream gate reads is the requesting user's own (shared/cloud topologies tracked in ENG-2525). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(llms): fail closed when the telemetry opt-out cannot be verified The relay's opt-out guard treated every settings read failure as 'no opt-out recorded', so a corrupted or unreadable settings file (including a torn read of the non-atomic writer) silently re-enabled tracing for a user who had opted out. Only a genuinely absent file (ENOENT, first run) now reads as opted in; malformed JSON and every other read failure disable the relay — consent that cannot be verified is not consent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(llms): identify the OTLP relay explicitly instead of inferring it from any recording tracer 'Some recording tracer exists' misclassified console-only tracing as the collector relay (enabling the relay decision) and — for mutable providers — newly suppressed the direct Langfuse attach that worked before the one-path guard. The hosts that build tracer providers now stamp a relay marker only when a real OTLP span processor was created (console-only providers stay unmarked), and the trace decisions key off that marker: - relay path enables only behind a marked provider; - the one-path decline fires only for marked providers — unmarked mutable providers keep the original cooperative attach behavior; - a relay decline is no longer cached, so direct Langfuse gets to retry once a relay is disposed. The marker lives on the provider instance (reached through the OTel API global), which survives bundled module duplication where a module-level registry would not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: organize imports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(telemetry): own and dispose remote trace clients across config updates * fix(llms): wire per-call Langfuse telemetry and isolate direct export * refactor(telemetry): reject unsupported providers before initialization * test(desktop): raise sidecar vitest timeout above the module-graph import cost The first test in each sidecar file pays the @cline/core → @cline/llms import cost, which lands within ~100ms of the 5s default on CI runners (commands-account failed at 5009ms; context.test.ts passed at 4923ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: abeatrix <beatrix@cline.bot>
313 lines
8.8 KiB
TypeScript
313 lines
8.8 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { join, relative, resolve } from "node:path";
|
|
import { $ } from "bun";
|
|
import {
|
|
parseBuildOptions,
|
|
shouldInstallNativeVariants,
|
|
validateBuildOptions,
|
|
} from "./build-options";
|
|
|
|
const cliDir = resolve(import.meta.dir, "..");
|
|
const rootDir = resolve(cliDir, "../..");
|
|
process.chdir(cliDir);
|
|
|
|
// Telemetry / OTEL environment variables that should be baked into the
|
|
// compiled binary at build time. Mirrors the list of secrets injected by the
|
|
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
|
|
// so the CLI ships with the production telemetry configuration without
|
|
// requiring the end user to set any env vars.
|
|
const BUILD_TIME_INLINED_ENV_VARS = [
|
|
"TELEMETRY_SERVICE_API_KEY",
|
|
"ERROR_SERVICE_API_KEY",
|
|
"OTEL_TELEMETRY_ENABLED",
|
|
"OTEL_LOGS_EXPORTER",
|
|
"OTEL_METRICS_EXPORTER",
|
|
"OTEL_TRACES_EXPORTER",
|
|
"CLINE_TRACE_SAMPLE_PERCENT",
|
|
"CLINE_TRACE_RECORD_CONTENT",
|
|
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
] as const;
|
|
|
|
function buildInlinedEnvDefines(): Record<string, string> {
|
|
const defines: Record<string, string> = {};
|
|
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
|
|
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
|
|
}
|
|
return defines;
|
|
}
|
|
|
|
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
|
|
const version: string = pkg.version;
|
|
const repository: unknown = pkg.repository;
|
|
|
|
console.log(`Building @cline/cli v${version}`);
|
|
|
|
const buildOptions = parseBuildOptions(process.argv.slice(2));
|
|
|
|
const allTargets: {
|
|
os: string;
|
|
arch: "arm64" | "x64";
|
|
}[] = [
|
|
{ os: "linux", arch: "arm64" },
|
|
{ os: "linux", arch: "x64" },
|
|
{ os: "darwin", arch: "arm64" },
|
|
{ os: "darwin", arch: "x64" },
|
|
{ os: "win32", arch: "x64" },
|
|
{ os: "win32", arch: "arm64" },
|
|
];
|
|
|
|
const targets = buildOptions.single
|
|
? allTargets.filter(
|
|
(item) => item.os === process.platform && item.arch === process.arch,
|
|
)
|
|
: allTargets;
|
|
|
|
const opentuiVersion = pkg.dependencies["@opentui/core"];
|
|
const optionsError = validateBuildOptions({
|
|
options: buildOptions,
|
|
opentuiVersion,
|
|
targetCount: targets.length,
|
|
});
|
|
if (optionsError) {
|
|
console.error(optionsError);
|
|
process.exit(1);
|
|
}
|
|
|
|
await $`rm -rf dist`;
|
|
|
|
// Pre-install all platform variants of native packages so cross-compilation
|
|
// can resolve them. Without this, Bun only has the host platform's native
|
|
// binary and cross-compiled builds fail to resolve @opentui/core's FFI layer.
|
|
if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) {
|
|
console.log(
|
|
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
|
|
);
|
|
await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiVersion}`;
|
|
}
|
|
|
|
// Build the SDK first (the CLI bundles workspace packages)
|
|
if (!buildOptions.skipSdkBuild) {
|
|
console.log("Building SDK packages...");
|
|
await $`bun run build:sdk`.cwd(rootDir);
|
|
|
|
console.log("Building CLI bundle...");
|
|
await $`bun -F @cline/cli build`.cwd(rootDir);
|
|
}
|
|
|
|
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
|
|
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
|
|
const hubWebviewIndex = join(hubWebviewDist, "index.html");
|
|
|
|
function newestFileMtimeMs(dir: string): number {
|
|
let newest = 0;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (
|
|
entry.name === "node_modules" ||
|
|
entry.name === "dist" ||
|
|
entry.name === ".turbo"
|
|
) {
|
|
continue;
|
|
}
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
newest = Math.max(newest, newestFileMtimeMs(path));
|
|
} else if (entry.isFile()) {
|
|
newest = Math.max(newest, statSync(path).mtimeMs);
|
|
}
|
|
}
|
|
return newest;
|
|
}
|
|
|
|
function shouldBuildHubWebview(): boolean {
|
|
if (!existsSync(hubWebviewIndex)) {
|
|
return true;
|
|
}
|
|
try {
|
|
return (
|
|
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (shouldBuildHubWebview()) {
|
|
console.log("Building Cline Hub webview...");
|
|
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
|
|
}
|
|
|
|
const binaries: Record<string, string> = {};
|
|
|
|
function findOpenTuiParserWorker(): string {
|
|
const localPath = resolve(
|
|
cliDir,
|
|
"node_modules/@opentui/core/parser.worker.js",
|
|
);
|
|
const rootPath = resolve(
|
|
rootDir,
|
|
"node_modules/@opentui/core/parser.worker.js",
|
|
);
|
|
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
|
|
return realpathSync(parserWorkerPath);
|
|
}
|
|
|
|
function getBunTarget(
|
|
item: (typeof allTargets)[number],
|
|
): Bun.Build.CompileTarget {
|
|
const targetOs = item.os === "win32" ? "windows" : item.os;
|
|
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
|
|
}
|
|
|
|
async function buildCompiledBinary(input: {
|
|
bunTarget: Bun.Build.CompileTarget;
|
|
dirName: string;
|
|
outfile: string;
|
|
}): Promise<void> {
|
|
const parserWorker = findOpenTuiParserWorker();
|
|
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
|
|
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
|
|
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
|
|
"\\",
|
|
"/",
|
|
);
|
|
|
|
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
|
|
// layer in containerized environments (virtiofs, overlayfs).
|
|
const entrypoint = join(cliDir, "src/index.ts");
|
|
const tmpDir = join("/tmp", `cline-build-${input.dirName}`);
|
|
const tmpOutfile = join(
|
|
tmpDir,
|
|
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
|
|
);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
|
|
process.chdir("/tmp");
|
|
const result = await Bun.build({
|
|
entrypoints: [entrypoint, parserWorker],
|
|
splitting: true,
|
|
compile: {
|
|
target: input.bunTarget,
|
|
outfile: tmpOutfile,
|
|
},
|
|
minify: true,
|
|
external: ["@anthropic-ai/vertex-sdk"],
|
|
define: {
|
|
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
|
|
// Inline telemetry/OTEL env vars at build time so the compiled
|
|
// binary ships with production telemetry configuration baked in.
|
|
...buildInlinedEnvDefines(),
|
|
},
|
|
throw: false,
|
|
});
|
|
process.chdir(cliDir);
|
|
|
|
if (!result.success) {
|
|
console.error(`Build failed for ${input.dirName}:`);
|
|
for (const log of result.logs) {
|
|
console.error(log);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
await $`cp ${tmpOutfile} ${input.outfile} && chmod 755 ${input.outfile}`;
|
|
await $`rm -rf ${tmpDir}`;
|
|
}
|
|
|
|
for (const item of targets) {
|
|
// npm treats "win32" specially in os field, but for package naming use "windows"
|
|
const displayOs = item.os === "win32" ? "windows" : item.os;
|
|
const name = `@cline/cli-${displayOs}-${item.arch}`;
|
|
const dirName = `cli-${displayOs}-${item.arch}`;
|
|
const binaryName = item.os === "win32" ? "cline.exe" : "cline";
|
|
const bunTarget = getBunTarget(item);
|
|
|
|
console.log(`\nBuilding ${name} (target: ${bunTarget})...`);
|
|
const outDir = join(cliDir, `dist/${dirName}/bin`);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
const outfile = join(outDir, binaryName);
|
|
|
|
await buildCompiledBinary({ bunTarget, dirName, outfile });
|
|
|
|
// Smoke test: only run on current platform
|
|
if (item.os === process.platform && item.arch === process.arch) {
|
|
console.log(` Smoke test: ${outfile} --version`);
|
|
try {
|
|
const output = await $`${outfile} --version`.text();
|
|
const actualVersion = output.trim();
|
|
if (actualVersion !== version) {
|
|
throw new Error(
|
|
`Expected --version to print ${version}, got ${actualVersion}`,
|
|
);
|
|
}
|
|
console.log(` Passed: ${actualVersion}`);
|
|
} catch (e) {
|
|
console.error(` Smoke test FAILED for ${name}:`, e);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Copy plugin sandbox bootstrap if it exists
|
|
const bootstrapSrc = join(
|
|
rootDir,
|
|
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
|
|
);
|
|
if (existsSync(bootstrapSrc)) {
|
|
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
|
|
mkdirSync(bootstrapDir, { recursive: true });
|
|
const content = readFileSync(bootstrapSrc);
|
|
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
|
|
}
|
|
|
|
if (existsSync(hubWebviewDist)) {
|
|
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
|
|
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
|
|
recursive: true,
|
|
});
|
|
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
|
|
}
|
|
|
|
// Generate platform package.json
|
|
await Bun.write(
|
|
join(cliDir, `dist/${dirName}/package.json`),
|
|
`${JSON.stringify(
|
|
{
|
|
name,
|
|
version,
|
|
description: `Cline CLI binary for ${displayOs} ${item.arch}`,
|
|
os: [item.os],
|
|
cpu: [item.arch],
|
|
...(repository ? { repository } : {}),
|
|
bin: {
|
|
cline: `bin/${binaryName}`,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
binaries[name] = version;
|
|
console.log(` Built ${name}`);
|
|
}
|
|
|
|
console.log(`\nBuild complete. ${Object.keys(binaries).length} targets built.`);
|
|
console.log("Packages:");
|
|
for (const [name, ver] of Object.entries(binaries)) {
|
|
console.log(` ${name}@${ver}`);
|
|
}
|
|
|
|
export { binaries, version };
|