Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cloud>/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 `<cloud>/otel`; direct exporters and the relay use `/otel/v1/<signal>`, while the local relay keeps `/v1/<signal>`. Re-run `otel disable <agent>` then `otel enable <agent>` 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.
Expand Down
44 changes: 37 additions & 7 deletions __tests__/hooks/otel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -77,6 +77,36 @@ describe("managed OTEL settings", () => {
}
});

it("Cloud OTLP lives under <origin>/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';
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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:");
Expand All @@ -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:"));
Expand Down Expand Up @@ -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);
}
});
Expand Down
3 changes: 2 additions & 1 deletion crates/failproofaid/tests/otlp_relay_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>/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
Expand Down
57 changes: 52 additions & 5 deletions crates/fpai-collect/src/otlp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<origin>/otel`; each batch goes to that plus its path.
const CLOUD_OTLP_PREFIX: &str = "/otel";

#[derive(Debug, Serialize, Deserialize)]
pub struct Metadata {
Expand Down Expand Up @@ -53,7 +57,8 @@ struct Relay {
health_file: PathBuf,
health: Mutex<RelayHealth>,
client: reqwest::Client,
origin: String,
/// Cloud's OTLP base URL, `<origin>/otel`.
cloud_base: String,
key: String,
receiving: tokio::sync::Semaphore,
/// First in-request retry delay; doubles per attempt. Tests shrink it.
Expand All @@ -78,7 +83,7 @@ const OFFLINE_BACKOFF_MAX: Duration = Duration::from_secs(300);
impl Relay {
fn new(home: &Path, ingest: &Ingest) -> Result<Self, TaskError> {
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()
Expand All @@ -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),
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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"))
Expand Down
6 changes: 4 additions & 2 deletions docs/agenteye/opentelemetry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<key>'
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 `<key>` 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.
Expand Down Expand Up @@ -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 <key>" } } }
exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/otel/v1/logs", protocol = "json", headers = { Authorization = "Bearer <key>" } } }
```

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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/otel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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://<cloud>/otel`, and the exporter appends `/v1/<signal>`; a per-signal variable such as `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` needs the full `https://<cloud>/otel/v1/logs`. Local output points at the relay, `http://127.0.0.1:<port>`. 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

Expand Down
Loading
Loading