diff --git a/CHANGELOG.md b/CHANGELOG.md index 023a3ce64..aa8396775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,12 @@ The first stable release with the new terminal experience and OpenTelemetry. Eve ### Features - **A redesigned CLI and `config` v2.** Every screen, picker, `--help` page and error has the new look, and `config` walks you through choosing agents, connecting to FailproofAI Cloud (hosted or self-hosted, with `--url`) and turning policies on, end to end. -- **OpenTelemetry, per agent.** `otel enable claude|codex|copilot|all` sends that agent's sessions to Cloud over OpenTelemetry instead of transcript upload, and `otel disable` restores its settings byte for byte. Codex exports logs only, a `log_user_prompt = false` you set is kept, `--no-content` leaves prompt and tool text out, and `--local` routes through the daemon's relay, which spools to disk and retries. `otel env` prints the exporter settings for any other OpenTelemetry app, and `otel status` shows the org switch, each agent and the relay. +- **OpenTelemetry, per agent.** `otel enable claude|codex|copilot|all` sends that agent's sessions to Cloud over OpenTelemetry instead of transcript upload, and `otel disable` restores its settings byte for byte. Codex exports logs only, a `log_user_prompt = false` you set is kept, `--no-content` leaves prompt and tool text out, and `--local` routes through the daemon's relay, which spools to disk and retries. `otel env` prints the exporter settings for any other OpenTelemetry app, and `otel status` shows the org switch, each agent and the relay. Cloud's OTLP endpoint base is `/otel`: exporters append `/v1/traces`, `/v1/logs` or `/v1/metrics` to it, and the relay forwards to the same address. - **Headless Codex enforces policies.** With sudo, `config` installs Codex's hooks into its system config, which Codex runs without an interactive trust prompt; `config --status` warns when hooks are not trusted. ### Fixes +- Cloud's OpenTelemetry ingest moved to `/otel`; direct exporters and the relay use `/otel/v1/`, while the local relay keeps `/v1/`. Re-run `otel disable ` then `otel enable ` after upgrading to rewrite older beta settings. (#903) - **Switching a machine to another organisation is a clean break.** Nothing recorded before the switch is sent to the new organisation (transcripts, queued batches and OpenTelemetry alike), the old organisation's cloud policies stop, and the daemon accepts the new organisation's policies without a restart. A first connect still backfills the last week. - **Reconnecting with a different key replaces every saved credential,** and events the old key was refused are resent with the new one. - **The daemon no longer stops collecting** when a settings change or backfill arrives while it is busy. diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts index 6b1baac6e..85a2672d1 100644 --- a/__tests__/hooks/otel.test.ts +++ b/__tests__/hooks/otel.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { spawnSync } from "node:child_process"; import { runOtelCommand, OTEL_ORG_OFF, waitForCollectorConfig } from "../../src/hooks/otel-cli"; -import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, vscodeProbe, agentOtelWrites, OTEL_AGENTS } from "../../src/hooks/otel-writers"; +import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, vscodeProbe, agentOtelWrites, cloudOtlpBase, otlpDestination, OTEL_AGENTS } from "../../src/hooks/otel-writers"; import { collectorHealthFile, configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; import { readConfig, updateConfig } from "../../src/hooks/fp-config"; import { agentSessionsEnabled } from "../../src/hooks/collector-config"; @@ -49,7 +49,7 @@ describe("managed OTEL settings", () => { expect((await runOtelCommand(["enable", "claude"], options())).exitCode).toBe(0); const enabled = JSON.parse(readFileSync(path, "utf8")); expect(enabled.env.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1"); - expect(enabled.env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://cloud.example"); + expect(enabled.env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://cloud.example/otel"); expect(enabled.env.OTEL_LOG_USER_PROMPTS).toBe("1"); expect(enabled.env.EXISTING).toBe("yes"); expect(enabled.hooks).toEqual({ keep: true }); @@ -77,6 +77,36 @@ describe("managed OTEL settings", () => { } }); + it("Cloud OTLP lives under /otel; per-signal endpoints carry the full path; the relay keeps /v1", async () => { + expect(cloudOtlpBase("https://cloud.example")).toBe("https://cloud.example/otel"); + expect(cloudOtlpBase("https://cloud.example/")).toBe("https://cloud.example/otel"); + expect(otlpDestination("https://cloud.example", false, 4318)).toBe("https://cloud.example/otel"); + expect(otlpDestination("https://cloud.example", true, 4320)).toBe("http://127.0.0.1:4320"); + const cloud = Object.fromEntries(agentOtelWrites("claude", cloudOtlpBase("https://cloud.example"), "k", false, true).map(w => [w.path[1], w.value])); + expect(cloud.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://cloud.example/otel"); + expect(cloud.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT).toBe("https://cloud.example/otel/v1/logs"); + expect(cloud.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT).toBe("https://cloud.example/otel/v1/metrics"); + const local = Object.fromEntries(agentOtelWrites("claude", "http://127.0.0.1:4318", "k", true, true).map(w => [w.path[1], w.value])); + expect(local.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT).toBe("http://127.0.0.1:4318/v1/logs"); + const env = await runOtelCommand(["env"], options()); + expect(env.lines).toContain("export OTEL_EXPORTER_OTLP_ENDPOINT='https://cloud.example/otel'"); + const localEnv = await runOtelCommand(["env", "--local"], options()); + expect(localEnv.lines).toContain("export OTEL_EXPORTER_OTLP_ENDPOINT='http://127.0.0.1:4318'"); + }); + + it("status calls settings written for Cloud's pre-/otel address not managed", async () => { + const path = agentConfigPath("claude", home); + seed(path, "{}\n"); + expect((await runOtelCommand(["enable", "claude", "--yes"], options())).exitCode).toBe(0); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Claude Code: on by failproofai"); + // What an earlier beta wrote: the bare origin, in the settings and the record. + for (const file of [path, configFile()]) writeFileSync(file, readFileSync(file, "utf8").replaceAll("https://cloud.example/otel", "https://cloud.example")); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Claude Code: on, not managed"); + expect((await runOtelCommand(["enable", "claude", "--yes"], options())).exitCode).toBe(0); + expect(JSON.parse(readFileSync(path, "utf8")).env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT).toBe("https://cloud.example/otel/v1/logs"); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Claude Code: on by failproofai"); + }); + it("local Claude writes only documented content flags and never writes auth header variables", async () => { const path = agentConfigPath("claude", home); const before = '{"env":{"KEEP":"yes"}}\n'; @@ -150,7 +180,7 @@ describe("managed OTEL settings", () => { const parsed = parseAgentConfig(next, "codex"); expect(parsed.otel).toMatchObject({ environment: "keep", - exporter: { "otlp-http": { endpoint: "https://cloud.example/v1/logs", headers: { Authorization: "Bearer capture-test-secret" } } }, + exporter: { "otlp-http": { endpoint: "https://cloud.example/otel/v1/logs", headers: { Authorization: "Bearer capture-test-secret" } } }, log_user_prompt: false, }); expect(parsed.otel).not.toHaveProperty("trace_exporter"); @@ -444,7 +474,7 @@ describe("opt-in, one path and status", () => { for (const agent of ["Claude Code", "Codex", "Copilot"]) expect(output).toContain(agent); expect(output).not.toContain("Gemini"); expect(output).toContain("on by failproofai"); - expect(output).toContain("https://cloud.example"); + expect(output).toContain("Cloud (https://cloud.example/otel)"); expect(output).toContain("http://127.0.0.1:4318"); expect(output).toContain("org: unknown"); expect(output).toContain("Relay:"); @@ -457,7 +487,7 @@ describe("opt-in, one path and status", () => { process.env.FAILPROOFAI_INGEST_URL = "http://127.0.0.1:18765/v1/events"; await runOtelCommand(["enable", "codex"], options()); let line = (await runOtelCommand(["status"], options())).lines.find(line => line.startsWith("Codex:")); - expect(line).toContain("destination: Cloud (http://127.0.0.1:18765)"); + expect(line).toContain("destination: Cloud (http://127.0.0.1:18765/otel)"); expect(line).not.toContain("local relay"); await runOtelCommand(["enable", "codex", "--local"], options()); line = (await runOtelCommand(["status"], options())).lines.find(line => line.startsWith("Codex:")); @@ -535,9 +565,9 @@ describe("opt-in, one path and status", () => { seed(credentialsFile(), JSON.stringify({ ingest: { url, key: "capture-test-secret" } })); const env = await runOtelCommand(["env"], options()); expect(env.exitCode).toBe(0); - expect(env.lines.join("\n")).toContain(new URL(url).origin); + expect(env.lines.join("\n")).toContain(`${new URL(url).origin}/otel`); expect((await runOtelCommand(["enable", "claude", "--yes"], options())).exitCode).toBe(0); - expect(JSON.parse(readFileSync(claude, "utf8")).env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(new URL(url).origin); + expect(JSON.parse(readFileSync(claude, "utf8")).env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(`${new URL(url).origin}/otel`); expect((await runOtelCommand(["disable", "claude"], options())).exitCode).toBe(0); } }); diff --git a/crates/failproofaid/tests/otlp_relay_e2e.rs b/crates/failproofaid/tests/otlp_relay_e2e.rs index 30ad633d8..3e70ff65f 100644 --- a/crates/failproofaid/tests/otlp_relay_e2e.rs +++ b/crates/failproofaid/tests/otlp_relay_e2e.rs @@ -234,7 +234,8 @@ fn sdk_spool_delivers_without_opening_a_tcp_listener() { let mut request = vec![0; 4096]; let read = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..read]).to_lowercase(); - assert!(request.starts_with("post /v1/traces ")); + // Cloud's OTLP base is /otel; the spool keeps the local /v1 path. + assert!(request.starts_with("post /otel/v1/traces ")); assert!(request.contains("authorization: bearer test")); assert!(request.contains("content-type: application/json")); stream diff --git a/crates/fpai-collect/src/otlp.rs b/crates/fpai-collect/src/otlp.rs index 4f5e36c3f..56f8afc45 100644 --- a/crates/fpai-collect/src/otlp.rs +++ b/crates/fpai-collect/src/otlp.rs @@ -21,7 +21,11 @@ use crate::supervisor::{Shutdown, TaskError, TaskSpec}; use crate::uploader::{ParkedName, new_request_id}; pub const MAX_BODY_BYTES: usize = 8 * 1024 * 1024; +/// The local OTLP paths: what the relay listens on and what the SDK spool +/// metadata carries. Standard OTLP, because agents and SDKs speak it to us. const PATHS: [&str; 3] = ["/v1/traces", "/v1/logs", "/v1/metrics"]; +/// Cloud's OTLP base is `/otel`; each batch goes to that plus its path. +const CLOUD_OTLP_PREFIX: &str = "/otel"; #[derive(Debug, Serialize, Deserialize)] pub struct Metadata { @@ -53,7 +57,8 @@ struct Relay { health_file: PathBuf, health: Mutex, client: reqwest::Client, - origin: String, + /// Cloud's OTLP base URL, `/otel`. + cloud_base: String, key: String, receiving: tokio::sync::Semaphore, /// First in-request retry delay; doubles per attempt. Tests shrink it. @@ -78,7 +83,7 @@ const OFFLINE_BACKOFF_MAX: Duration = Duration::from_secs(300); impl Relay { fn new(home: &Path, ingest: &Ingest) -> Result { let url = reqwest::Url::parse(&ingest.url).map_err(|e| TaskError(e.to_string()))?; - let origin = url.origin().ascii_serialization(); + let cloud_base = format!("{}{CLOUD_OTLP_PREFIX}", url.origin().ascii_serialization()); let health_file = home.join("state/otlp-health.json"); let mut health = fs::read(&health_file) .ok() @@ -95,7 +100,7 @@ impl Relay { .timeout(Duration::from_secs(30)) .build() .map_err(|e| TaskError(e.to_string()))?, - origin, + cloud_base, key: ingest.key.clone(), receiving: tokio::sync::Semaphore::new(16), retry_base: Duration::from_secs(1), @@ -147,7 +152,7 @@ impl Relay { } let mut request = self .client - .post(format!("{}{}", self.origin, metadata.path)) + .post(format!("{}{}", self.cloud_base, metadata.path)) .bearer_auth(&self.key) .header("Content-Type", &metadata.content_type) .body(body.to_vec()); @@ -529,11 +534,53 @@ mod tests { .unwrap() } + #[tokio::test] + async fn every_signal_gets_exactly_one_cloud_prefix() { + let server = MockServer::start().await; + let home = temp(); + // Saved credentials may name the event endpoint or the OTLP base. + // Neither path is carried into the upstream URL a second time. + for suffix in ["", "/v1/events", "/otel", "/otel/v1/logs"] { + let relay = Relay::new( + &home, + &Ingest { + url: format!("{}{suffix}", server.uri()), + key: "test".into(), + }, + ) + .unwrap(); + for signal in PATHS { + Mock::given(method("POST")) + .and(path(format!("/otel{signal}"))) + .and(header("authorization", "Bearer test")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let file = spool_request( + &relay.spool, + &Metadata { + path: signal.into(), + content_type: "application/json".into(), + encoding: None, + }, + b"{}", + ) + .unwrap(); + relay.deliver(&file, &shutdown()).await; + assert!(!file.exists()); + server.verify().await; + server.reset().await; + } + } + fs::remove_dir_all(home).unwrap(); + } + #[tokio::test] async fn durable_opaque_forward_and_partial_success() { let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/v1/logs")) + .and(path("/otel/v1/logs")) .and(header("authorization", "Bearer test")) .and(header("content-type", "application/json")) .and(header("content-encoding", "gzip")) diff --git a/docs/agenteye/opentelemetry.mdx b/docs/agenteye/opentelemetry.mdx index 340ff84d7..b2444f070 100644 --- a/docs/agenteye/opentelemetry.mdx +++ b/docs/agenteye/opentelemetry.mdx @@ -31,12 +31,14 @@ If FailproofAI already sends an agent's session transcripts, setup asks whether Use your application's OpenTelemetry instrumentation with these environment variables: ```bash -export OTEL_EXPORTER_OTLP_ENDPOINT=https://app.befailproof.ai +export OTEL_EXPORTER_OTLP_ENDPOINT=https://app.befailproof.ai/otel export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_HEADERS='Authorization=Bearer%20' export OTEL_SERVICE_NAME=my-agent ``` +The endpoint is Cloud's OTLP base, `https://app.befailproof.ai/otel`; your exporter appends `/v1/traces`, `/v1/logs`, or `/v1/metrics` itself. Per-signal variables such as `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` are used as-is, so they need the full path, for example `https://app.befailproof.ai/otel/v1/logs`. + Replace `` with your API key. Keep the secret out of shell history, screen recordings, and committed configuration. For a configured machine, `failproofai otel env --service my-agent` prints the block with its existing key. Its output contains a credential. Cloud accepts OTLP/HTTP exports for traces, logs, and metrics, as protobuf or JSON, including gzip-compressed requests. It does not accept OTLP/gRPC. @@ -76,7 +78,7 @@ For an agent you configure manually, use its own content-capture controls: ```toml [otel] log_user_prompt = true - exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/logs", protocol = "json", headers = { Authorization = "Bearer " } } } + exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/otel/v1/logs", protocol = "json", headers = { Authorization = "Bearer " } } } ``` Codex conversation content is in logs; leave internal runtime traces and metrics disabled. Codex uses the complete endpoint you provide. Set `log_user_prompt` to `false` to leave prompts out; the setup command preserves an explicit `false`. diff --git a/docs/reference/otel.mdx b/docs/reference/otel.mdx index 249d64279..8c06ed529 100644 --- a/docs/reference/otel.mdx +++ b/docs/reference/otel.mdx @@ -61,7 +61,7 @@ failproofai otel env --service my-agent failproofai otel env --service my-agent --local ``` -The output is a shell environment block for an OTLP/HTTP exporter. Cloud output includes the configured API key; treat it as a secret. Local output omits the key. To use local output, first enable a local agent so the relay is running. +The output is a shell environment block for an OTLP/HTTP exporter. Cloud output sets `OTEL_EXPORTER_OTLP_ENDPOINT` to Cloud's OTLP base, `https:///otel`, and the exporter appends `/v1/`; a per-signal variable such as `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` needs the full `https:///otel/v1/logs`. Local output points at the relay, `http://127.0.0.1:`. Cloud output includes the configured API key; treat it as a secret. Local output omits the key. To use local output, first enable a local agent so the relay is running. ## Restore previous settings diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts index c09fc5b0d..8acf717c3 100644 --- a/src/hooks/otel-cli.ts +++ b/src/hooks/otel-cli.ts @@ -16,7 +16,7 @@ import { validateCloudUrl } from "./cloud-enrollment"; import { readJsonFile } from "./integrations"; import { selectOne } from "./tui"; import { - AGENT_LABELS, OTEL_AGENTS, agentConfigPath, agentOtelWrites, editAgentConfig, + AGENT_LABELS, OTEL_AGENTS, agentConfigPath, agentOtelWrites, editAgentConfig, otlpDestination, cloudOtlpBase, getSetting, parseAgentConfig, vscodeInstalled, type JsonObject, type OtelAgent, type SettingWrite, } from "./otel-writers"; @@ -191,7 +191,7 @@ async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, c const local = localFlag || agent === "copilot"; const port = readConfig().collector.otlp?.port ?? 4318; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("collector.otlp.port must be between 1 and 65535."); - const destination = local ? `http://127.0.0.1:${port}` : origin; + const destination = otlpDestination(origin, local, port); const original = existsSync(file) ? readFileSync(file, "utf8") : null; const parsed = parseAgentConfig(original ?? "", agent); const oldState = existing ? privateState(agent) : null; @@ -309,8 +309,12 @@ async function status(options: OtelOptions): Promise { try { const state = privateState(agent); const original = parseAgentConfig(state.original ?? "", agent); + const { key, origin } = credential(); + // Cloud's address is recomputed, so settings written for an older + // address (or another Cloud) read as "not managed" until re-enabled. + const expectedDestination = record.local ? record.destination : cloudOtlpBase(origin); const expected = parseAgentConfig(editAgentConfig(state.original ?? "", agent, - agentOtelWrites(agent, record.destination, credential().key, record.local, record.content, original)), agent); + agentOtelWrites(agent, expectedDestination, key, record.local, record.content, original)), agent); managed = record.keys.every(k => JSON.stringify(getSetting(parsed, k.path)) === JSON.stringify(getSetting(expected, k.path))); } catch { /* Missing recovery state or key. */ } } @@ -345,7 +349,7 @@ export async function runOtelCommand(args: string[], options: OtelOptions = {}): const consumed = rest.filter((_, i) => i !== index && i !== index + 1 || index < 0).filter(x => x !== "--local"); if (consumed.length) throw new Error("env takes only --service and --local."); const local = rest.includes("--local"); - const endpoint = local ? `http://127.0.0.1:${readConfig().collector.otlp?.port ?? 4318}` : origin; + const endpoint = otlpDestination(origin, local, readConfig().collector.otlp?.port ?? 4318); const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; const values = { OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts index 37c97ca47..fc6c3597f 100644 --- a/src/hooks/otel-writers.ts +++ b/src/hooks/otel-writers.ts @@ -186,6 +186,19 @@ export function editAgentConfig(text: string, agent: OtelAgent, writes: SettingW return next; } +/** + * FailproofAI Cloud's OTLP base URL. Exporters append `/v1/` to it, as + * the OTLP spec says. The local relay is plain `http://127.0.0.1:`. + */ +export function cloudOtlpBase(origin: string): string { + return `${origin.replace(/\/+$/, "")}/otel`; +} + +/** Where an agent's exporter points: the local relay, or Cloud's OTLP base. */ +export function otlpDestination(origin: string, local: boolean, port: number): string { + return local ? `http://127.0.0.1:${port}` : cloudOtlpBase(origin); +} + export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, local: boolean, content: boolean, config: JsonObject = {}): SettingWrite[] { const auth = local ? {} : { Authorization: `Bearer ${key}` }; const env: JsonObject = {