diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 186c4fdd3..7de55873c 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1422,6 +1422,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1726,6 +1737,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -2712,6 +2729,75 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "opentelemetry" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302249bd8ee804789faaa7b5b2cbf79bc61cb864714079c644e3828af5b7e811" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a" +dependencies = [ + "http", + "httpdate", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.4", + "thiserror", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3726,6 +3812,8 @@ version = "0.4.0-pre-alpha-v9" dependencies = [ "anyhow", "apple-native-keyring-store", + "async-trait", + "base64", "chrono", "clap", "clap_complete", @@ -3736,6 +3824,10 @@ dependencies = [ "jsonschema", "keyring-core", "murmur3", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-otlp", + "opentelemetry_sdk", "owo-colors 4.3.0", "quint-connect", "rand 0.8.6", @@ -3747,6 +3839,8 @@ dependencies = [ "tokio", "toml", "tracing", + "tracing-opentelemetry", + "tracing-subscriber", "turso", "uuid", "windows-native-keyring-store", @@ -4467,6 +4561,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index b16e47124..5e7cde30c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -27,6 +27,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" +base64 = "=0.22.1" chrono = "0.4" clap = { version = "4", features = ["derive"] } turso = { version = "0.8.1" } @@ -49,6 +50,13 @@ tracing = "0.1" uuid = { version = "1", features = ["v4", "v7"] } murmur3 = "0.5.2" +opentelemetry-otlp = { version = "=0.33.1", default-features = false, features = ["http-proto", "trace", "logs"] } +opentelemetry-http = "=0.33.1" +tracing-opentelemetry = "=0.34.0" +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry"] } +opentelemetry = { version = "=0.33.1", default-features = false, features = ["trace", "logs"] } +async-trait = "0.1" +opentelemetry_sdk = { version = "=0.33.1", default-features = false, features = ["trace", "logs"] } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] zbus-secret-service-keyring-store = { version = "1.0.0", features = ["rt-tokio-crypto-rust"] } diff --git a/cli/migrations/auth/004_bind_telemetry_credentials.sql b/cli/migrations/auth/004_bind_telemetry_credentials.sql new file mode 100644 index 000000000..d600f0c35 --- /dev/null +++ b/cli/migrations/auth/004_bind_telemetry_credentials.sql @@ -0,0 +1,12 @@ +ALTER TABLE telemetry_credentials +ADD COLUMN user_id TEXT NOT NULL DEFAULT ''; + +ALTER TABLE telemetry_credentials +ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS telemetry_repository_bindings ( + repository_id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + bound_at_unix_seconds INTEGER NOT NULL +); diff --git a/cli/src/app.rs b/cli/src/app.rs index b651cc964..e7ec4d71c 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -1,14 +1,18 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::time::Duration; use crate::services; use services::app_support::{self, RunOutcome}; use services::error::CliError; +use services::managed_telemetry::RuntimeTelemetry; use services::observability::traits::{ Logger as LoggerTrait, NoopTelemetry, Telemetry as TelemetryTrait, }; +const MIN_WORKER_THREADS: usize = 2; +const RUNTIME_SHUTDOWN_GRACE: Duration = Duration::from_millis(500); const REPEATED_COMMAND_DISPATCH_ERROR: &str = "Command lifecycle telemetry attempted to execute command dispatch more than once"; @@ -19,16 +23,13 @@ struct StartupContext { struct AppRuntime { logger: services::observability::Logger, - telemetry: NoopTelemetry, + telemetry: RuntimeTelemetry, fs: services::capabilities::StdFsOps, git: services::capabilities::ProcessGitOps, registry: services::command_registry::CommandRegistry, startup_diagnostic: Option, } -/// Lightweight borrowed view of the CLI runtime dependencies. -/// -/// `AppContext` does **not** own its dependencies; it borrows them from `AppRuntime`. pub struct AppContext< 'a, L: LoggerTrait = services::observability::Logger, @@ -46,7 +47,7 @@ pub struct AppContext< type ProductionAppContext<'a> = AppContext< 'a, services::observability::Logger, - NoopTelemetry, + RuntimeTelemetry, services::capabilities::StdFsOps, services::capabilities::ProcessGitOps, >; @@ -112,8 +113,6 @@ where HasTelemetry::telemetry(self) } - /// Returns a context for a command-scoped repository root while preserving - /// the runtime logger, telemetry, and capability dependencies. pub(crate) fn with_repo_root(&self, repo_root: impl Into) -> Self { Self { logger: self.logger, @@ -207,6 +206,45 @@ impl AppRuntime { } } +pub fn run_process(args: I) -> ExitCode +where + I: IntoIterator, +{ + let runtime = match build_runtime() { + Ok(runtime) => runtime, + Err(error) => { + return app_support::render_run_outcome( + RunOutcome:: { + result: Err(CliError::runtime(anyhow::Error::msg(format!( + "Failed to start async runtime: {error}" + )))), + logger: None, + startup_diagnostic: None, + }, + &mut io::stdout(), + &mut io::stderr(), + ); + } + }; + let exit_code = runtime.block_on(run(args)); + finish_runtime(runtime); + exit_code +} + +pub(crate) fn build_runtime() -> io::Result { + let worker_threads = std::thread::available_parallelism().map_or(MIN_WORKER_THREADS, |count| { + count.get().max(MIN_WORKER_THREADS) + }); + tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads) + .enable_all() + .build() +} + +pub(crate) fn finish_runtime(runtime: tokio::runtime::Runtime) { + runtime.shutdown_timeout(RUNTIME_SHUTDOWN_GRACE); +} + pub async fn run(args: I) -> ExitCode where I: IntoIterator, @@ -236,42 +274,42 @@ where StdoutW: Write, StderrW: Write, { - app_support::render_run_outcome( - try_run_with_dependency_check(args, dependency_check, stderr).await, - stdout, - stderr, - ) -} - -async fn try_run_with_dependency_check( - args: I, - dependency_check: F, - stderr: &mut StderrW, -) -> RunOutcome -where - I: IntoIterator, - F: FnOnce() -> anyhow::Result<()>, - StderrW: Write, -{ - let result = perform_dependency_check(dependency_check) + let args = args.into_iter().collect::>(); + let managed_allowed = !is_hook_invocation(&args); + let startup = perform_dependency_check(dependency_check) .and_then(|()| build_startup_context()) .and_then(initialize_runtime); - - match result { - Ok(runtime) => { - let startup_diagnostic = runtime.startup_diagnostic.clone(); - let result = run_command_lifecycle(args, &runtime, stderr).await; - RunOutcome { - logger: Some(runtime.logger), - startup_diagnostic, - result, - } + let mut runtime = match startup { + Ok(runtime) => runtime, + Err(error) => { + return app_support::render_run_outcome( + RunOutcome:: { + result: Err(error), + logger: None, + startup_diagnostic: None, + }, + stdout, + stderr, + ); } - Err(error) => RunOutcome { - result: Err(error), - logger: None, - startup_diagnostic: None, - }, + }; + runtime.telemetry = RuntimeTelemetry::resolve(managed_allowed).await; + let rendered = run_command_lifecycle(args, &runtime, stdout, stderr).await; + runtime.telemetry.shutdown().await; + app_support::exit_code_for(&rendered) +} + +fn is_hook_invocation(args: &[String]) -> bool { + let mut words = args + .iter() + .skip(1) + .map(String::as_str) + .filter(|arg| !arg.starts_with('-')); + match (words.next(), words.next()) { + (Some(first), _) if first == services::hooks::NAME => true, + (Some("policy"), Some("bash")) => true, + (Some(first), Some("telemetry-headers")) if first == services::auth_command::NAME => true, + _ => false, } } @@ -308,7 +346,7 @@ fn initialize_runtime(startup: StartupContext) -> Result { app_support::log_startup_configuration(&logger, &startup.observability_config); Ok(AppRuntime { logger, - telemetry: NoopTelemetry, + telemetry: RuntimeTelemetry::Noop(NoopTelemetry), fs: services::capabilities::StdFsOps, git: services::capabilities::ProcessGitOps, registry: services::command_registry::build_default_registry(), @@ -316,37 +354,50 @@ fn initialize_runtime(startup: StartupContext) -> Result { }) } -async fn run_command_lifecycle( +async fn run_command_lifecycle( args: I, runtime: &AppRuntime, + stdout: &mut StdoutW, stderr: &mut StderrW, ) -> Result where I: IntoIterator, + StdoutW: Write, StderrW: Write, { let context = runtime.context(); - run_command_lifecycle_with_context(args, &runtime.registry, &context, stderr).await + run_command_lifecycle_with_context( + args, + &runtime.registry, + &context, + runtime.startup_diagnostic.as_deref(), + stdout, + stderr, + ) + .await } -async fn run_command_lifecycle_with_context( +async fn run_command_lifecycle_with_context( args: I, registry: &services::command_registry::CommandRegistry, context: &AppContext<'_, L, T>, + startup_diagnostic: Option<&str>, + stdout: &mut StdoutW, stderr: &mut StderrW, ) -> Result where I: IntoIterator, L: LoggerTrait, T: TelemetryTrait, + StdoutW: Write, StderrW: Write, { let mut args = Some(args.into_iter().collect::>()); - let mut stderr = Some(stderr); + let mut streams = Some((stdout, stderr)); context .telemetry() .with_default_subscriber(&mut || { - let command_inputs = args.take().zip(stderr.take()); + let command_inputs = args.take().zip(streams.take()); async move { context.logger().info( "sce.app.start", @@ -354,18 +405,43 @@ where &[("component", services::observability::NAME)], None, ); - let Some((command_args, stderr)) = command_inputs else { - return Err(CliError::runtime(anyhow::Error::msg( - REPEATED_COMMAND_DISPATCH_ERROR, - ))); + let Some((command_args, (stdout, stderr))) = command_inputs else { + return Err(repeated_dispatch_error()); }; - let command = parse_command_phase(command_args, registry, context)?; - app_support::execute_command_phase(&command, context, stderr).await + let result = dispatch_command(command_args, registry, context, stderr).await; + app_support::render_command_result( + result, + Some(context.logger()), + startup_diagnostic, + stdout, + stderr, + ) + .map(|()| String::new()) } }) .await } +fn repeated_dispatch_error() -> CliError { + CliError::runtime(anyhow::Error::msg(REPEATED_COMMAND_DISPATCH_ERROR)) +} + +async fn dispatch_command( + args: Vec, + registry: &services::command_registry::CommandRegistry, + context: &AppContext<'_, L, T>, + stderr: &mut StderrW, +) -> Result +where + L: LoggerTrait, + T: TelemetryTrait, + StderrW: Write, +{ + let command = parse_command_phase(args, registry, context)?; + services::managed_telemetry::trace_export::record_command_name(&command.name()); + app_support::execute_command_phase(&command, context, stderr).await +} + fn parse_command_phase( args: I, registry: &services::command_registry::CommandRegistry, @@ -390,9 +466,10 @@ where mod doctor_reconciliation_cli_tests; #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::future::{poll_fn, Future}; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::*; @@ -425,7 +502,14 @@ mod tests { } fn warn(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - fn log_cli_error(&self, _: &CliError, _: Option<&str>) {} + fn log_cli_error(&self, error: &CliError, _: Option<&str>) { + let mut scope = self.0.lock().unwrap(); + assert!( + scope.active, + "final error logging must occur inside a telemetry poll" + ); + scope.events.push(format!("final_error:{}", error.code())); + } } struct RecordingTelemetry { @@ -564,10 +648,13 @@ mod tests { let fs = services::capabilities::StdFsOps; let git = services::capabilities::ProcessGitOps; let context = AppContext::new(&logger, &telemetry, &fs, &git, None); + let mut stdout = Vec::new(); let result = Box::pin(run_command_lifecycle_with_context( args.into_iter().map(String::from), &services::command_registry::CommandRegistry::default(), &context, + None, + &mut stdout, &mut Vec::new(), )) .await; @@ -578,7 +665,11 @@ mod tests { assert_eq!(error.to_string(), REPEATED_COMMAND_DISPATCH_ERROR); } } else { - assert_eq!(result.unwrap(), services::help::help_text()); + result.unwrap(); + assert_eq!( + stdout, + format!("{}\n", services::help::help_text()).as_bytes() + ); } let scope = scope.lock().unwrap(); assert!(!scope.active); @@ -592,6 +683,296 @@ mod tests { } } + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) + } + } + + async fn run_recorded_lifecycle( + args: &[&str], + stdout: &mut StdoutW, + stderr: &mut StderrW, + ) -> (Result, Vec) { + let scope = Arc::new(Mutex::new(RecordingScope::default())); + let logger = RecordingLogger(scope.clone()); + let telemetry = RecordingTelemetry { + scope: scope.clone(), + repeat: false, + }; + let fs = services::capabilities::StdFsOps; + let git = services::capabilities::ProcessGitOps; + let context = AppContext::new(&logger, &telemetry, &fs, &git, None); + let result = Box::pin(run_command_lifecycle_with_context( + args.iter().map(|arg| (*arg).to_string()), + &services::command_registry::CommandRegistry::default(), + &context, + None, + stdout, + stderr, + )) + .await; + let events = scope + .lock() + .unwrap() + .events + .iter() + .filter(|event| event.starts_with("final_error:")) + .cloned() + .collect(); + (result, events) + } + + #[tokio::test(flavor = "multi_thread")] + async fn final_command_outcome_and_error_log_are_produced_inside_telemetry_scope() { + let missing_config = [ + "sce", + "config", + "validate", + "--config", + "/nonexistent-sce-outcome-config.json", + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let (result, events) = + run_recorded_lifecycle(&["sce", "help"], &mut stdout, &mut stderr).await; + assert!(result.is_ok()); + assert!(events.is_empty()); + assert_eq!(app_support::exit_code_for(&result), ExitCode::SUCCESS); + assert_eq!( + stdout, + format!("{}\n", services::help::help_text()).as_bytes() + ); + assert!(stderr.is_empty()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let (result, events) = run_recorded_lifecycle( + &["sce", "--invalid-outcome-option"], + &mut stdout, + &mut stderr, + ) + .await; + let error = result.unwrap_err(); + assert_eq!(error.class(), services::error::FailureClass::Parse); + assert_eq!(events, [format!("final_error:{}", error.code())]); + assert!(stdout.is_empty()); + assert!(!stderr.is_empty()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let (result, events) = + run_recorded_lifecycle(&missing_config, &mut stdout, &mut stderr).await; + let error = result.unwrap_err(); + assert_eq!(error.class(), services::error::FailureClass::Runtime); + assert_eq!(events, [format!("final_error:{}", error.code())]); + assert_eq!( + app_support::exit_code_for(&Err::<(), _>(error)), + ExitCode::from(4) + ); + assert!(stdout.is_empty()); + assert!(!stderr.is_empty()); + + let mut stderr = Vec::new(); + let (result, events) = + run_recorded_lifecycle(&["sce", "help"], &mut FailingWriter, &mut stderr).await; + let error = result.unwrap_err(); + assert_eq!(error.class(), services::error::FailureClass::Runtime); + assert_eq!(events, [format!("final_error:{}", error.code())]); + assert!( + String::from_utf8_lossy(&stderr).contains("Failed to write command output to stdout") + ); + + let mut stdout = Vec::new(); + let (result, events) = + run_recorded_lifecycle(&missing_config, &mut stdout, &mut FailingWriter).await; + let error = result.unwrap_err(); + assert_eq!(error.class(), services::error::FailureClass::Runtime); + assert_eq!(events, [format!("final_error:{}", error.code())]); + assert_eq!( + app_support::exit_code_for(&Err::<(), _>(error)), + ExitCode::from(4) + ); + assert!(stdout.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn managed_export_marks_failed_output_and_exports_final_error_log() { + use services::managed_telemetry::trace_export::tests::{ + capture_requests, loopback_telemetry, + }; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let receiver = tokio::spawn(capture_requests(listener, "HTTP/1.1 200 OK", 2)); + let telemetry = loopback_telemetry(address, "bao-token-value"); + let logger = services::observability::Logger::without_log_file(); + let fs = services::capabilities::StdFsOps; + let git = services::capabilities::ProcessGitOps; + let context = AppContext::new(&logger, &telemetry, &fs, &git, None); + let mut stderr = Vec::new(); + + let result = Box::pin(run_command_lifecycle_with_context( + ["sce", "help"].map(String::from), + &services::command_registry::CommandRegistry::default(), + &context, + None, + &mut FailingWriter, + &mut stderr, + )) + .await; + let error = result.unwrap_err(); + assert_eq!(error.class(), services::error::FailureClass::Runtime); + telemetry.shutdown().await; + + let requests = receiver.await.unwrap(); + let body_for = |path: &str| { + requests + .iter() + .find(|request| request.head.to_ascii_lowercase().starts_with(path)) + .map(|request| String::from_utf8_lossy(&request.body).to_string()) + .unwrap_or_default() + }; + let trace = body_for("post /v1/traces "); + let log = body_for("post /v1/logs "); + assert!(trace.contains("failure"), "trace outcome must be failure"); + assert!(!trace.contains("success")); + assert!( + log.contains(&format!("sce.error.{}", error.code())), + "{:?} {log:?}", + requests + .iter() + .map(|r| r.head.lines().next().unwrap_or_default().to_string()) + .collect::>() + ); + assert!(!log.contains("bao-token-value")); + } + + const FAULT_CHILD_ENV: &str = "SCE_TELEMETRY_FAULT_CHILD"; + const CHILD_KILL_AFTER: Duration = Duration::from_secs(30); + const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(50); + + pub(crate) fn run_fault_child(test_name: &str) -> (Duration, std::process::Output) { + let started = std::time::Instant::now(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .env(FAULT_CHILD_ENV, "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + while child.try_wait().unwrap().is_none() { + if started.elapsed() > CHILD_KILL_AFTER { + let _ = child.kill(); + panic!("{test_name} child process did not exit within {CHILD_KILL_AFTER:?}"); + } + std::thread::sleep(CHILD_POLL_INTERVAL); + } + let elapsed = started.elapsed(); + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed"), + "{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + (elapsed, output) + } + + pub(crate) fn is_fault_child() -> bool { + std::env::var_os(FAULT_CHILD_ENV).is_some() + } + + async fn stalled_keychain_lookup() -> Result< + Option, + services::token_storage::TokenStorageError, + > { + let _ = tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_hours(1))).await; + Ok(None) + } + + async fn stalled_blocking_database_lookup() -> Result< + Option, + services::token_storage::TokenStorageError, + > { + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_hours(1)); + Ok(None) + } + + #[test] + fn stalled_startup_credential_lookup_cannot_extend_process_lifetime() { + let (elapsed, _) = + run_fault_child("app::tests::isolated_stalled_startup_credential_lookup"); + let bound = services::managed_telemetry::CREDENTIAL_LOOKUP_DEADLINE * 2 + + RUNTIME_SHUTDOWN_GRACE + + Duration::from_secs(3); + assert!(elapsed < bound, "child exited after {elapsed:?}"); + } + + #[test] + fn isolated_stalled_startup_credential_lookup() { + if !is_fault_child() { + return; + } + let runtime = build_runtime().unwrap(); + runtime.block_on(async { + let keychain = RuntimeTelemetry::resolve_with(stalled_keychain_lookup()).await; + assert!(matches!(keychain, RuntimeTelemetry::Noop(_))); + let database = RuntimeTelemetry::resolve_with(stalled_blocking_database_lookup()).await; + assert!(matches!(database, RuntimeTelemetry::Noop(_))); + }); + finish_runtime(runtime); + } + + #[test] + fn stalled_telemetry_headers_lookup_fails_closed_within_deadline() { + let (elapsed, _) = run_fault_child("app::tests::isolated_stalled_telemetry_headers_lookup"); + let bound = services::managed_telemetry::CREDENTIAL_LOOKUP_DEADLINE + + RUNTIME_SHUTDOWN_GRACE + + Duration::from_secs(3); + assert!(elapsed < bound, "child exited after {elapsed:?}"); + } + + #[test] + fn isolated_stalled_telemetry_headers_lookup() { + if !is_fault_child() { + return; + } + let repo = tempfile::tempdir().unwrap(); + let runtime = build_runtime().unwrap(); + let result = runtime.block_on(services::auth_command::telemetry_headers_with( + Some(repo.path().to_path_buf()), + false, + |_: &std::path::Path| Some("repository-a".to_string()), + |_: String| stalled_keychain_lookup(), + )); + finish_runtime(runtime); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let code = app_support::render_run_outcome( + RunOutcome:: { + result, + logger: None, + startup_diagnostic: None, + }, + &mut stdout, + &mut stderr, + ); + assert_eq!(code, ExitCode::from(4)); + assert!(stdout.is_empty()); + let stderr = String::from_utf8(stderr).unwrap(); + assert!(stderr.contains("Telemetry credential is unavailable.")); + assert!(!stderr.contains("Bearer")); + } + #[test] fn app_output_boundary_uses_isolated_process() { let sandbox = tempfile::tempdir().unwrap(); @@ -690,4 +1071,26 @@ mod tests { b"An unexpected error occurred. Check the log files for more details.\n" ); } + + #[test] + fn hook_invocations_never_resolve_managed_telemetry() { + let hook = ["sce", "hooks", "pre-commit"].map(String::from); + let flagged_hook = ["sce", "--verbose", "hooks", "pre-commit"].map(String::from); + let other = ["sce", "config", "show"].map(String::from); + let policy = ["sce", "policy", "bash"].map(String::from); + let headers = ["sce", "auth", "telemetry-headers"].map(String::from); + let auth_login = ["sce", "auth", "login"].map(String::from); + assert!(is_hook_invocation(&policy)); + assert!(is_hook_invocation(&headers)); + assert!(!is_hook_invocation(&auth_login)); + assert!(is_hook_invocation(&hook)); + assert!(is_hook_invocation(&flagged_hook)); + assert!(!is_hook_invocation(&other)); + } + + #[tokio::test] + async fn disallowed_managed_telemetry_resolves_to_noop_without_credential_lookup() { + let telemetry = RuntimeTelemetry::resolve(false).await; + assert!(matches!(telemetry, RuntimeTelemetry::Noop(_))); + } } diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 112ee5061..c5348c670 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -260,6 +260,9 @@ pub enum AuthSubcommand { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, + + #[command(hide = true, about = "Print telemetry authorization headers")] + TelemetryHeaders, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/main.rs b/cli/src/main.rs index 313c38b66..cc201f990 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -9,7 +9,6 @@ mod services; use std::process::ExitCode; -#[tokio::main(flavor = "multi_thread")] -async fn main() -> ExitCode { - app::run(std::env::args()).await +fn main() -> ExitCode { + app::run_process(std::env::args()) } diff --git a/cli/src/services/agent_trace_sync/control_plane.rs b/cli/src/services/agent_trace_sync/control_plane.rs index 66736b16d..dc8a796bd 100644 --- a/cli/src/services/agent_trace_sync/control_plane.rs +++ b/cli/src/services/agent_trace_sync/control_plane.rs @@ -13,7 +13,7 @@ use crate::services::agent_trace_export::{ }; use crate::services::auth::{self, AuthError, TokenResponse}; use crate::services::resilience::{run_with_retry, RetryPolicy}; -use crate::services::telemetry_credential_storage::{self, TelemetryCredential}; +use crate::services::telemetry_credential_storage::{self, TelemetryCredential, TelemetryIdentity}; use crate::services::token_storage::{self, StoredTokens, TokenStorageError}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -274,6 +274,7 @@ impl fmt::Debug for TelemetryCredentialResponse { impl TelemetryCredentialResponse { fn into_credential( self, + identity: TelemetryIdentity, now_unix_seconds: u64, ) -> Result { let invalid = @@ -292,6 +293,7 @@ impl TelemetryCredentialResponse { token: self.token, accessor: self.accessor, expires_at_unix_seconds, + identity, }) } } @@ -402,18 +404,31 @@ impl AuthenticatedControlPlaneClient { async fn request_telemetry_credential(&self) -> Result { let url = self.endpoint(TELEMETRY_CREDENTIAL_PATH); + let issuing_token = std::sync::Mutex::new(None::); let response = self - .execute_authenticated(|token| self.http.post(&url).bearer_auth(token)) + .execute_authenticated(|token| { + if let Ok(mut slot) = issuing_token.lock() { + *slot = Some(token.to_string()); + } + self.http.post(&url).bearer_auth(token) + }) .await .map_err(sanitize_telemetry_error)?; let status = response.status(); if !status.is_success() { return Err(classify_telemetry_status(status)); } - let body: TelemetryCredentialResponse = response.json().await.map_err(|_| { - ControlPlaneError::InvalidResponse(TELEMETRY_INVALID_RESPONSE_MESSAGE.to_string()) - })?; - body.into_credential(current_unix_seconds()) + let invalid = + || ControlPlaneError::InvalidResponse(TELEMETRY_INVALID_RESPONSE_MESSAGE.to_string()); + let identity = issuing_token + .into_inner() + .ok() + .flatten() + .as_deref() + .and_then(TelemetryIdentity::from_access_token) + .ok_or_else(invalid)?; + let body: TelemetryCredentialResponse = response.json().await.map_err(|_| invalid())?; + body.into_credential(identity, current_unix_seconds()) } pub async fn issue_and_save_telemetry_credential( @@ -1056,6 +1071,51 @@ mod tests { (base_url, captured) } + struct IdentityStore(String); + + impl CredentialStore for IdentityStore { + async fn load(&self) -> Result, ControlPlaneError> { + Ok(Some(StoredTokens { + access_token: self.0.clone(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh".to_string(), + scope: None, + stored_at_unix_seconds: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_secs(), + })) + } + + async fn save(&self, _token: &TokenResponse) -> Result { + unreachable!("fixed credentials never refresh") + } + } + + fn telemetry_client( + base_url: String, + access_token: String, + ) -> AuthenticatedControlPlaneClient { + AuthenticatedControlPlaneClient::with_credential_store( + reqwest::Client::builder() + .tls_certs_only(Vec::new()) + .build() + .expect("test http client"), + base_url, + "http://127.0.0.1:1", + "client", + IdentityStore(access_token), + ) + } + + fn workspace_access_token() -> String { + crate::services::telemetry_credential_storage::test_access_token( + "user_a", + Some("workspace_a"), + ) + } + async fn request_telemetry_credential( status_line: &str, payload: &str, @@ -1067,16 +1127,7 @@ mod tests { Arc>, ) { let (base_url, captured) = start_capture_server(status_line, payload); - let client = AuthenticatedControlPlaneClient::with_credential_store( - reqwest::Client::builder() - .tls_certs_only(Vec::new()) - .build() - .expect("test http client"), - base_url, - "http://127.0.0.1:1", - "client", - FixedStore, - ); + let client = telemetry_client(base_url, workspace_access_token()); (client.issue_telemetry_credential().await, captured) } @@ -1108,13 +1159,25 @@ mod tests { assert_eq!(credential.token, TELEMETRY_SECRET_TOKEN); assert_eq!(credential.accessor, TELEMETRY_SECRET_ACCESSOR); assert_eq!(credential.expires_at_unix_seconds, 32_472_144_000); + assert_eq!( + credential.identity, + crate::services::telemetry_credential_storage::TelemetryIdentity { + user_id: "user_a".to_string(), + workspace_id: "workspace_a".to_string(), + } + ); let head = captured.lock().expect("capture lock").to_lowercase(); assert!( head.starts_with("post /auth/telemetry/credential "), "{head}" ); - assert!(head.contains("authorization: bearer access"), "{head}"); + assert!( + head.contains( + &format!("authorization: bearer {}", workspace_access_token()).to_lowercase() + ), + "{head}" + ); assert!( !head.contains("content-type: application/json"), "request must carry no JSON body: {head}" @@ -1128,16 +1191,7 @@ mod tests { async fn telemetry_credential_issue_and_save_replaces_cached_credential() { let (base_url, _) = start_capture_server("200 OK", &telemetry_success_payload("2999-01-01T00:00:00Z")); - let client = AuthenticatedControlPlaneClient::with_credential_store( - reqwest::Client::builder() - .tls_certs_only(Vec::new()) - .build() - .expect("test http client"), - base_url, - "http://127.0.0.1:1", - "client", - FixedStore, - ); + let client = telemetry_client(base_url, workspace_access_token()); let saved = Arc::new(Mutex::new(None)); let saved_credential = Arc::clone(&saved); @@ -1155,6 +1209,33 @@ mod tests { ); } + #[tokio::test] + async fn telemetry_credential_without_workspace_identity_is_rejected_and_not_saved() { + for access_token in [ + crate::services::telemetry_credential_storage::test_access_token("user_a", None), + "opaque-access-token".to_string(), + ] { + let (base_url, _) = + start_capture_server("200 OK", &telemetry_success_payload("2999-01-01T00:00:00Z")); + let client = telemetry_client(base_url, access_token); + let saved = Arc::new(Mutex::new(false)); + let saved_flag = Arc::clone(&saved); + let error = client + .issue_and_save_with(|_| async move { + *saved_flag.lock().expect("saved flag lock") = true; + Ok(()) + }) + .await + .expect_err("credential without workspace identity must be rejected"); + assert!( + matches!(&error, ControlPlaneError::InvalidResponse(_)), + "{error:?}" + ); + assert_no_telemetry_secret(&error); + assert!(!*saved.lock().expect("saved flag lock")); + } + } + #[tokio::test] async fn telemetry_credential_client_maps_failures_to_sanitized_errors() { let leaky = format!( @@ -1327,7 +1408,7 @@ mod tests { let client = telemetry_client_with_timeout( base_url, "http://127.0.0.1:1", - FixedStore, + IdentityStore(workspace_access_token()), SHORT_TELEMETRY_TIMEOUT, ); let error = @@ -1345,7 +1426,7 @@ mod tests { let client = telemetry_client_with_timeout( base_url, "http://127.0.0.1:1", - FixedStore, + IdentityStore(workspace_access_token()), SHORT_TELEMETRY_TIMEOUT, ); let error = @@ -1383,7 +1464,7 @@ mod tests { let client = telemetry_client_with_timeout( base_url, "http://127.0.0.1:1", - FixedStore, + IdentityStore(workspace_access_token()), TELEMETRY_TEST_DEADLINE, ); let credential = client diff --git a/cli/src/services/app_support.rs b/cli/src/services/app_support.rs index 98de5056d..364a36ab6 100644 --- a/cli/src/services/app_support.rs +++ b/cli/src/services/app_support.rs @@ -28,21 +28,51 @@ where StdoutW: Write, StderrW: Write, { - match outcome.result { + let rendered = render_command_result( + outcome.result, + outcome.logger.as_ref(), + outcome.startup_diagnostic.as_deref(), + stdout, + stderr, + ); + exit_code_for(&rendered) +} + +pub(crate) fn render_command_result( + result: Result, + logger: Option<&L>, + startup_diagnostic: Option<&str>, + stdout: &mut StdoutW, + stderr: &mut StderrW, +) -> Result<(), CliError> +where + L: LoggerTrait, + StdoutW: Write, + StderrW: Write, +{ + let error = match result { Ok(payload) => { - if let Some(diagnostic) = outcome.startup_diagnostic { - write_startup_diagnostic(stderr, &diagnostic); + if let Some(diagnostic) = startup_diagnostic { + write_startup_diagnostic(stderr, diagnostic); + } + match write_stdout_payload(stdout, &payload) { + Ok(()) => return Ok(()), + Err(error) => error, } - let logger = outcome.logger.as_ref(); - write_stdout_payload(stdout, &payload).map_or_else( - |error| exit_with_error(stderr, logger, &error), - |()| ExitCode::SUCCESS, - ) - } - Err(error) => { - let logger = outcome.logger.as_ref(); - exit_with_error(stderr, logger, &error) } + Err(error) => error, + }; + if let Some(log) = logger { + log.log_cli_error(&error, None); + } + write_error_diagnostic(stderr, &error); + Err(error) +} + +pub(crate) fn exit_code_for(result: &Result) -> ExitCode { + match result { + Ok(_) => ExitCode::SUCCESS, + Err(error) => ExitCode::from(error.class().exit_code()), } } @@ -137,27 +167,17 @@ where }) } -fn exit_with_error(stderr: &mut W, logger: Option<&L>, error: &CliError) -> ExitCode -where - L: LoggerTrait, - W: Write, -{ - if let Some(log) = logger { - log.log_cli_error(error, None); - } - write_error_diagnostic(stderr, error); - ExitCode::from(error.class().exit_code()) -} - fn write_stdout_payload(writer: &mut W, payload: &str) -> Result<(), CliError> { if payload.is_empty() { return Ok(()); } - writeln!(writer, "{payload}").map_err(|error| { - CliError::runtime(anyhow::Error::msg(format!( - "Failed to write command output to stdout: {error}" - ))) - }) + writeln!(writer, "{payload}") + .and_then(|()| writer.flush()) + .map_err(|error| { + CliError::runtime(anyhow::Error::msg(format!( + "Failed to write command output to stdout: {error}" + ))) + }) } fn write_error_diagnostic(writer: &mut W, error: &CliError) { @@ -186,8 +206,7 @@ fn write_error_diagnostic_with_color_policy( error: user_error, .. } => { let message = services::security::redact_sensitive_text(user_error.message()); - writeln!(writer, "{message}") - .expect("writing user error diagnostic to writer should not fail"); + let _ = writeln!(writer, "{message}"); return; } }; @@ -195,17 +214,15 @@ fn write_error_diagnostic_with_color_policy( &services::security::redact_sensitive_text(&rendered), color_enabled, ); - writeln!( + let _ = writeln!( writer, "{} [{}]: {}", services::style::heading_with_color_policy("Error", color_enabled), services::style::error_code_with_color_policy(error.code(), color_enabled), styled_message - ) - .expect("writing error diagnostic to writer should not fail"); + ); } fn write_startup_diagnostic(writer: &mut W, diagnostic: &str) { - writeln!(writer, "{}", services::style::error_code(diagnostic)) - .expect("writing startup diagnostic to writer should not fail"); + let _ = writeln!(writer, "{}", services::style::error_code(diagnostic)); } diff --git a/cli/src/services/auth_command/mod.rs b/cli/src/services/auth_command/mod.rs index c0e41918f..bd36da304 100644 --- a/cli/src/services/auth_command/mod.rs +++ b/cli/src/services/auth_command/mod.rs @@ -2,6 +2,7 @@ pub mod command; use std::future::Future; use std::io::Write; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use serde_json::json; @@ -12,6 +13,10 @@ use crate::services::agent_trace_sync::control_plane::{ use crate::services::auth::{self, AuthError, DeviceAuthFlowResult}; use crate::services::config; use crate::services::error::{CliError, UserError}; +use crate::services::managed_telemetry::claude_helper::{ + repository_id_for, visible_destination_conflict, +}; +use crate::services::managed_telemetry::{run_with_deadline, CREDENTIAL_LOOKUP_DEADLINE}; use crate::services::output_format::OutputFormat; use crate::services::style::{label, prompt_label, prompt_value, value}; use crate::services::telemetry_credential_storage; @@ -24,6 +29,7 @@ pub type AuthFormat = OutputFormat; const TELEMETRY_PROVISIONING_WARNING: &str = "Telemetry credential provisioning was unavailable. Retry with `sce auth login`."; const TELEMETRY_PROVISIONING_SUCCESS: &str = "Telemetry credential provisioned."; +const TELEMETRY_HEADERS_UNAVAILABLE: &str = "Telemetry credential is unavailable."; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum TelemetryProvisioningResult { @@ -52,6 +58,7 @@ pub enum AuthSubcommand { Login { format: AuthFormat }, Logout { format: AuthFormat }, Whoami { format: AuthFormat }, + TelemetryHeaders, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -60,16 +67,26 @@ pub struct AuthRequest { } pub async fn run_auth_subcommand(request: AuthRequest) -> Result { - run_auth_subcommand_with(request, run_login, run_logout, run_whoami).await + run_auth_subcommand_with( + request, + run_login, + run_logout, + run_whoami, + run_telemetry_headers, + ) + .await } -async fn run_auth_subcommand_with( +async fn run_auth_subcommand_with( request: AuthRequest, login: L, logout: O, whoami: S, + telemetry_headers: H, ) -> Result where + H: FnOnce() -> HF, + HF: Future>, L: FnOnce(AuthFormat) -> LF, LF: Future>, O: FnOnce(AuthFormat) -> OF, @@ -81,9 +98,66 @@ where AuthSubcommand::Login { format } => login(format).await, AuthSubcommand::Logout { format } => logout(format).await, AuthSubcommand::Whoami { format } => whoami(format).await, + AuthSubcommand::TelemetryHeaders => telemetry_headers().await, } } +async fn run_telemetry_headers() -> Result { + telemetry_headers_with( + std::env::current_dir().ok(), + visible_destination_conflict(|key| std::env::var(key).ok()), + repository_id_for, + telemetry_credential_storage::load_repository_telemetry_credential, + ) + .await +} + +pub(crate) async fn telemetry_headers_with( + working_dir: Option, + destination_conflict: bool, + resolve_repository: R, + lookup: L, +) -> Result +where + R: FnOnce(&Path) -> Option + Send + 'static, + L: FnOnce(String) -> Fut + Send + 'static, + Fut: Future< + Output = Result< + Option, + TokenStorageError, + >, + > + Send + + 'static, +{ + let Some(working_dir) = working_dir.filter(|_| !destination_conflict) else { + return Err(telemetry_headers_unavailable()); + }; + let credential = run_with_deadline(CREDENTIAL_LOOKUP_DEADLINE, async move { + let repository_id = resolve_repository(&working_dir)?; + lookup(repository_id).await.ok().flatten() + }) + .await + .flatten(); + render_telemetry_headers(Ok(credential)) +} + +pub(crate) fn render_telemetry_headers( + loaded: Result, TokenStorageError>, +) -> Result { + let Ok(Some(credential)) = loaded else { + return Err(telemetry_headers_unavailable()); + }; + + serde_json::to_string(&json!({ + "Authorization": format!("Bearer {}", credential.token), + })) + .map_err(|_| telemetry_headers_unavailable()) +} + +fn telemetry_headers_unavailable() -> CliError { + CliError::runtime(anyhow!(TELEMETRY_HEADERS_UNAVAILABLE)) +} + pub async fn run_login(format: AuthFormat) -> Result { let client = reqwest::Client::new(); let cwd = std::env::current_dir() @@ -223,15 +297,53 @@ where device_login(format).await } +async fn save_new_session_tokens(token: &auth::TokenResponse) -> Result { + replace_session_with( + telemetry_credential_storage::delete_telemetry_credential(), + || token_storage::save_tokens(token), + ) + .await +} + +async fn replace_session_with( + invalidate_telemetry: D, + save_session: S, +) -> Result +where + D: Future>, + S: FnOnce() -> SF, + SF: Future>, +{ + invalidate_telemetry.await.map_err(auth_storage_error)?; + save_session().await.map_err(auth_storage_error) +} + async fn provision_telemetry_credential( client: &AuthenticatedControlPlaneClient, ) -> TelemetryProvisioningResult { match client.issue_and_save_telemetry_credential().await { - Ok(_) => TelemetryProvisioningResult::Issued, + Ok(_) => { + enroll_current_repository_if_unbound().await; + TelemetryProvisioningResult::Issued + } Err(_) => TelemetryProvisioningResult::Unavailable, } } +async fn enroll_current_repository_if_unbound() { + let Some(repository_id) = std::env::current_dir() + .ok() + .and_then(|working_dir| repository_id_for(&working_dir)) + else { + return; + }; + let _ = telemetry_credential_storage::bind_repository_telemetry( + repository_id, + telemetry_credential_storage::RepositoryBindingMode::EnrollIfUnbound, + ) + .await; +} + async fn run_text_login( client: &reqwest::Client, client_id: &str, @@ -253,9 +365,7 @@ async fn run_text_login( .await .map_err(map_login_error)?; - let stored_tokens = token_storage::save_tokens(&token) - .await - .map_err(auth_storage_error)?; + let stored_tokens = save_new_session_tokens(&token).await?; let telemetry = provision_telemetry_credential(control_plane).await; render_login_result_with_telemetry( @@ -289,9 +399,7 @@ async fn run_login_json( .await .map_err(map_login_error)?; - let stored_tokens = token_storage::save_tokens(&token) - .await - .map_err(auth_storage_error)?; + let stored_tokens = save_new_session_tokens(&token).await?; let telemetry = provision_telemetry_credential(control_plane).await; render_login_result_with_telemetry( @@ -568,6 +676,113 @@ mod tests { TokenStorageError::Database("failed".to_string()) } + fn telemetry_credential(token: &str) -> telemetry_credential_storage::TelemetryCredential { + telemetry_credential_storage::TelemetryCredential { + token: token.to_string(), + accessor: "fixture-accessor".to_string(), + expires_at_unix_seconds: 4_000_000_000, + identity: telemetry_credential_storage::TelemetryIdentity { + user_id: "user_a".to_string(), + workspace_id: "workspace_a".to_string(), + }, + } + } + + #[test] + fn telemetry_headers_render_only_authorization_bearer_and_follow_replacement() { + let first = render_telemetry_headers(Ok(Some(telemetry_credential("tok-one")))) + .expect("valid credential should render"); + assert_eq!(first, r#"{"Authorization":"Bearer tok-one"}"#); + assert!(!first.contains("fixture-accessor")); + + let second = render_telemetry_headers(Ok(Some(telemetry_credential("tok-two")))) + .expect("replacement should render"); + assert_eq!(second, r#"{"Authorization":"Bearer tok-two"}"#); + } + + #[tokio::test] + async fn new_session_invalidates_previous_telemetry_credential_before_saving() { + let order = std::sync::Mutex::new(Vec::new()); + let saved = replace_session_with( + async { + order.lock().unwrap().push("invalidate"); + Ok(true) + }, + || async { + order.lock().unwrap().push("save"); + Ok(stored_tokens()) + }, + ) + .await + .expect("replacement should succeed"); + assert_eq!(saved, stored_tokens()); + assert_eq!(*order.lock().unwrap(), ["invalidate", "save"]); + + let save_called = std::sync::atomic::AtomicBool::new(false); + let failed = replace_session_with(async { Err(storage_failure()) }, || async { + save_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(stored_tokens()) + }) + .await; + assert!(failed.is_err()); + assert!(!save_called.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn telemetry_headers_fail_closed_before_lookup_without_repository_or_on_conflict() { + let lookup_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let lookup = |called: std::sync::Arc| { + move |_repository_id: String| async move { + called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(Some(telemetry_credential("tok-scoped"))) + } + }; + let working_dir = Some(PathBuf::from("/workspace/repo")); + + for (dir, conflict, repository) in [ + (working_dir.clone(), true, Some("repo-a")), + (None, false, Some("repo-a")), + (working_dir.clone(), false, None), + ] { + let error = telemetry_headers_with( + dir, + conflict, + move |_: &Path| repository.map(str::to_string), + lookup(std::sync::Arc::clone(&lookup_called)), + ) + .await + .expect_err("helper must fail closed"); + assert_eq!(error.to_string(), TELEMETRY_HEADERS_UNAVAILABLE); + } + assert!(!lookup_called.load(std::sync::atomic::Ordering::SeqCst)); + + let headers = telemetry_headers_with( + working_dir, + false, + |dir: &Path| (dir == Path::new("/workspace/repo")).then(|| "repo-a".to_string()), + |repository_id: String| async move { + assert_eq!(repository_id, "repo-a"); + Ok(Some(telemetry_credential("tok-scoped"))) + }, + ) + .await + .expect("authorized repository should render"); + assert_eq!(headers, r#"{"Authorization":"Bearer tok-scoped"}"#); + } + + #[test] + fn telemetry_headers_failures_use_fixed_secret_free_diagnostic() { + let missing = render_telemetry_headers(Ok(None)).expect_err("missing should fail"); + let storage = + render_telemetry_headers(Err(TokenStorageError::Database("tok-secret".to_string()))) + .expect_err("storage failure should fail"); + + for error in [missing, storage] { + assert_eq!(error.class(), crate::services::error::FailureClass::Runtime); + assert_eq!(error.to_string(), TELEMETRY_HEADERS_UNAVAILABLE); + } + } + fn device_auth_result() -> DeviceAuthFlowResult { DeviceAuthFlowResult { authorization: auth::DeviceAuthorizationResponse { diff --git a/cli/src/services/config/render.rs b/cli/src/services/config/render.rs index 1ab4ad00e..b2583ac3e 100644 --- a/cli/src/services/config/render.rs +++ b/cli/src/services/config/render.rs @@ -8,7 +8,7 @@ use super::resolver::{ WORKOS_CLIENT_ID_KEY, }; use super::types::{AgentTraceDbRetryConfig, DatabaseRetryConfig}; -use super::{ConfigPathSource, ReportFormat, ResolvedOptionalValue, ValueSource}; +use super::{ConfigPathSource, ReportFormat, ResolvedOptionalValue, ResolvedValue, ValueSource}; #[allow(clippy::too_many_lines)] pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { @@ -49,6 +49,7 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF &runtime.agent_trace_auto_sync.value.to_string(), runtime.agent_trace_auto_sync.source, ), + format_otel_text(runtime), format_bash_policies_text(&runtime.bash_policies), format_database_retry_text(&runtime.database_retry), format_validation_warnings_text(&warnings), @@ -94,6 +95,7 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF runtime.agent_trace_auto_sync.source, ), }, + "otel": format_otel_json(runtime), "policies": { "bash": format_bash_policies_json(&runtime.bash_policies), "database_retry": format_database_retry_json(&runtime.database_retry), @@ -107,6 +109,38 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF } } +fn otel_fields(runtime: &RuntimeConfig) -> [(&'static str, &ResolvedValue); 5] { + let otel = &runtime.otel; + [ + ("endpoint", &otel.endpoint), + ("protocol", &otel.protocol), + ("traces_endpoint", &otel.traces_endpoint), + ("metrics_endpoint", &otel.metrics_endpoint), + ("logs_endpoint", &otel.logs_endpoint), + ] +} + +fn format_otel_text(runtime: &RuntimeConfig) -> String { + otel_fields(runtime) + .iter() + .map(|(key, field)| { + format_resolved_value_text(&format!("otel.{key}"), &field.value, field.source) + }) + .collect::>() + .join("\n") +} + +fn format_otel_json(runtime: &RuntimeConfig) -> Value { + let mut fields = serde_json::Map::new(); + for (key, field) in otel_fields(runtime) { + fields.insert( + key.to_string(), + format_resolved_value_json(field.value.as_str(), field.source), + ); + } + Value::Object(fields) +} + pub(super) fn format_validate_output( runtime: &RuntimeConfig, report_format: ReportFormat, diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs index dc8db4e7c..00b2d32c8 100644 --- a/cli/src/services/config/resolver.rs +++ b/cli/src/services/config/resolver.rs @@ -12,6 +12,8 @@ use crate::services::default_paths::{ observability_log_dir, resolve_sce_default_locations, RepoPaths, }; +use crate::services::managed_telemetry::MANAGED_OTLP_DEFAULTS; + use super::policy::{build_validation_warnings, resolve_bash_policy_config, BashPolicyConfig}; use super::schema; use super::types::{ @@ -64,6 +66,45 @@ impl AuthConfigKeySpec { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ResolvedOtelConfig { + pub(super) endpoint: ResolvedValue, + pub(super) protocol: ResolvedValue, + pub(super) traces_endpoint: ResolvedValue, + pub(super) metrics_endpoint: ResolvedValue, + pub(super) logs_endpoint: ResolvedValue, +} + +fn resolve_otel_value( + file_value: Option>, + code_default: &str, +) -> ResolvedValue { + match file_value { + Some(value) => ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }, + None => ResolvedValue { + value: code_default.to_string(), + source: ValueSource::Default, + }, + } +} + +fn resolve_otel_config(file_config: schema::OtelFileConfig) -> ResolvedOtelConfig { + let defaults = MANAGED_OTLP_DEFAULTS; + ResolvedOtelConfig { + endpoint: resolve_otel_value(file_config.endpoint, defaults.endpoint), + protocol: resolve_otel_value(file_config.protocol, defaults.protocol), + traces_endpoint: resolve_otel_value(file_config.traces_endpoint, defaults.traces_endpoint), + metrics_endpoint: resolve_otel_value( + file_config.metrics_endpoint, + defaults.metrics_endpoint, + ), + logs_endpoint: resolve_otel_value(file_config.logs_endpoint, defaults.logs_endpoint), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct RuntimeConfig { pub(super) loaded_config_paths: Vec, @@ -78,6 +119,7 @@ pub(super) struct RuntimeConfig { pub(super) agent_trace_repository_id: ResolvedOptionalValue, pub(super) agent_trace_repository_remote: ResolvedValue, pub(super) agent_trace_auto_sync: ResolvedValue, + pub(super) otel: ResolvedOtelConfig, pub(super) bash_policies: ResolvedOptionalValue, pub(super) database_retry: ResolvedOptionalValue, pub(super) validation_errors: Vec, @@ -359,6 +401,7 @@ where agent_trace_repository_id: None, agent_trace_repository_remote: None, agent_trace_auto_sync: None, + otel: schema::OtelFileConfig::default(), bash_policy_presets: None, bash_policy_custom: None, database_retry: None, @@ -408,6 +451,7 @@ where if let Some(agent_trace_auto_sync) = layer.agent_trace_auto_sync { file_config.agent_trace_auto_sync = Some(agent_trace_auto_sync); } + merge_otel_layer(&mut file_config.otel, layer.otel); if let Some(bash_policy_presets) = layer.bash_policy_presets { file_config.bash_policy_presets = Some(bash_policy_presets); } @@ -584,6 +628,7 @@ where agent_trace_repository_id: resolved_agent_trace_repository_id, agent_trace_repository_remote: resolved_agent_trace_repository_remote, agent_trace_auto_sync: resolved_agent_trace_auto_sync, + otel: resolve_otel_config(file_config.otel), bash_policies: resolved_bash_policies, database_retry: resolved_database_retry, validation_errors, @@ -591,6 +636,24 @@ where }) } +fn merge_otel_layer(target: &mut schema::OtelFileConfig, layer: schema::OtelFileConfig) { + if layer.endpoint.is_some() { + target.endpoint = layer.endpoint; + } + if layer.protocol.is_some() { + target.protocol = layer.protocol; + } + if layer.traces_endpoint.is_some() { + target.traces_endpoint = layer.traces_endpoint; + } + if layer.metrics_endpoint.is_some() { + target.metrics_endpoint = layer.metrics_endpoint; + } + if layer.logs_endpoint.is_some() { + target.logs_endpoint = layer.logs_endpoint; + } +} + fn resolve_optional_auth_config_value( key: AuthConfigKeySpec, file_value: Option>, @@ -899,6 +962,46 @@ mod tests { ); } + #[test] + fn otel_resolves_to_code_defaults_when_block_absent() { + let runtime = resolve_runtime_with_config(None).unwrap(); + + assert_eq!(runtime.otel.endpoint.value, "https://otel.crocoderlab.dev"); + assert_eq!(runtime.otel.protocol.value, "http/protobuf"); + assert_eq!( + runtime.otel.traces_endpoint.value, + "https://otel.crocoderlab.dev/v1/traces" + ); + assert_eq!( + runtime.otel.metrics_endpoint.value, + "https://otel.crocoderlab.dev/v1/metrics" + ); + assert_eq!( + runtime.otel.logs_endpoint.value, + "https://otel.crocoderlab.dev/v1/logs" + ); + assert_eq!(runtime.otel.endpoint.source, ValueSource::Default); + assert_eq!(runtime.otel.logs_endpoint.source, ValueSource::Default); + } + + #[test] + fn otel_partial_block_keeps_code_defaults_for_omitted_fields() { + let runtime = resolve_runtime_with_config(Some( + r#"{"otel":{"logs_endpoint":"https://otel.crocoderlab.dev/v1/logs"}}"#, + )) + .unwrap(); + + assert_eq!( + runtime.otel.logs_endpoint.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + assert_eq!(runtime.otel.traces_endpoint.source, ValueSource::Default); + assert_eq!( + runtime.otel.traces_endpoint.value, + "https://otel.crocoderlab.dev/v1/traces" + ); + } + #[test] fn agent_trace_auto_sync_defaults_to_false_when_missing() { let runtime = resolve_runtime_with_config(None).unwrap(); diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index 449bc85e1..00f13a6e2 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -41,12 +41,13 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ super::resolver::WORKOS_CLIENT_ID_KEY.config_key, super::resolver::CONTROL_PLANE_BASE_URL_KEY.config_key, "agent_trace", + "otel", "policies", "integrations", ]; pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, log_to_file, workos_client_id, control_plane_base_url, agent_trace, policies, integrations, log_dir, log_file_retention_limit"; + "$schema, log_level, log_format, log_to_file, workos_client_id, control_plane_base_url, agent_trace, otel, policies, integrations, log_dir, log_file_retention_limit"; const PER_DB_RETRY_KEYS: &[&str] = &["connection_open", "query"]; const PER_DB_RETRY_KEYS_DESCRIPTION: &str = "connection_open, query"; @@ -89,6 +90,7 @@ pub(crate) struct ParsedFileConfigDocument { pub(crate) workos_client_id: Option, pub(crate) control_plane_base_url: Option, pub(crate) agent_trace: Option, + pub(crate) otel: Option, pub(crate) policies: Option, pub(crate) integrations: Option, } @@ -100,6 +102,15 @@ pub(crate) struct ParsedAgentTraceConfigDocument { pub(crate) auto_sync: Option, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedOtelConfigDocument { + pub(crate) endpoint: Option, + pub(crate) protocol: Option, + pub(crate) traces_endpoint: Option, + pub(crate) metrics_endpoint: Option, + pub(crate) logs_endpoint: Option, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub(crate) struct ParsedIntegrationsConfigDocument { pub(crate) target: Option>, @@ -174,6 +185,15 @@ pub(crate) struct FileConfigValue { pub(crate) source: ConfigPathSource, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct OtelFileConfig { + pub(crate) endpoint: Option>, + pub(crate) protocol: Option>, + pub(crate) traces_endpoint: Option>, + pub(crate) metrics_endpoint: Option>, + pub(crate) logs_endpoint: Option>, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct FileConfig { pub(crate) log_level: Option>, @@ -187,6 +207,7 @@ pub(crate) struct FileConfig { pub(crate) agent_trace_repository_id: Option>, pub(crate) agent_trace_repository_remote: Option>, pub(crate) agent_trace_auto_sync: Option>, + pub(crate) otel: OtelFileConfig, pub(crate) bash_policy_presets: Option>>, pub(crate) bash_policy_custom: Option>>, pub(crate) database_retry: Option>, @@ -337,6 +358,7 @@ pub(crate) fn parse_file_config( .map(|value| FileConfigValue { value, source }); let (agent_trace_repository_id, agent_trace_repository_remote, agent_trace_auto_sync) = map_agent_trace_config(typed.agent_trace.as_ref(), object, path, source)?; + let otel = map_otel_config(typed.otel.as_ref(), object, path, source)?; let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom, database_retry) = map_policies_config(typed.policies.as_ref(), object, path, source)?; let integrations = map_integrations_config(typed.integrations.as_ref(), object, path, source)?; @@ -353,6 +375,7 @@ pub(crate) fn parse_file_config( agent_trace_repository_id, agent_trace_repository_remote, agent_trace_auto_sync, + otel, bash_policy_presets, bash_policy_custom, database_retry, @@ -752,6 +775,53 @@ fn map_agent_trace_config( Ok((repository_id, repository_remote, auto_sync)) } +fn map_otel_config( + typed: Option<&ParsedOtelConfigDocument>, + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result { + let Some(otel_value) = object.get("otel") else { + return Ok(OtelFileConfig::default()); + }; + + let otel_object = otel_value.as_object().with_context(|| { + format!( + "Config key 'otel' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + otel_object, + path, + Some("otel"), + &[ + "endpoint", + "protocol", + "traces_endpoint", + "metrics_endpoint", + "logs_endpoint", + ], + "endpoint, protocol, traces_endpoint, metrics_endpoint, logs_endpoint", + )?; + + let field = |select: fn(&ParsedOtelConfigDocument) -> Option<&String>| { + typed.and_then(select).map(|value| FileConfigValue { + value: value.clone(), + source, + }) + }; + + Ok(OtelFileConfig { + endpoint: field(|doc| doc.endpoint.as_ref()), + protocol: field(|doc| doc.protocol.as_ref()), + traces_endpoint: field(|doc| doc.traces_endpoint.as_ref()), + metrics_endpoint: field(|doc| doc.metrics_endpoint.as_ref()), + logs_endpoint: field(|doc| doc.logs_endpoint.as_ref()), + }) +} + fn map_integrations_config( typed: Option<&ParsedIntegrationsConfigDocument>, object: &serde_json::Map, @@ -887,6 +957,25 @@ mod agent_trace_config_tests { assert!(error.contains("failed schema validation"), "{error}"); } + #[test] + fn omitted_otel_block_parses_as_unset() { + let config = parse("{}").unwrap(); + assert_eq!(config.otel, super::OtelFileConfig::default()); + } + + #[test] + fn parses_optional_otel_fields_independently() { + let config = parse(r#"{"otel":{"protocol":"http/protobuf"}}"#).unwrap(); + assert_eq!(config.otel.protocol.unwrap().value, "http/protobuf"); + assert!(config.otel.endpoint.is_none()); + assert!(config.otel.logs_endpoint.is_none()); + } + + #[test] + fn rejects_unknown_otel_key() { + assert!(parse(r#"{"otel":{"headers":"x"}}"#).is_err()); + } + #[test] fn omitted_agent_trace_block_parses_as_unset() { let config = parse("{}").unwrap(); diff --git a/cli/src/services/managed_telemetry.rs b/cli/src/services/managed_telemetry.rs new file mode 100644 index 000000000..74831a1ce --- /dev/null +++ b/cli/src/services/managed_telemetry.rs @@ -0,0 +1,255 @@ +use std::fmt; +use std::future::Future; +use std::time::Duration; + +pub(crate) mod claude_helper; +pub mod log_export; +pub mod trace_export; + +use crate::services::error::CliError; +use crate::services::observability::traits::{NoopTelemetry, Telemetry}; +use trace_export::ManagedTraceTelemetry; + +use crate::services::telemetry_credential_storage::{ + load_telemetry_credential, TelemetryCredential, +}; +use crate::services::token_storage::TokenStorageError; + +pub const MANAGED_OTLP_ENDPOINT: &str = "https://otel.crocoderlab.dev"; +pub const MANAGED_OTLP_PROTOCOL: &str = "http/protobuf"; +pub const MANAGED_OTLP_TRACES_ENDPOINT: &str = "https://otel.crocoderlab.dev/v1/traces"; +pub const MANAGED_OTLP_METRICS_ENDPOINT: &str = "https://otel.crocoderlab.dev/v1/metrics"; +pub const MANAGED_OTLP_LOGS_ENDPOINT: &str = "https://otel.crocoderlab.dev/v1/logs"; + +#[cfg(test)] +const TRACES_PATH: &str = "/v1/traces"; +#[cfg(test)] +const LOGS_PATH: &str = "/v1/logs"; +const REDACTED: &str = ""; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ManagedOtlpEndpoints { + pub endpoint: &'static str, + pub protocol: &'static str, + pub traces_endpoint: &'static str, + pub metrics_endpoint: &'static str, + pub logs_endpoint: &'static str, +} + +pub const MANAGED_OTLP_DEFAULTS: ManagedOtlpEndpoints = ManagedOtlpEndpoints { + endpoint: MANAGED_OTLP_ENDPOINT, + protocol: MANAGED_OTLP_PROTOCOL, + traces_endpoint: MANAGED_OTLP_TRACES_ENDPOINT, + metrics_endpoint: MANAGED_OTLP_METRICS_ENDPOINT, + logs_endpoint: MANAGED_OTLP_LOGS_ENDPOINT, +}; + +#[cfg(test)] +pub fn derive_signal_endpoint(base: &str, path: &str) -> String { + format!("{}{path}", base.trim_end_matches('/')) +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ManagedBearerToken(String); + +impl ManagedBearerToken { + pub fn authorization_header_value(&self) -> String { + format!("Bearer {}", self.0) + } +} + +impl fmt::Debug for ManagedBearerToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("ManagedBearerToken") + .field(&REDACTED) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ManagedTelemetryMode { + Noop, + Managed { + bearer: ManagedBearerToken, + endpoints: ManagedOtlpEndpoints, + }, +} + +pub(crate) fn select_mode( + credential: Option, + now_unix_seconds: u64, +) -> ManagedTelemetryMode { + match credential { + Some(credential) + if !credential.token.trim().is_empty() + && credential.expires_at_unix_seconds > now_unix_seconds => + { + ManagedTelemetryMode::Managed { + bearer: ManagedBearerToken(credential.token), + endpoints: MANAGED_OTLP_DEFAULTS, + } + } + _ => ManagedTelemetryMode::Noop, + } +} + +pub const CREDENTIAL_LOOKUP_DEADLINE: Duration = Duration::from_secs(2); + +pub(crate) async fn run_with_deadline(deadline: Duration, work: Fut) -> Option +where + Fut: Future + Send + 'static, + T: Send + 'static, +{ + let runtime = tokio::runtime::Handle::try_current().ok()?; + let task = runtime.spawn(work); + let abort = task.abort_handle(); + let joined = tokio::time::timeout(deadline, task).await; + abort.abort(); + joined.ok()?.ok() +} + +pub(crate) async fn load_credential_within_deadline(lookup: Fut) -> Option +where + Fut: Future, TokenStorageError>> + Send + 'static, +{ + run_with_deadline(CREDENTIAL_LOOKUP_DEADLINE, lookup) + .await + .and_then(Result::ok) + .flatten() +} + +fn now_unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +pub enum RuntimeTelemetry { + Noop(NoopTelemetry), + Managed(ManagedTraceTelemetry), +} + +impl RuntimeTelemetry { + pub async fn resolve(managed_allowed: bool) -> Self { + if !managed_allowed { + return Self::Noop(NoopTelemetry); + } + Self::resolve_with(load_telemetry_credential()).await + } + + pub(crate) async fn resolve_with(lookup: Fut) -> Self + where + Fut: Future, TokenStorageError>> + + Send + + 'static, + { + let credential = load_credential_within_deadline(lookup).await; + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return Self::Noop(NoopTelemetry); + }; + match select_mode(credential, now_unix_seconds()) { + ManagedTelemetryMode::Managed { bearer, endpoints } => { + ManagedTraceTelemetry::new(&bearer, endpoints, runtime) + .map_or(Self::Noop(NoopTelemetry), Self::Managed) + } + ManagedTelemetryMode::Noop => Self::Noop(NoopTelemetry), + } + } + + pub async fn shutdown(self) { + if let Self::Managed(telemetry) = self { + telemetry.shutdown().await; + } + } +} + +impl Telemetry for RuntimeTelemetry { + async fn with_default_subscriber(&self, action: &mut F) -> Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + match self { + Self::Noop(telemetry) => telemetry.with_default_subscriber(action).await, + Self::Managed(telemetry) => telemetry.with_default_subscriber(action).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: u64 = 1_000; + + fn credential(token: &str, expires_at_unix_seconds: u64) -> TelemetryCredential { + TelemetryCredential { + token: token.to_string(), + accessor: "local-accessor".to_string(), + expires_at_unix_seconds, + identity: crate::services::telemetry_credential_storage::TelemetryIdentity { + user_id: "user_a".to_string(), + workspace_id: "workspace_a".to_string(), + }, + } + } + + #[test] + fn managed_defaults_match_canonical_collector_contract() { + assert_eq!( + MANAGED_OTLP_DEFAULTS.endpoint, + "https://otel.crocoderlab.dev" + ); + assert_eq!(MANAGED_OTLP_DEFAULTS.protocol, "http/protobuf"); + assert_eq!( + MANAGED_OTLP_DEFAULTS.traces_endpoint, + "https://otel.crocoderlab.dev/v1/traces" + ); + assert_eq!( + MANAGED_OTLP_DEFAULTS.metrics_endpoint, + "https://otel.crocoderlab.dev/v1/metrics" + ); + assert_eq!( + MANAGED_OTLP_DEFAULTS.logs_endpoint, + "https://otel.crocoderlab.dev/v1/logs" + ); + } + + #[test] + fn signal_endpoints_derive_from_base_endpoint() { + assert_eq!( + derive_signal_endpoint(MANAGED_OTLP_ENDPOINT, TRACES_PATH), + MANAGED_OTLP_TRACES_ENDPOINT + ); + assert_eq!( + derive_signal_endpoint("https://otel.crocoderlab.dev/", LOGS_PATH), + MANAGED_OTLP_LOGS_ENDPOINT + ); + } + + #[test] + fn valid_unexpired_credential_selects_managed_mode_with_bearer_only() { + let mode = select_mode(Some(credential("bao-token", NOW + 60)), NOW); + match mode { + ManagedTelemetryMode::Managed { bearer, endpoints } => { + assert_eq!(bearer.authorization_header_value(), "Bearer bao-token"); + assert_eq!(endpoints, MANAGED_OTLP_DEFAULTS); + assert!(!format!("{bearer:?}").contains("bao-token")); + } + ManagedTelemetryMode::Noop => panic!("expected managed mode"), + } + } + + #[test] + fn absent_expired_or_blank_credential_selects_noop() { + assert_eq!(select_mode(None, NOW), ManagedTelemetryMode::Noop); + assert_eq!( + select_mode(Some(credential("bao-token", NOW)), NOW), + ManagedTelemetryMode::Noop + ); + assert_eq!( + select_mode(Some(credential(" ", NOW + 60)), NOW), + ManagedTelemetryMode::Noop + ); + } +} diff --git a/cli/src/services/managed_telemetry/claude_helper.rs b/cli/src/services/managed_telemetry/claude_helper.rs new file mode 100644 index 000000000..8dea4f180 --- /dev/null +++ b/cli/src/services/managed_telemetry/claude_helper.rs @@ -0,0 +1,155 @@ +use std::path::Path; + +use super::{ + MANAGED_OTLP_ENDPOINT, MANAGED_OTLP_LOGS_ENDPOINT, MANAGED_OTLP_METRICS_ENDPOINT, + MANAGED_OTLP_TRACES_ENDPOINT, +}; +use crate::services::config; +use crate::services::repository_identity::resolve::resolve_repository_identity; + +const GIT_DIR_NAME: &str = ".git"; +const DESTINATION_ENVS: [(&str, &str); 5] = [ + ("OTEL_EXPORTER_OTLP_ENDPOINT", MANAGED_OTLP_ENDPOINT), + ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + MANAGED_OTLP_TRACES_ENDPOINT, + ), + ( + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + MANAGED_OTLP_METRICS_ENDPOINT, + ), + ( + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + MANAGED_OTLP_LOGS_ENDPOINT, + ), + ("BETA_TRACING_ENDPOINT", MANAGED_OTLP_ENDPOINT), +]; + +fn normalized(value: &str) -> &str { + value.trim().trim_end_matches('/') +} + +pub fn visible_destination_conflict(lookup: F) -> bool +where + F: Fn(&str) -> Option, +{ + DESTINATION_ENVS + .iter() + .any(|(key, expected)| lookup(key).is_some_and(|value| normalized(&value) != *expected)) +} + +pub fn repository_id_for(working_dir: &Path) -> Option { + let root = working_dir + .ancestors() + .find(|dir| dir.join(GIT_DIR_NAME).exists())?; + let storage_config = config::resolve_agent_trace_storage_runtime_config(root).ok()?; + resolve_repository_identity( + root, + storage_config.repository_id.as_deref(), + &storage_config.repository_remote, + ) + .ok() + .map(|resolved| resolved.identity.repository_id) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use std::process::Command; + + use super::*; + + fn lookup_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect::>(); + move |key| map.get(key).cloned() + } + + pub(crate) fn git_repository_with_remote(parent: &Path, name: &str, remote: &str) -> PathBuf { + let root = parent.join(name); + fs::create_dir_all(root.join("src").join("module")).unwrap(); + for args in [ + vec!["init", "--quiet"], + vec!["remote", "add", "origin", remote], + ] { + let status = Command::new("git") + .arg("-C") + .arg(&root) + .args(args) + .status() + .unwrap(); + assert!(status.success()); + } + root + } + + #[test] + fn helper_without_any_visible_otel_variables_is_not_a_conflict() { + assert!(!visible_destination_conflict(lookup_from(&[]))); + assert!(!visible_destination_conflict(lookup_from(&[ + ( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "https://otel.crocoderlab.dev/" + ), + ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + MANAGED_OTLP_TRACES_ENDPOINT + ), + ("BETA_TRACING_ENDPOINT", MANAGED_OTLP_ENDPOINT), + ]))); + } + + #[test] + fn helper_refuses_visible_conflicting_destinations() { + for (key, value) in [ + ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), + ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "https://other.example/v1/traces", + ), + ( + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + MANAGED_OTLP_LOGS_ENDPOINT, + ), + ("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", ""), + ("BETA_TRACING_ENDPOINT", "https://collector.example"), + ] { + assert!( + visible_destination_conflict(lookup_from(&[(key, value)])), + "{key}={value}" + ); + } + } + + #[test] + fn helper_resolves_repository_identity_from_nested_launch_directory() { + let dir = tempfile::tempdir().unwrap(); + let first = + git_repository_with_remote(dir.path(), "first", "git@github.com:acme/first.git"); + let second = + git_repository_with_remote(dir.path(), "second", "https://github.com/acme/second"); + + let root_id = repository_id_for(&first).expect("repository identity"); + assert_eq!( + repository_id_for(&first.join("src").join("module")), + Some(root_id.clone()) + ); + assert_ne!(repository_id_for(&second), Some(root_id)); + assert_eq!(repository_id_for(&dir.path().join("not-a-repo")), None); + + let without_remote = dir.path().join("no-remote"); + fs::create_dir_all(&without_remote).unwrap(); + assert!(Command::new("git") + .arg("-C") + .arg(&without_remote) + .args(["init", "--quiet"]) + .status() + .unwrap() + .success()); + assert_eq!(repository_id_for(&without_remote), None); + } +} diff --git a/cli/src/services/managed_telemetry/log_export.rs b/cli/src/services/managed_telemetry/log_export.rs new file mode 100644 index 000000000..5227a3860 --- /dev/null +++ b/cli/src/services/managed_telemetry/log_export.rs @@ -0,0 +1,178 @@ +use std::fmt; + +use opentelemetry::logs::{AnyValue, LogRecord, Logger, Severity}; +use opentelemetry_sdk::logs::SdkLogger; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::Layer; + +use crate::services::security::redact_sensitive_text; + +pub const LOG_EVENT_TARGET: &str = "sce"; +const EVENT_ID_FIELD: &str = "event_id"; +const EVENT_MESSAGE_FIELD: &str = "event_message"; +const FIELDS_FIELD: &str = "fields"; +const EVENT_ID_ATTRIBUTE: &str = "sce.event_id"; +const MAX_VALUE_CHARS: usize = 160; +const ALLOWED_FIELD_KEYS: &[&str] = &[ + "command", + "component", + "failure_class", + "error_code", + "error_class", + "error_surface", + "user_error", +]; + +pub fn is_exportable_event(metadata: &tracing::Metadata<'_>) -> bool { + !metadata.is_span() && metadata.target() == LOG_EVENT_TARGET +} + +fn sanitize_value(value: &str) -> Option { + if value.contains('/') || value.contains('\\') || value.contains('\n') { + return None; + } + let redacted = redact_sensitive_text(value); + if redacted != value { + return None; + } + Some(redacted.chars().take(MAX_VALUE_CHARS).collect()) +} + +fn allowlisted_fields(fields_json: &str) -> Vec<(String, String)> { + let Ok(serde_json::Value::Object(map)) = serde_json::from_str(fields_json) else { + return Vec::new(); + }; + ALLOWED_FIELD_KEYS + .iter() + .filter_map(|key| { + let value = map.get(*key)?.as_str()?; + Some(((*key).to_string(), sanitize_value(value)?)) + }) + .collect() +} + +fn severity_for(level: Level) -> (Severity, &'static str) { + match level { + Level::ERROR => (Severity::Error, "ERROR"), + Level::WARN => (Severity::Warn, "WARN"), + Level::INFO => (Severity::Info, "INFO"), + Level::DEBUG | Level::TRACE => (Severity::Debug, "DEBUG"), + } +} + +#[derive(Default)] +struct EventFields { + event_id: Option, + message: Option, + fields_json: Option, +} + +impl Visit for EventFields { + fn record_str(&mut self, field: &Field, value: &str) { + match field.name() { + EVENT_ID_FIELD => self.event_id = Some(value.to_string()), + EVENT_MESSAGE_FIELD => self.message = Some(value.to_string()), + FIELDS_FIELD => self.fields_json = Some(value.to_string()), + _ => {} + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == FIELDS_FIELD { + self.fields_json = Some(format!("{value:?}")); + } + } +} + +pub struct LogBridgeLayer { + logger: SdkLogger, +} + +impl LogBridgeLayer { + pub fn new(logger: SdkLogger) -> Self { + Self { logger } + } +} + +impl Layer for LogBridgeLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + if !is_exportable_event(metadata) { + return; + } + let mut captured = EventFields::default(); + event.record(&mut captured); + let Some(event_id) = captured.event_id.as_deref().and_then(sanitize_value) else { + return; + }; + let body = captured + .message + .as_deref() + .and_then(sanitize_value) + .unwrap_or_else(|| event_id.clone()); + let (severity, severity_text) = severity_for(*metadata.level()); + + let mut record = self.logger.create_log_record(); + record.set_severity_number(severity); + record.set_severity_text(severity_text); + record.set_body(AnyValue::String(body.into())); + record.add_attribute(EVENT_ID_ATTRIBUTE, event_id); + for (key, value) in captured + .fields_json + .as_deref() + .map(allowlisted_fields) + .unwrap_or_default() + { + record.add_attribute(format!("sce.{key}"), value); + } + self.logger.emit(record); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlist_keeps_only_approved_safe_fields() { + let json = r#"{"command":"config","component":"observability","argv":"--token x","repo":"/home/u/repo","error_code":"SCE-ERR-RUNTIME","error_class":"runtime","user_error":"Bearer abc"}"#; + let fields = allowlisted_fields(json); + let keys = fields + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(); + assert_eq!(keys, ["command", "component", "error_code", "error_class"]); + } + + #[test] + fn enabled_hint_for_sce_events_passes_export_filter() { + use tracing_subscriber::filter::filter_fn; + use tracing_subscriber::layer::SubscriberExt; + + let layer = tracing_subscriber::layer::Identity::new().with_filter(filter_fn( + is_exportable_event as fn(&tracing::Metadata<'_>) -> bool, + )); + let dispatch = tracing::Dispatch::new(tracing_subscriber::Registry::default().with(layer)); + tracing::dispatcher::with_default(&dispatch, || { + assert!(tracing::enabled!(target: "sce", Level::ERROR)); + assert!(!tracing::enabled!(target: "other", Level::ERROR)); + }); + } + + #[test] + fn sanitize_rejects_paths_and_secrets_and_bounds_length() { + assert_eq!(sanitize_value("/home/u/repo"), None); + assert_eq!(sanitize_value("token=abc123"), None); + assert_eq!(sanitize_value("Authorization: Bearer abc"), None); + assert_eq!( + sanitize_value(&"a".repeat(500)).map(|v| v.len()), + Some(MAX_VALUE_CHARS) + ); + } +} diff --git a/cli/src/services/managed_telemetry/trace_export.rs b/cli/src/services/managed_telemetry/trace_export.rs new file mode 100644 index 000000000..f629d90c4 --- /dev/null +++ b/cli/src/services/managed_telemetry/trace_export.rs @@ -0,0 +1,665 @@ +use std::future::Future; +use std::time::Duration; + +use opentelemetry::logs::LoggerProvider as _; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry::KeyValue; +use opentelemetry_http::{Bytes, HttpClient, HttpError, Request, Response}; +use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig, WithHttpConfig}; +use opentelemetry_sdk::logs::{ + BatchConfigBuilder as LogBatchConfigBuilder, BatchLogProcessor, SdkLoggerProvider, +}; +use opentelemetry_sdk::trace::{BatchConfigBuilder, BatchSpanProcessor, SdkTracerProvider}; +use opentelemetry_sdk::Resource; +use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use tracing::instrument::WithSubscriber; +use tracing::{field, Dispatch, Instrument, Span}; +use tracing_subscriber::filter::filter_fn; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::{Layer, Registry}; + +use super::log_export::{is_exportable_event, LogBridgeLayer}; +use super::{ManagedBearerToken, ManagedOtlpEndpoints}; +use crate::services::error::CliError; +use crate::services::observability::traits::Telemetry; + +const SERVICE_NAME: &str = "sce"; +const TRACER_NAME: &str = "sce.cli"; +pub const COMMAND_SPAN_TARGET: &str = "sce.managed.trace"; +const COMMAND_SPAN_NAME: &str = "sce.command"; +pub const COMMAND_NAME_FIELD: &str = "sce.command.name"; +const OUTCOME_FIELD: &str = "sce.outcome"; +const ERROR_CLASS_FIELD: &str = "sce.error.class"; +const OUTCOME_SUCCESS: &str = "success"; +const OUTCOME_FAILURE: &str = "failure"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const EXPORT_TIMEOUT: Duration = Duration::from_secs(3); +const BATCH_QUEUE_SIZE: usize = 64; +const BATCH_EXPORT_SIZE: usize = 32; +const SCHEDULED_DELAY: Duration = Duration::from_millis(500); +pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug)] +struct PinnedBearerClient { + client: reqwest::Client, + runtime: tokio::runtime::Handle, + endpoint: &'static str, + authorization: HeaderValue, +} + +#[async_trait::async_trait] +impl HttpClient for PinnedBearerClient { + async fn send_bytes(&self, request: Request) -> Result, HttpError> { + let content_type = request.headers().get(CONTENT_TYPE).cloned(); + let body = request.into_body(); + let mut builder = self + .client + .post(self.endpoint) + .header(AUTHORIZATION, self.authorization.clone()) + .body(body); + if let Some(content_type) = content_type { + builder = builder.header(CONTENT_TYPE, content_type); + } + let status = self + .runtime + .spawn(async move { builder.send().await.map(|response| response.status()) }) + .await??; + let response = Response::builder().status(status).body(Bytes::new())?; + Ok(response) + } +} + +fn is_command_span(metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_span() && metadata.target() == COMMAND_SPAN_TARGET +} + +#[derive(Debug)] +pub struct ManagedTraceTelemetry { + provider: SdkTracerProvider, + log_provider: SdkLoggerProvider, + dispatch: Dispatch, +} + +impl ManagedTraceTelemetry { + pub fn new( + bearer: &ManagedBearerToken, + endpoints: ManagedOtlpEndpoints, + runtime: tokio::runtime::Handle, + ) -> anyhow::Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build()?; + Self::with_client(bearer, endpoints, runtime, client) + } + + fn with_client( + bearer: &ManagedBearerToken, + endpoints: ManagedOtlpEndpoints, + runtime: tokio::runtime::Handle, + client: reqwest::Client, + ) -> anyhow::Result { + let mut authorization = HeaderValue::from_str(&bearer.authorization_header_value())?; + authorization.set_sensitive(true); + let http_client = PinnedBearerClient { + client: client.clone(), + runtime: runtime.clone(), + endpoint: endpoints.traces_endpoint, + authorization: authorization.clone(), + }; + let log_http_client = PinnedBearerClient { + client, + runtime, + endpoint: endpoints.logs_endpoint, + authorization, + }; + let exporter = SpanExporter::builder() + .with_http() + .with_http_client(http_client) + .with_endpoint(endpoints.traces_endpoint) + .with_timeout(EXPORT_TIMEOUT) + .build()?; + let batch_config = BatchConfigBuilder::default() + .with_max_queue_size(BATCH_QUEUE_SIZE) + .with_max_export_batch_size(BATCH_EXPORT_SIZE) + .with_scheduled_delay(SCHEDULED_DELAY) + .build(); + let processor = BatchSpanProcessor::builder(exporter) + .with_batch_config(batch_config) + .build(); + let resource = Resource::builder_empty() + .with_attributes([ + KeyValue::new("service.name", SERVICE_NAME), + KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), + ]) + .build(); + let resource_for_logs = resource.clone(); + let provider = SdkTracerProvider::builder() + .with_resource(resource) + .with_span_processor(processor) + .build(); + let log_exporter = LogExporter::builder() + .with_http() + .with_http_client(log_http_client) + .with_endpoint(endpoints.logs_endpoint) + .with_timeout(EXPORT_TIMEOUT) + .build()?; + let log_batch_config = LogBatchConfigBuilder::default() + .with_max_queue_size(BATCH_QUEUE_SIZE) + .with_max_export_batch_size(BATCH_EXPORT_SIZE) + .with_scheduled_delay(SCHEDULED_DELAY) + .build(); + let log_provider = SdkLoggerProvider::builder() + .with_resource(resource_for_logs) + .with_log_processor( + BatchLogProcessor::builder(log_exporter) + .with_batch_config(log_batch_config) + .build(), + ) + .build(); + let span_layer = tracing_opentelemetry::layer() + .with_tracer(provider.tracer(TRACER_NAME)) + .with_filter(filter_fn( + is_command_span as fn(&tracing::Metadata<'_>) -> bool, + )); + let log_layer = LogBridgeLayer::new(log_provider.logger(TRACER_NAME)).with_filter( + filter_fn(is_exportable_event as fn(&tracing::Metadata<'_>) -> bool), + ); + let dispatch = Dispatch::new(Registry::default().with(span_layer).with(log_layer)); + Ok(Self { + provider, + log_provider, + dispatch, + }) + } + + pub async fn shutdown(self) { + let deadline = tokio::time::Instant::now() + SHUTDOWN_TIMEOUT; + let Self { + provider, + log_provider, + dispatch, + } = self; + drop(dispatch); + let traces = + tokio::task::spawn_blocking(move || provider.shutdown_with_timeout(SHUTDOWN_TIMEOUT)); + let logs = tokio::task::spawn_blocking(move || { + log_provider.shutdown_with_timeout(SHUTDOWN_TIMEOUT) + }); + let _ = tokio::time::timeout_at(deadline, async { + let _ = traces.await; + let _ = logs.await; + }) + .await; + } + + fn command_span(&self) -> Span { + tracing::dispatcher::with_default(&self.dispatch, || { + tracing::info_span!( + target: COMMAND_SPAN_TARGET, + COMMAND_SPAN_NAME, + sce.command.name = field::Empty, + sce.outcome = field::Empty, + sce.error.class = field::Empty, + ) + }) + } +} + +impl Telemetry for ManagedTraceTelemetry { + async fn with_default_subscriber(&self, action: &mut F) -> Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + let span = self.command_span(); + let result = action() + .instrument(span.clone()) + .with_subscriber(self.dispatch.clone()) + .await; + match &result { + Ok(_) => { + span.record(OUTCOME_FIELD, OUTCOME_SUCCESS); + } + Err(error) => { + span.record(OUTCOME_FIELD, OUTCOME_FAILURE); + span.record(ERROR_CLASS_FIELD, error.code()); + } + } + drop(span); + result + } +} + +pub fn record_command_name(name: &str) { + Span::current().record(COMMAND_NAME_FIELD, name); +} + +#[cfg(test)] +pub(crate) mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + use crate::services::managed_telemetry::{ManagedTelemetryMode, MANAGED_OTLP_DEFAULTS}; + + pub(crate) struct CapturedRequest { + pub(crate) head: String, + pub(crate) body: Vec, + } + + pub(crate) async fn capture_requests( + listener: TcpListener, + status_line: &'static str, + expected: usize, + ) -> Vec { + let mut captured = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while captured.len() < expected { + let Ok(request) = + tokio::time::timeout_at(deadline, capture_one_request(&listener, status_line)) + .await + else { + break; + }; + captured.push(request); + } + captured + } + + async fn capture_one_request( + listener: &TcpListener, + status_line: &'static str, + ) -> CapturedRequest { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut chunk).await.expect("read"); + assert!(read > 0, "connection closed before headers"); + buffer.extend_from_slice(&chunk[..read]); + if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let content_length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while buffer.len() < header_end + content_length { + let read = stream.read(&mut chunk).await.expect("read body"); + assert!(read > 0, "connection closed before body"); + buffer.extend_from_slice(&chunk[..read]); + } + let response = format!("{status_line}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"); + stream + .write_all(response.as_bytes()) + .await + .expect("respond"); + CapturedRequest { + head, + body: buffer[header_end..].to_vec(), + } + } + + pub(crate) fn loopback_telemetry( + address: std::net::SocketAddr, + token: &str, + ) -> ManagedTraceTelemetry { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .tls_certs_only(Vec::new()) + .build() + .expect("test http client"); + loopback_telemetry_with_client(address, token, client) + } + + fn loopback_telemetry_with_client( + address: std::net::SocketAddr, + token: &str, + client: reqwest::Client, + ) -> ManagedTraceTelemetry { + let traces_endpoint: &'static str = + Box::leak(format!("http://{address}/v1/traces").into_boxed_str()); + let logs_endpoint: &'static str = + Box::leak(format!("http://{address}/v1/logs").into_boxed_str()); + let endpoints = ManagedOtlpEndpoints { + traces_endpoint, + logs_endpoint, + ..MANAGED_OTLP_DEFAULTS + }; + let mode = crate::services::managed_telemetry::select_mode( + Some( + crate::services::telemetry_credential_storage::TelemetryCredential { + token: token.to_string(), + accessor: "local-accessor-secret".to_string(), + expires_at_unix_seconds: u64::MAX, + identity: crate::services::telemetry_credential_storage::TelemetryIdentity { + user_id: "user_a".to_string(), + workspace_id: "workspace_a".to_string(), + }, + }, + ), + 0, + ); + let ManagedTelemetryMode::Managed { bearer, .. } = mode else { + panic!("expected managed mode"); + }; + ManagedTraceTelemetry::with_client( + &bearer, + endpoints, + tokio::runtime::Handle::current(), + client, + ) + .expect("telemetry") + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn managed_trace_export_posts_allowlisted_span_with_bearer_only() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + let receiver = tokio::spawn(capture_requests(listener, "HTTP/1.1 200 OK", 2)); + let telemetry = loopback_telemetry(address, "bao-token-value"); + + let result = telemetry + .with_default_subscriber(&mut || async { + record_command_name("config"); + let fields_json = r#"{"command":"config","argv":"--token secret-arg","repo":"/home/u/repo","error_source":"Bearer leak-value"}"#; + tracing::info!(target: "sce", event_id = "sce.test.event", event_message = "Starting command", fields = %fields_json); + tokio::task::yield_now().await; + Ok("payload".to_string()) + }) + .await; + assert_eq!(result.expect("command result"), "payload"); + telemetry.shutdown().await; + + let requests = receiver.await.expect("receiver join"); + assert_eq!( + requests.len(), + 2, + "{:?}", + requests + .iter() + .map(|r| r.head.lines().next().unwrap_or_default().to_string()) + .collect::>() + ); + for request in &requests { + let head = request.head.to_ascii_lowercase(); + assert!(head.contains("authorization: bearer bao-token-value")); + assert!(head.contains("application/x-protobuf")); + assert!(!head.contains("local-accessor-secret")); + } + let trace = requests + .iter() + .find(|request| { + request + .head + .to_ascii_lowercase() + .starts_with("post /v1/traces ") + }) + .expect("trace request"); + let log = requests + .iter() + .find(|request| { + request + .head + .to_ascii_lowercase() + .starts_with("post /v1/logs ") + }) + .expect("log request"); + let log_body = String::from_utf8_lossy(&log.body).to_string(); + for expected in [ + "sce.test.event", + "Starting command", + "config", + "service.name", + ] { + assert!(log_body.contains(expected), "missing log {expected}"); + } + for leaked in [ + "secret-arg", + "/home/u/repo", + "leak-value", + "bao-token-value", + "local-accessor-secret", + ] { + assert!(!log_body.contains(leaked), "leaked {leaked}"); + } + let body = String::from_utf8_lossy(&trace.body).to_string(); + for expected in [ + "sce.command", + "sce.command.name", + "config", + "service.name", + "success", + ] { + assert!(body.contains(expected), "missing {expected}"); + } + assert!(!body.contains("local-accessor-secret")); + assert!(!body.contains("bao-token-value")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn managed_trace_export_does_not_follow_redirects_or_alter_results() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + let receiver = tokio::spawn(capture_requests( + listener, + "HTTP/1.1 307 Temporary Redirect\r\nlocation: http://127.0.0.1:1/steal", + 1, + )); + let telemetry = loopback_telemetry(address, "bao-token-value"); + + let result = telemetry + .with_default_subscriber(&mut || async { + Err(CliError::runtime(anyhow::anyhow!("boom"))) + }) + .await; + assert!(result.is_err()); + telemetry.shutdown().await; + + let requests = receiver.await.expect("receiver join"); + assert!(!requests.is_empty()); + for request in &requests { + let head = request.head.to_ascii_lowercase(); + assert!(head.starts_with("post /v1/traces ") || head.starts_with("post /v1/logs ")); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unreachable_collector_does_not_block_command_or_shutdown() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + drop(listener); + let telemetry = loopback_telemetry(address, "bao-token-value"); + + let result = telemetry + .with_default_subscriber(&mut || async { Ok("ok".to_string()) }) + .await; + assert_eq!(result.expect("result"), "ok"); + let started = std::time::Instant::now(); + telemetry.shutdown().await; + assert!(started.elapsed() < Duration::from_secs(6)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn ambient_otlp_environment_cannot_redirect_or_inject_headers() { + let alternate = TcpListener::bind("127.0.0.1:0").await.expect("bind alt"); + let alternate_address = alternate.local_addr().expect("alt addr"); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + std::env::set_var( + "OTEL_EXPORTER_OTLP_ENDPOINT", + format!("http://{alternate_address}"), + ); + std::env::set_var( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + format!("http://{alternate_address}/v1/traces"), + ); + std::env::set_var("OTEL_EXPORTER_OTLP_HEADERS", "x-ambient-leak=1"); + std::env::set_var("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc"); + let receiver = tokio::spawn(capture_requests(listener, "HTTP/1.1 200 OK", 2)); + let telemetry = loopback_telemetry(address, "bao-token-value"); + + let result = telemetry + .with_default_subscriber(&mut || async { + record_command_name("config"); + tracing::info!(target: "sce", event_id = "sce.test.event", event_message = "Starting command"); + Ok("payload".to_string()) + }) + .await; + assert_eq!(result.expect("command result"), "payload"); + telemetry.shutdown().await; + for name in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + ] { + std::env::remove_var(name); + } + + let requests = receiver.await.expect("receiver join"); + assert_eq!(requests.len(), 2); + for request in &requests { + let head = request.head.to_ascii_lowercase(); + assert!(head.contains("authorization: bearer bao-token-value")); + assert!(!head.contains("x-ambient-leak")); + assert!(head.contains("application/x-protobuf")); + } + let unexpected = tokio::time::timeout(Duration::from_millis(300), alternate.accept()).await; + assert!(unexpected.is_err(), "alternate receiver was contacted"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stalled_collector_does_not_block_command_and_shutdown_is_bounded() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + let holder = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + held.push(stream); + } + }); + let telemetry = loopback_telemetry(address, "bao-token-value"); + + let command_started = std::time::Instant::now(); + let result = telemetry + .with_default_subscriber(&mut || async { Ok("ok".to_string()) }) + .await; + assert_eq!(result.expect("result"), "ok"); + assert!(command_started.elapsed() < Duration::from_secs(1)); + let started = std::time::Instant::now(); + telemetry.shutdown().await; + assert!(started.elapsed() < Duration::from_secs(6)); + holder.abort(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_commands_keep_results_and_export_isolated() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + let receiver = tokio::spawn(capture_requests(listener, "HTTP/1.1 200 OK", 1)); + let telemetry = std::sync::Arc::new(loopback_telemetry(address, "bao-token-value")); + + let first = { + let telemetry = telemetry.clone(); + tokio::spawn(async move { + telemetry + .with_default_subscriber(&mut || async { + record_command_name("first"); + tokio::task::yield_now().await; + Ok("one".to_string()) + }) + .await + }) + }; + let second = { + let telemetry = telemetry.clone(); + tokio::spawn(async move { + telemetry + .with_default_subscriber(&mut || async { + record_command_name("second"); + tokio::task::yield_now().await; + Err(CliError::runtime(anyhow::anyhow!("boom"))) + }) + .await + }) + }; + assert_eq!(first.await.expect("first join").expect("first"), "one"); + assert!(second.await.expect("second join").is_err()); + std::sync::Arc::try_unwrap(telemetry) + .expect("sole owner") + .shutdown() + .await; + + let requests = receiver.await.expect("receiver join"); + let trace = requests + .iter() + .find(|request| { + request + .head + .to_ascii_lowercase() + .starts_with("post /v1/traces ") + }) + .expect("trace request"); + let body = String::from_utf8_lossy(&trace.body).to_string(); + assert!(body.contains("first")); + assert!(body.contains("second")); + assert!(body.contains("success")); + assert!(body.contains("failure")); + } + + #[test] + fn stalled_exporter_shutdown_cannot_extend_process_lifetime() { + let (elapsed, _) = crate::app::tests::run_fault_child( + "services::managed_telemetry::trace_export::tests::isolated_stalled_exporter_shutdown", + ); + let bound = SHUTDOWN_TIMEOUT + Duration::from_secs(2); + assert!(elapsed < bound, "child exited after {elapsed:?}"); + } + + #[test] + fn isolated_stalled_exporter_shutdown() { + if !crate::app::tests::is_fault_child() { + return; + } + let runtime = crate::app::build_runtime().expect("runtime"); + runtime.block_on(async { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("addr"); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + held.push(stream); + } + }); + let unbounded_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .tls_certs_only(Vec::new()) + .build() + .expect("test http client"); + let telemetry = + loopback_telemetry_with_client(address, "bao-token-value", unbounded_client); + let result = telemetry + .with_default_subscriber(&mut || async { + record_command_name("config"); + tracing::error!(target: "sce", event_id = "sce.test.event", event_message = "failed"); + Err(CliError::runtime(anyhow::anyhow!("boom"))) + }) + .await; + assert!(result.is_err()); + let started = std::time::Instant::now(); + telemetry.shutdown().await; + assert!(started.elapsed() < SHUTDOWN_TIMEOUT + Duration::from_millis(500)); + }); + crate::app::finish_runtime(runtime); + } +} diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index 126b23863..2612c646b 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -23,6 +23,7 @@ pub mod help; pub mod hooks; pub mod lifecycle; pub mod local_db; +pub mod managed_telemetry; pub mod mutation_trace; pub mod observability; pub mod output_format; diff --git a/cli/src/services/observability.rs b/cli/src/services/observability.rs index 3d117b754..bd0445f48 100644 --- a/cli/src/services/observability.rs +++ b/cli/src/services/observability.rs @@ -73,6 +73,19 @@ impl Logger { }) } + #[cfg(test)] + pub(crate) fn without_log_file() -> Self { + Self { + config: ObservabilityConfig { + level: LogLevel::Error, + format: LogFormat::Text, + log_to_file: false, + }, + log_dir: None, + log_file_retention_limit: 0, + } + } + pub fn info( &self, event_id: &str, diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index c40ef758c..38d9999fb 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -398,6 +398,9 @@ fn convert_auth_subcommand( cli_schema::AuthSubcommand::Whoami { format } => { services::auth_command::AuthSubcommand::Whoami { format } } + cli_schema::AuthSubcommand::TelemetryHeaders => { + services::auth_command::AuthSubcommand::TelemetryHeaders + } }; Ok(RuntimeCommand::Auth( @@ -837,6 +840,22 @@ mod tests { assert!(!command.text.contains("renew")); } + #[test] + fn telemetry_headers_parses_and_is_hidden_from_help() { + let command = parse(&["sce", "auth", "telemetry-headers"]); + + let RuntimeCommand::Auth(command) = command else { + panic!("expected auth command"); + }; + assert_eq!( + command.request.subcommand, + services::auth_command::AuthSubcommand::TelemetryHeaders + ); + + assert!(!cli_schema::auth_help_text().contains("telemetry-headers")); + assert!(!services::help::help_text().contains("telemetry-headers")); + } + #[test] fn removed_auth_renew_command_routes_to_auth_help() { assert_eq!( diff --git a/cli/src/services/setup/claude_telemetry.rs b/cli/src/services/setup/claude_telemetry.rs new file mode 100644 index 000000000..8d7abcf09 --- /dev/null +++ b/cli/src/services/setup/claude_telemetry.rs @@ -0,0 +1,328 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; + +use super::config_merge::{ + merge_claude_telemetry_settings, ClaudeTelemetryMergeOutcome, ClaudeTelemetrySkipReason, +}; + +const CLAUDE_CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR"; +const CLAUDE_SETTINGS_FILE_NAME: &str = "settings.json"; +const CLAUDE_USER_DIR_NAME: &str = ".claude"; +const LINUX_MANAGED_SETTINGS_PATH: &str = "/etc/claude-code/managed-settings.json"; +const MACOS_MANAGED_SETTINGS_PATH: &str = + "/Library/Application Support/ClaudeCode/managed-settings.json"; +const NEW_SETTINGS_FILE_MODE: u32 = 0o600; +const TEMP_SUFFIX: &str = ".sce-tmp"; + +const CONFIGURED_LINE: &str = + "Claude telemetry: configured in user-level Claude settings for native OpenTelemetry export."; +const AUTH_PENDING_LINE: &str = + "Claude telemetry: authentication pending for this repository; run 'sce auth login' in this repository to start exporting."; +const SKIPPED_HELPER_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define a different otelHeadersHelper."; +const SKIPPED_DISABLED_LINE: &str = + "Claude telemetry: skipped; existing Claude settings explicitly disable telemetry or tracing."; +const SKIPPED_PRIVACY_LINE: &str = + "Claude telemetry: skipped; existing Claude settings explicitly opt out of repository attributes."; +const SKIPPED_ENDPOINT_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define a different OTLP endpoint."; +const SKIPPED_HEADERS_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define OTLP headers."; +const SKIPPED_PROTOCOL_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define a different OTLP protocol."; +const SKIPPED_EXPORTER_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define a different telemetry exporter."; +const SKIPPED_HELPER_SETTING_LINE: &str = + "Claude telemetry: skipped; existing Claude settings define a different headers helper refresh interval."; +const SKIPPED_MANAGED_LINE: &str = + "Claude telemetry: skipped; managed Claude settings own the telemetry configuration."; +const SKIPPED_INVALID_LINE: &str = + "Claude telemetry: skipped; existing Claude settings could not be parsed and were left unchanged."; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum ClaudeTelemetryInstallOutcome { + Configured, + SkippedConflict(ClaudeTelemetrySkipReason), + SkippedInvalid, +} + +pub(crate) fn resolve_claude_settings_path() -> Option { + if let Some(dir) = env::var_os(CLAUDE_CONFIG_DIR_ENV).filter(|value| !value.is_empty()) { + return Some(PathBuf::from(dir).join(CLAUDE_SETTINGS_FILE_NAME)); + } + dirs::home_dir().map(|home| { + home.join(CLAUDE_USER_DIR_NAME) + .join(CLAUDE_SETTINGS_FILE_NAME) + }) +} + +pub(crate) fn managed_claude_settings_path() -> PathBuf { + if cfg!(target_os = "macos") { + PathBuf::from(MACOS_MANAGED_SETTINGS_PATH) + } else { + PathBuf::from(LINUX_MANAGED_SETTINGS_PATH) + } +} + +pub(crate) fn install_claude_telemetry( + settings_path: &Path, + managed_settings_path: &Path, +) -> Result { + let existing = read_optional(settings_path)?; + let managed = read_optional(managed_settings_path).unwrap_or(None); + let source_path = settings_path.display().to_string(); + + match merge_claude_telemetry_settings(existing.as_deref(), managed.as_deref(), &source_path) { + Err(_) => Ok(ClaudeTelemetryInstallOutcome::SkippedInvalid), + Ok(ClaudeTelemetryMergeOutcome::AlreadyCurrent) => { + Ok(ClaudeTelemetryInstallOutcome::Configured) + } + Ok(ClaudeTelemetryMergeOutcome::Skipped(reason)) => { + Ok(ClaudeTelemetryInstallOutcome::SkippedConflict(reason)) + } + Ok(ClaudeTelemetryMergeOutcome::Installed(bytes)) => { + write_atomically(settings_path, &bytes)?; + Ok(ClaudeTelemetryInstallOutcome::Configured) + } + } +} + +pub(crate) fn format_claude_telemetry_report( + outcome: &ClaudeTelemetryInstallOutcome, + authentication_pending: bool, +) -> String { + match outcome { + ClaudeTelemetryInstallOutcome::Configured if authentication_pending => { + format!("{CONFIGURED_LINE}\n{AUTH_PENDING_LINE}") + } + ClaudeTelemetryInstallOutcome::Configured => CONFIGURED_LINE.to_string(), + ClaudeTelemetryInstallOutcome::SkippedConflict(reason) => { + skipped_conflict_line(*reason).to_string() + } + ClaudeTelemetryInstallOutcome::SkippedInvalid => SKIPPED_INVALID_LINE.to_string(), + } +} + +fn skipped_conflict_line(reason: ClaudeTelemetrySkipReason) -> &'static str { + match reason { + ClaudeTelemetrySkipReason::UserHelperPresent => SKIPPED_HELPER_LINE, + ClaudeTelemetrySkipReason::TelemetryDisabled => SKIPPED_DISABLED_LINE, + ClaudeTelemetrySkipReason::PrivacySettingConflict => SKIPPED_PRIVACY_LINE, + ClaudeTelemetrySkipReason::ConflictingEndpoint => SKIPPED_ENDPOINT_LINE, + ClaudeTelemetrySkipReason::ConflictingHeaders => SKIPPED_HEADERS_LINE, + ClaudeTelemetrySkipReason::ConflictingProtocol => SKIPPED_PROTOCOL_LINE, + ClaudeTelemetrySkipReason::ConflictingExporter => SKIPPED_EXPORTER_LINE, + ClaudeTelemetrySkipReason::ConflictingHelperSetting => SKIPPED_HELPER_SETTING_LINE, + ClaudeTelemetrySkipReason::ManagedSettingsConflict => SKIPPED_MANAGED_LINE, + } +} + +fn read_optional(path: &Path) -> Result>> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("Failed to read '{}'", path.display())), + } +} + +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("Settings path '{}' has no parent directory", path.display()))?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create '{}'", parent.display()))?; + + let mut temp_name = path.as_os_str().to_os_string(); + temp_name.push(TEMP_SUFFIX); + let temp_path = PathBuf::from(temp_name); + + fs::write(&temp_path, bytes) + .with_context(|| format!("Failed to write '{}'", temp_path.display()))?; + apply_mode(path, &temp_path)?; + fs::rename(&temp_path, path).with_context(|| { + let _ = fs::remove_file(&temp_path); + format!("Failed to replace '{}'", path.display()) + }) +} + +#[cfg(unix)] +fn apply_mode(target: &Path, temp_path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mode = match fs::metadata(target) { + Ok(metadata) => metadata.permissions().mode() & 0o7777, + Err(_) => NEW_SETTINGS_FILE_MODE, + }; + fs::set_permissions(temp_path, fs::Permissions::from_mode(mode)) + .with_context(|| format!("Failed to set permissions on '{}'", temp_path.display())) +} + +#[cfg(not(unix))] +fn apply_mode(_target: &Path, _temp_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + const FIXTURE_TOKEN: &str = "fixture-secret-token"; + + fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() + } + + #[test] + fn claude_telemetry_install_writes_env_and_helper_idempotently() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("claude").join("settings.json"); + let managed = dir.path().join("managed.json"); + + let outcome = install_claude_telemetry(&settings, &managed).unwrap(); + assert_eq!(outcome, ClaudeTelemetryInstallOutcome::Configured); + let first = fs::read(&settings).unwrap(); + let parsed: Value = serde_json::from_slice(&first).unwrap(); + assert_eq!(parsed["otelHeadersHelper"], "sce auth telemetry-headers"); + assert_eq!(parsed["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1"); + assert!(!String::from_utf8(first.clone()) + .unwrap() + .contains(FIXTURE_TOKEN)); + + install_claude_telemetry(&settings, &managed).unwrap(); + assert_eq!(fs::read(&settings).unwrap(), first); + } + + #[cfg(unix)] + #[test] + fn claude_telemetry_install_preserves_unrelated_settings_and_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + let managed = dir.path().join("managed.json"); + fs::write( + &settings, + serde_json::to_vec(&json!({ + "permissions": {"allow": ["Bash(ls)"]}, + "env": {"KEEP": "me"} + })) + .unwrap(), + ) + .unwrap(); + fs::set_permissions(&settings, fs::Permissions::from_mode(0o640)).unwrap(); + + install_claude_telemetry(&settings, &managed).unwrap(); + + let parsed = read_json(&settings); + assert_eq!(parsed["permissions"]["allow"][0], "Bash(ls)"); + assert_eq!(parsed["env"]["KEEP"], "me"); + assert_eq!( + fs::metadata(&settings).unwrap().permissions().mode() & 0o777, + 0o640 + ); + } + + #[cfg(unix)] + #[test] + fn claude_telemetry_install_creates_new_file_with_private_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + install_claude_telemetry(&settings, &dir.path().join("managed.json")).unwrap(); + assert_eq!( + fs::metadata(&settings).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn claude_telemetry_install_skips_user_owned_helper_and_leaves_file_untouched() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + let original = serde_json::to_vec(&json!({"otelHeadersHelper": "my-helper"})).unwrap(); + fs::write(&settings, &original).unwrap(); + + let outcome = + install_claude_telemetry(&settings, &dir.path().join("managed.json")).unwrap(); + + assert!(matches!( + outcome, + ClaudeTelemetryInstallOutcome::SkippedConflict(_) + )); + assert_eq!(fs::read(&settings).unwrap(), original); + } + + #[test] + fn claude_telemetry_install_refuses_malformed_json_without_overwriting() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + fs::write(&settings, b"{ not json").unwrap(); + + let outcome = + install_claude_telemetry(&settings, &dir.path().join("managed.json")).unwrap(); + + assert_eq!(outcome, ClaudeTelemetryInstallOutcome::SkippedInvalid); + assert_eq!(fs::read(&settings).unwrap(), b"{ not json"); + } + + #[test] + fn claude_telemetry_install_skips_on_managed_settings_conflict() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + let managed = dir.path().join("managed.json"); + fs::write( + &managed, + serde_json::to_vec(&json!({"otelHeadersHelper": "corp-helper"})).unwrap(), + ) + .unwrap(); + + let outcome = install_claude_telemetry(&settings, &managed).unwrap(); + + assert!(matches!( + outcome, + ClaudeTelemetryInstallOutcome::SkippedConflict(_) + )); + assert!(!settings.exists()); + } + + #[test] + fn claude_telemetry_install_keeps_explicit_opt_out_and_reports_reason() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + let original = + serde_json::to_vec(&json!({"env": {"CLAUDE_CODE_ENABLE_TELEMETRY": "0"}})).unwrap(); + fs::write(&settings, &original).unwrap(); + + let outcome = + install_claude_telemetry(&settings, &dir.path().join("managed.json")).unwrap(); + + assert_eq!( + outcome, + ClaudeTelemetryInstallOutcome::SkippedConflict( + ClaudeTelemetrySkipReason::TelemetryDisabled + ) + ); + assert_eq!(fs::read(&settings).unwrap(), original); + let report = format_claude_telemetry_report(&outcome, false); + assert!(report.contains("explicitly disable telemetry")); + assert!(!report.contains("configured")); + } + + #[test] + fn claude_telemetry_report_marks_authentication_pending_without_helper_name() { + let pending = + format_claude_telemetry_report(&ClaudeTelemetryInstallOutcome::Configured, true); + let ready = + format_claude_telemetry_report(&ClaudeTelemetryInstallOutcome::Configured, false); + + assert!(pending.contains("authentication pending")); + assert!(!ready.contains("authentication pending")); + assert!(!pending.contains("telemetry-headers")); + } +} diff --git a/cli/src/services/setup/command.rs b/cli/src/services/setup/command.rs index faf0770f7..ca2c6ebd7 100644 --- a/cli/src/services/setup/command.rs +++ b/cli/src/services/setup/command.rs @@ -1,3 +1,5 @@ +use std::future::Future; + use anyhow::Context; use crate::app::ContextWithRepoRoot; @@ -5,6 +7,12 @@ use crate::services::error::{CliError, UserError}; use crate::services::lifecycle::{ lifecycle_providers, RequiredHookInstallStatus, RequiredHooksInstallOutcome, }; +use crate::services::managed_telemetry::claude_helper::repository_id_for; +use crate::services::managed_telemetry::{run_with_deadline, CREDENTIAL_LOOKUP_DEADLINE}; +use crate::services::telemetry_credential_storage::{ + bind_repository_telemetry, RepositoryBindingMode, +}; +use crate::services::token_storage::TokenStorageError; use crate::services::{config, setup}; pub struct SetupCommand { @@ -48,6 +56,8 @@ impl SetupCommand { None }; + let claude_telemetry_selected = dispatch_targets_claude(setup_dispatch.as_ref()); + let mut sections = Vec::new(); let context_message = @@ -96,12 +106,67 @@ impl SetupCommand { ) .map_err(unexpected_failure)?; sections.push(setup_message); + + if claude_telemetry_selected { + sections.push(claude_telemetry_report(&repository_root).await); + } } Ok(sections.join("\n\n")) } } +fn dispatch_targets_claude(dispatch: Option<&setup::SetupDispatch>) -> bool { + matches!( + dispatch, + Some(setup::SetupDispatch::Proceed { targets, .. }) + if targets.contains(&setup::ConcreteSetupTarget::Claude) + ) +} + +async fn claude_telemetry_report(repository_root: &std::path::Path) -> String { + let repository_id = repository_id_for(repository_root); + render_claude_telemetry_section( + setup::claude_telemetry::resolve_claude_settings_path().as_deref(), + &setup::claude_telemetry::managed_claude_settings_path(), + async move { + match repository_id { + Some(repository_id) => { + bind_repository_telemetry(repository_id, RepositoryBindingMode::Enroll).await + } + None => Ok(false), + } + }, + ) + .await +} + +async fn render_claude_telemetry_section( + settings_path: Option<&std::path::Path>, + managed_settings_path: &std::path::Path, + repository_enrollment: Fut, +) -> String +where + Fut: Future> + Send + 'static, +{ + use setup::claude_telemetry::{ + format_claude_telemetry_report, install_claude_telemetry, ClaudeTelemetryInstallOutcome, + }; + + let installed = match settings_path { + Some(settings_path) => install_claude_telemetry(settings_path, managed_settings_path) + .unwrap_or(ClaudeTelemetryInstallOutcome::SkippedInvalid), + None => ClaudeTelemetryInstallOutcome::SkippedInvalid, + }; + let authentication_pending = installed == ClaudeTelemetryInstallOutcome::Configured + && !run_with_deadline(CREDENTIAL_LOOKUP_DEADLINE, repository_enrollment) + .await + .and_then(Result::ok) + .unwrap_or(false); + + format_claude_telemetry_report(&installed, authentication_pending) +} + fn resolve_setup_repository(start_path: &std::path::Path) -> Result { let repository_root = setup::ensure_git_repository(start_path).map_err(|source| { let user_error = match source { @@ -156,3 +221,193 @@ fn setup_required_hooks_outcome_from_lifecycle( .collect(), } } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use anyhow::Result; + use serde_json::Value; + + use super::*; + use crate::services::setup::{ + resolve_setup_dispatch, resolve_setup_request, ConcreteSetupTarget, + PersistedSetupConfirmations, SetupCliOptions, SetupDispatch, SetupMode, SetupTarget, + SetupTargetPrompter, + }; + + struct FakePrompter { + targets: Option>, + } + + impl SetupTargetPrompter for FakePrompter { + fn prompt_targets( + &self, + _configured: &[ConcreteSetupTarget], + ) -> Result>> { + Ok(self.targets.clone()) + } + + fn prompt_optional_workflows(&self, _defaults: &[String]) -> Result>> { + Ok(Some(Vec::new())) + } + + fn prompt_agent_trace_auto_sync(&self) -> Result> { + Ok(Some(false)) + } + + fn prompt_attribution_hooks_enabled(&self) -> Result> { + Ok(Some(false)) + } + } + + fn dispatch_for(mode: SetupMode, prompted: Option>) -> SetupDispatch { + resolve_setup_dispatch( + mode, + &FakePrompter { targets: prompted }, + &[], + &[], + PersistedSetupConfirmations::default(), + ) + .expect("dispatch should resolve") + } + + fn run_claude_telemetry_stage(dispatch: Option<&SetupDispatch>, root: &Path) -> Option { + dispatch_targets_claude(dispatch).then(|| { + block_on_current_thread(render_claude_telemetry_section( + Some(&root.join("settings.json")), + &root.join("managed.json"), + std::future::ready(Ok(false)), + )) + }) + } + + fn block_on_current_thread(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(future) + } + + #[tokio::test] + async fn setup_claude_telemetry_skips_repository_enrollment_when_install_is_skipped() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + fs::write(&settings, r#"{"otelHeadersHelper":"my-helper"}"#).unwrap(); + let looked_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = looked_up.clone(); + + let report = render_claude_telemetry_section( + Some(&settings), + &dir.path().join("managed.json"), + async move { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(false) + }, + ) + .await; + + assert!(report.contains("skipped")); + assert!(!looked_up.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn setup_claude_telemetry_reports_pending_when_repository_enrollment_stalls() { + let dir = tempfile::tempdir().unwrap(); + let started = std::time::Instant::now(); + + let report = render_claude_telemetry_section( + Some(&dir.path().join("settings.json")), + &dir.path().join("managed.json"), + std::future::pending(), + ) + .await; + + assert!(report.contains("authentication pending")); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + } + + #[tokio::test] + async fn setup_claude_telemetry_reports_ready_only_when_repository_is_enrolled() { + for (enrolled, pending) in [(Ok(true), false), (Ok(false), true), (Err(storage()), true)] { + let dir = tempfile::tempdir().unwrap(); + let report = render_claude_telemetry_section( + Some(&dir.path().join("settings.json")), + &dir.path().join("managed.json"), + std::future::ready(enrolled), + ) + .await; + assert_eq!( + report.contains("authentication pending"), + pending, + "{report}" + ); + } + } + + fn storage() -> TokenStorageError { + TokenStorageError::Database("unavailable".to_string()) + } + + fn assert_installed(root: &Path) { + let parsed: Value = + serde_json::from_slice(&fs::read(root.join("settings.json")).unwrap()).unwrap(); + assert_eq!(parsed["otelHeadersHelper"], "sce auth telemetry-headers"); + assert!(parsed["env"].is_object()); + } + + fn assert_untouched(root: &Path) { + assert!(!root.join("settings.json").exists()); + } + + #[test] + fn setup_claude_telemetry_installs_when_claude_is_selected() { + let interactive = dispatch_for( + SetupMode::Interactive, + Some(vec![ConcreteSetupTarget::Claude]), + ); + for dispatch in [ + dispatch_for(SetupMode::NonInteractive(SetupTarget::Claude), None), + dispatch_for(SetupMode::NonInteractive(SetupTarget::All), None), + interactive, + ] { + let dir = tempfile::tempdir().unwrap(); + + assert!(run_claude_telemetry_stage(Some(&dispatch), dir.path()).is_some()); + assert_installed(dir.path()); + } + } + + #[test] + fn setup_claude_telemetry_leaves_settings_untouched_when_claude_is_not_installed() { + let hooks_only = resolve_setup_request(SetupCliOptions { + hooks: true, + ..SetupCliOptions::default() + }) + .unwrap(); + let bootstrap = resolve_setup_request(SetupCliOptions { + bootstrap_context: true, + ..SetupCliOptions::default() + }) + .unwrap(); + assert_eq!(hooks_only.config_mode, None); + assert!(bootstrap.context_only); + + let dispatches = [ + Some(dispatch_for( + SetupMode::NonInteractive(SetupTarget::OpenCode), + None, + )), + Some(dispatch_for(SetupMode::Interactive, None)), + None, + ]; + for dispatch in &dispatches { + let dir = tempfile::tempdir().unwrap(); + + assert!(run_claude_telemetry_stage(dispatch.as_ref(), dir.path()).is_none()); + assert_untouched(dir.path()); + } + } +} diff --git a/cli/src/services/setup/config_merge.rs b/cli/src/services/setup/config_merge.rs index e8457b13e..5af9e3299 100644 --- a/cli/src/services/setup/config_merge.rs +++ b/cli/src/services/setup/config_merge.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; use serde_json::Value; +use crate::services::managed_telemetry::{MANAGED_OTLP_ENDPOINT, MANAGED_OTLP_PROTOCOL}; + const CLAUDE_SCE_HOOK_MARKER: &str = "run-sce-or-show-install-guidance.sh"; const LEGACY_CLAUDE_AGENT_TRACE_PLUGIN: &str = ".claude/plugins/sce-agent-trace.ts"; @@ -197,11 +199,427 @@ pub(crate) fn opencode_config_fragment_is_current( Ok(merged == existing) } +const CLAUDE_OTEL_HEADERS_HELPER_COMMAND: &str = "sce auth telemetry-headers"; +const CLAUDE_OTEL_HEADERS_HELPER_KEY: &str = "otelHeadersHelper"; +const CLAUDE_OTEL_HEADERS_DEBOUNCE_MS: &str = "300000"; + +const CLAUDE_OTEL_ENDPOINT_KEYS: [&str; 4] = [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", +]; +const CLAUDE_OTEL_HEADERS_KEYS: [&str; 4] = [ + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", +]; +const CLAUDE_OTEL_PROTOCOL_KEYS: [&str; 4] = [ + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", +]; +const CLAUDE_OTEL_EXPORTER_KEYS: [&str; 3] = [ + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", +]; +const CLAUDE_TELEMETRY_SWITCH_KEYS: [&str; 3] = [ + "CLAUDE_CODE_ENABLE_TELEMETRY", + "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + CLAUDE_ENHANCED_TELEMETRY_BETA_ALIAS, +]; +const CLAUDE_ENHANCED_TELEMETRY_BETA_ALIAS: &str = "ENABLE_ENHANCED_TELEMETRY_BETA"; +const CLAUDE_TELEMETRY_PRIVACY_KEYS: [&str; 1] = ["OTEL_METRICS_INCLUDE_REPOSITORY"]; +const CLAUDE_TELEMETRY_HELPER_SETTING_KEYS: [&str; 1] = + ["CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS"]; +const CLAUDE_TELEMETRY_ENABLED_VALUE: &str = "1"; +const CLAUDE_OTEL_EXPORTER_DISABLED_VALUE: &str = "none"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ClaudeTelemetrySkipReason { + UserHelperPresent, + TelemetryDisabled, + PrivacySettingConflict, + ConflictingEndpoint, + ConflictingHeaders, + ConflictingProtocol, + ConflictingExporter, + ConflictingHelperSetting, + ManagedSettingsConflict, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum ClaudeTelemetryMergeOutcome { + Installed(Vec), + AlreadyCurrent, + Skipped(ClaudeTelemetrySkipReason), +} + +fn claude_telemetry_env_entries() -> Vec<(&'static str, &'static str)> { + vec![ + ( + "CLAUDE_CODE_ENABLE_TELEMETRY", + CLAUDE_TELEMETRY_ENABLED_VALUE, + ), + ( + "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + CLAUDE_TELEMETRY_ENABLED_VALUE, + ), + ("OTEL_TRACES_EXPORTER", "otlp"), + ("OTEL_METRICS_EXPORTER", "otlp"), + ("OTEL_LOGS_EXPORTER", "otlp"), + ("OTEL_EXPORTER_OTLP_PROTOCOL", MANAGED_OTLP_PROTOCOL), + ("OTEL_EXPORTER_OTLP_ENDPOINT", MANAGED_OTLP_ENDPOINT), + ( + "CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS", + CLAUDE_OTEL_HEADERS_DEBOUNCE_MS, + ), + ("OTEL_METRICS_INCLUDE_REPOSITORY", "true"), + ] +} + +fn expected_claude_telemetry_value(key: &str) -> Option<&'static str> { + if key == CLAUDE_ENHANCED_TELEMETRY_BETA_ALIAS { + return Some(CLAUDE_TELEMETRY_ENABLED_VALUE); + } + claude_telemetry_env_entries() + .into_iter() + .find(|(candidate, _)| *candidate == key) + .map(|(_, expected)| expected) +} + +fn claude_telemetry_conflict(settings: &Value) -> Option { + if let Some(helper) = settings.get(CLAUDE_OTEL_HEADERS_HELPER_KEY) { + if helper.as_str() != Some(CLAUDE_OTEL_HEADERS_HELPER_COMMAND) { + return Some(ClaudeTelemetrySkipReason::UserHelperPresent); + } + } + + let env = settings.get("env").and_then(Value::as_object)?; + let differing = |keys: &[&str]| { + keys.iter().any(|key| { + env.get(*key) + .is_some_and(|value| value.as_str() != expected_claude_telemetry_value(key)) + }) + }; + let exporter_disabled = CLAUDE_OTEL_EXPORTER_KEYS.iter().any(|key| { + env.get(*key) + .and_then(Value::as_str) + .is_some_and(|value| value.trim() == CLAUDE_OTEL_EXPORTER_DISABLED_VALUE) + }); + + if differing(&CLAUDE_TELEMETRY_SWITCH_KEYS) || exporter_disabled { + Some(ClaudeTelemetrySkipReason::TelemetryDisabled) + } else if differing(&CLAUDE_TELEMETRY_PRIVACY_KEYS) { + Some(ClaudeTelemetrySkipReason::PrivacySettingConflict) + } else if differing(&CLAUDE_OTEL_ENDPOINT_KEYS) { + Some(ClaudeTelemetrySkipReason::ConflictingEndpoint) + } else if differing(&CLAUDE_OTEL_HEADERS_KEYS) { + Some(ClaudeTelemetrySkipReason::ConflictingHeaders) + } else if differing(&CLAUDE_OTEL_PROTOCOL_KEYS) { + Some(ClaudeTelemetrySkipReason::ConflictingProtocol) + } else if differing(&CLAUDE_OTEL_EXPORTER_KEYS) { + Some(ClaudeTelemetrySkipReason::ConflictingExporter) + } else if differing(&CLAUDE_TELEMETRY_HELPER_SETTING_KEYS) { + Some(ClaudeTelemetrySkipReason::ConflictingHelperSetting) + } else { + None + } +} + +pub(crate) fn merge_claude_telemetry_settings( + existing_bytes: Option<&[u8]>, + managed_bytes: Option<&[u8]>, + source_path: &str, +) -> Result { + if let Some(managed_bytes) = managed_bytes { + let managed: Value = serde_json::from_slice(managed_bytes) + .context("Managed Claude settings must contain valid JSON.")?; + if claude_telemetry_conflict(&managed).is_some() { + return Ok(ClaudeTelemetryMergeOutcome::Skipped( + ClaudeTelemetrySkipReason::ManagedSettingsConflict, + )); + } + } + + let existing: Value = match existing_bytes { + Some(bytes) => serde_json::from_slice(bytes).with_context(|| { + format!("Existing config file '{source_path}' must contain valid JSON.") + })?, + None => Value::Object(serde_json::Map::new()), + }; + let mut merged_obj = existing.as_object().cloned().with_context(|| { + format!("Existing config file '{source_path}' must contain a top-level JSON object.") + })?; + + if let Some(reason) = claude_telemetry_conflict(&existing) { + return Ok(ClaudeTelemetryMergeOutcome::Skipped(reason)); + } + + let mut env = match merged_obj.get("env") { + Some(value) => value.as_object().cloned().with_context(|| { + format!("Existing config file '{source_path}' key 'env' must be a JSON object.") + })?, + None => serde_json::Map::new(), + }; + for (key, value) in claude_telemetry_env_entries() { + env.insert(key.to_string(), Value::String(value.to_string())); + } + merged_obj.insert("env".to_string(), Value::Object(env)); + merged_obj.insert( + CLAUDE_OTEL_HEADERS_HELPER_KEY.to_string(), + Value::String(CLAUDE_OTEL_HEADERS_HELPER_COMMAND.to_string()), + ); + + let merged = Value::Object(merged_obj); + if existing_bytes.is_some() && merged == existing { + return Ok(ClaudeTelemetryMergeOutcome::AlreadyCurrent); + } + + let mut serialized = serde_json::to_string_pretty(&merged) + .context("Failed to serialize merged Claude telemetry settings")?; + serialized.push('\n'); + Ok(ClaudeTelemetryMergeOutcome::Installed( + serialized.into_bytes(), + )) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn merge_telemetry( + existing: Option, + managed: Option, + ) -> ClaudeTelemetryMergeOutcome { + let existing = existing.map(|value| serde_json::to_vec(&value).unwrap()); + let managed = managed.map(|value| serde_json::to_vec(&value).unwrap()); + merge_claude_telemetry_settings(existing.as_deref(), managed.as_deref(), "settings.json") + .unwrap() + } + + fn installed_value(outcome: ClaudeTelemetryMergeOutcome) -> Value { + match outcome { + ClaudeTelemetryMergeOutcome::Installed(bytes) => { + serde_json::from_slice(&bytes).unwrap() + } + other => panic!("expected Installed, got {other:?}"), + } + } + + #[test] + fn telemetry_merge_installs_env_and_helper_preserving_unrelated_keys() { + let existing = json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "env": {"FOO": "bar"}, + "hooks": {"Stop": [user_hook_entry()]} + }); + + let merged = installed_value(merge_telemetry(Some(existing), None)); + + assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!(merged["env"]["FOO"], "bar"); + assert_eq!(merged["hooks"]["Stop"][0], user_hook_entry()); + assert_eq!(merged["otelHeadersHelper"], "sce auth telemetry-headers"); + assert_eq!(merged["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1"); + assert_eq!( + merged["env"]["OTEL_EXPORTER_OTLP_ENDPOINT"], + MANAGED_OTLP_ENDPOINT + ); + assert_eq!( + merged["env"]["OTEL_EXPORTER_OTLP_PROTOCOL"], + MANAGED_OTLP_PROTOCOL + ); + assert_eq!( + merged["env"]["CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS"], + "300000" + ); + assert_eq!(merged["env"]["OTEL_METRICS_INCLUDE_REPOSITORY"], "true"); + assert!(merged["env"].get("OTEL_EXPORTER_OTLP_HEADERS").is_none()); + } + + #[test] + fn telemetry_merge_is_byte_idempotent_and_reports_already_current() { + let first = match merge_telemetry(None, None) { + ClaudeTelemetryMergeOutcome::Installed(bytes) => bytes, + other => panic!("expected Installed, got {other:?}"), + }; + + let second = merge_claude_telemetry_settings(Some(&first), None, "settings.json").unwrap(); + + assert_eq!(second, ClaudeTelemetryMergeOutcome::AlreadyCurrent); + } + + #[test] + fn telemetry_merge_skips_user_owned_helper() { + let existing = json!({"otelHeadersHelper": "/usr/local/bin/my-helper"}); + + assert_eq!( + merge_telemetry(Some(existing), None), + ClaudeTelemetryMergeOutcome::Skipped(ClaudeTelemetrySkipReason::UserHelperPresent) + ); + } + + #[test] + fn telemetry_merge_skips_each_conflicting_env_class_in_user_settings() { + let cases = [ + ( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "https://other.example", + ClaudeTelemetrySkipReason::ConflictingEndpoint, + ), + ( + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "https://other.example/v1/logs", + ClaudeTelemetrySkipReason::ConflictingEndpoint, + ), + ( + "OTEL_EXPORTER_OTLP_HEADERS", + "x=y", + ClaudeTelemetrySkipReason::ConflictingHeaders, + ), + ( + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "x=y", + ClaudeTelemetrySkipReason::ConflictingHeaders, + ), + ( + "OTEL_EXPORTER_OTLP_PROTOCOL", + "grpc", + ClaudeTelemetrySkipReason::ConflictingProtocol, + ), + ( + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "grpc", + ClaudeTelemetrySkipReason::ConflictingProtocol, + ), + ( + "OTEL_METRICS_EXPORTER", + "prometheus", + ClaudeTelemetrySkipReason::ConflictingExporter, + ), + ]; + + for (key, value, reason) in cases { + let existing = json!({"env": {key: value}}); + assert_eq!( + merge_telemetry(Some(existing), None), + ClaudeTelemetryMergeOutcome::Skipped(reason), + "{key}" + ); + } + } + + #[test] + fn telemetry_merge_enables_native_tracing_beta() { + let merged = installed_value(merge_telemetry(None, None)); + + assert_eq!(merged["env"]["CLAUDE_CODE_ENHANCED_TELEMETRY_BETA"], "1"); + assert_eq!(merged["env"]["OTEL_TRACES_EXPORTER"], "otlp"); + + let without_beta = json!({ + "otelHeadersHelper": "sce auth telemetry-headers", + "env": {"CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_TRACES_EXPORTER": "otlp"} + }); + let upgraded = installed_value(merge_telemetry(Some(without_beta), None)); + assert_eq!(upgraded["env"]["CLAUDE_CODE_ENHANCED_TELEMETRY_BETA"], "1"); + } + + #[test] + fn telemetry_merge_preserves_explicit_opt_out_and_privacy_settings() { + let cases = [ + ( + "CLAUDE_CODE_ENABLE_TELEMETRY", + "0", + ClaudeTelemetrySkipReason::TelemetryDisabled, + ), + ( + "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + "0", + ClaudeTelemetrySkipReason::TelemetryDisabled, + ), + ( + "ENABLE_ENHANCED_TELEMETRY_BETA", + "false", + ClaudeTelemetrySkipReason::TelemetryDisabled, + ), + ( + "OTEL_TRACES_EXPORTER", + "none", + ClaudeTelemetrySkipReason::TelemetryDisabled, + ), + ( + "OTEL_LOGS_EXPORTER", + "none", + ClaudeTelemetrySkipReason::TelemetryDisabled, + ), + ( + "OTEL_METRICS_INCLUDE_REPOSITORY", + "false", + ClaudeTelemetrySkipReason::PrivacySettingConflict, + ), + ( + "CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS", + "60000", + ClaudeTelemetrySkipReason::ConflictingHelperSetting, + ), + ]; + + for (key, value, reason) in cases { + let user = json!({"env": {key: value}}); + assert_eq!( + merge_telemetry(Some(user), None), + ClaudeTelemetryMergeOutcome::Skipped(reason), + "{key}" + ); + let managed = json!({"env": {key: value}}); + assert_eq!( + merge_telemetry(Some(json!({})), Some(managed)), + ClaudeTelemetryMergeOutcome::Skipped( + ClaudeTelemetrySkipReason::ManagedSettingsConflict + ), + "managed {key}" + ); + } + } + + #[test] + fn telemetry_merge_skips_when_managed_settings_conflict() { + let managed = json!({"env": {"OTEL_EXPORTER_OTLP_ENDPOINT": "https://corp.example"}}); + + assert_eq!( + merge_telemetry(Some(json!({})), Some(managed)), + ClaudeTelemetryMergeOutcome::Skipped( + ClaudeTelemetrySkipReason::ManagedSettingsConflict + ) + ); + } + + #[test] + fn telemetry_merge_rejects_malformed_existing_json_naming_the_path() { + let error = merge_claude_telemetry_settings( + Some(b"{ not valid json"), + None, + "~/.claude/settings.json", + ) + .unwrap_err(); + + assert!(error.to_string().contains("~/.claude/settings.json")); + } + + #[test] + fn telemetry_merge_output_contains_no_token_material() { + let merged = installed_value(merge_telemetry(None, None)).to_string(); + + assert!(!merged.contains("Bearer")); + assert!(!merged.contains("Authorization")); + assert!(!merged.contains("OTEL_EXPORTER_OTLP_HEADERS")); + } + fn sce_hook_entry(command: &str) -> Value { json!({ "hooks": [ diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 54c2ad2c8..bccec4f2c 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -8,6 +8,7 @@ use std::{ use crate::services::style::{label, success, value}; use crate::services::{default_paths, default_paths::RepoPaths}; +pub(crate) mod claude_telemetry; pub mod command; pub(crate) mod config_merge; pub(crate) mod hook_merge; diff --git a/cli/src/services/telemetry_credential_storage.rs b/cli/src/services/telemetry_credential_storage.rs index 4d616f906..3110e029c 100644 --- a/cli/src/services/telemetry_credential_storage.rs +++ b/cli/src/services/telemetry_credential_storage.rs @@ -1,17 +1,56 @@ use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; + use crate::services::db::{DbSpec, EncryptedTursoDb}; use crate::services::token_storage::{get_auth_db, TokenStorageError}; const TELEMETRY_CREDENTIAL_ROW_ID: i64 = 1; +const WORKOS_SESSION_ROW_ID: i64 = 1; const REDACTED: &str = ""; +const JWT_PAYLOAD_SEGMENT: usize = 1; +const BASE64_PADDING: char = '='; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TelemetryIdentity { + pub user_id: String, + pub workspace_id: String, +} + +#[derive(Deserialize)] +struct AccessTokenClaims { + sub: String, + workspace_id: Option, +} + +impl TelemetryIdentity { + pub fn from_access_token(access_token: &str) -> Option { + let payload = access_token.split('.').nth(JWT_PAYLOAD_SEGMENT)?; + let bytes = URL_SAFE_NO_PAD + .decode(payload.trim_end_matches(BASE64_PADDING)) + .ok()?; + let claims: AccessTokenClaims = serde_json::from_slice(&bytes).ok()?; + let identity = Self { + user_id: claims.sub, + workspace_id: claims.workspace_id?, + }; + identity.is_complete().then_some(identity) + } + + fn is_complete(&self) -> bool { + !self.user_id.trim().is_empty() && !self.workspace_id.trim().is_empty() + } +} #[derive(Clone, Eq, PartialEq)] pub struct TelemetryCredential { pub token: String, pub accessor: String, pub expires_at_unix_seconds: u64, + pub identity: TelemetryIdentity, } impl fmt::Debug for TelemetryCredential { @@ -20,6 +59,7 @@ impl fmt::Debug for TelemetryCredential { .field("token", &REDACTED) .field("accessor", &REDACTED) .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .field("identity", &self.identity) .finish() } } @@ -28,55 +68,93 @@ impl TelemetryCredential { fn is_valid_at(&self, now_unix_seconds: u64) -> bool { !self.token.trim().is_empty() && !self.accessor.trim().is_empty() + && self.identity.is_complete() && self.expires_at_unix_seconds > now_unix_seconds } } -#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepositoryBindingMode { + Enroll, + EnrollIfUnbound, +} + pub async fn save_telemetry_credential( credential: &TelemetryCredential, ) -> Result<(), TokenStorageError> { save_with(get_auth_db().await?, credential).await } -#[allow(dead_code)] pub async fn load_telemetry_credential() -> Result, TokenStorageError> { load_with(get_auth_db().await?, current_unix_timestamp_seconds()?).await } +pub async fn load_repository_telemetry_credential( + repository_id: String, +) -> Result, TokenStorageError> { + load_for_repository_with( + get_auth_db().await?, + current_unix_timestamp_seconds()?, + &repository_id, + ) + .await +} + +pub async fn bind_repository_telemetry( + repository_id: String, + mode: RepositoryBindingMode, +) -> Result { + bind_repository_with( + get_auth_db().await?, + current_unix_timestamp_seconds()?, + &repository_id, + mode, + ) + .await +} + pub async fn delete_telemetry_credential() -> Result { delete_with(get_auth_db().await?).await } +fn database_error(message: &str) -> TokenStorageError { + TokenStorageError::Database(message.to_string()) +} + +fn timestamp_to_db(value: u64) -> Result { + i64::try_from(value).map_err(|_| { + database_error("telemetry credential timestamp is out of range for database storage") + }) +} + async fn save_with( db: &EncryptedTursoDb, credential: &TelemetryCredential, ) -> Result<(), TokenStorageError> { - let expires_at = i64::try_from(credential.expires_at_unix_seconds).map_err(|_| { - TokenStorageError::Database( - "telemetry credential expiry is out of range for database storage".to_string(), - ) - })?; - let stored_at = i64::try_from(current_unix_timestamp_seconds()?).map_err(|_| { - TokenStorageError::Database( - "telemetry credential timestamp is out of range for database storage".to_string(), - ) - })?; + if !credential.identity.is_complete() { + return Err(database_error( + "telemetry credential identity is incomplete", + )); + } + let expires_at = timestamp_to_db(credential.expires_at_unix_seconds)?; + let stored_at = timestamp_to_db(current_unix_timestamp_seconds()?)?; db.execute( "INSERT OR REPLACE INTO telemetry_credentials \ - (id, token, accessor, expires_at_unix_seconds, stored_at_unix_seconds) \ - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, token, accessor, expires_at_unix_seconds, stored_at_unix_seconds, user_id, workspace_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ( TELEMETRY_CREDENTIAL_ROW_ID, credential.token.as_str(), credential.accessor.as_str(), expires_at, stored_at, + credential.identity.user_id.as_str(), + credential.identity.workspace_id.as_str(), ), ) .await - .map_err(|_| TokenStorageError::Database("failed to save telemetry credential".to_string()))?; + .map_err(|_| database_error("failed to save telemetry credential"))?; Ok(()) } @@ -85,34 +163,124 @@ async fn load_with( db: &EncryptedTursoDb, now_unix_seconds: u64, ) -> Result, TokenStorageError> { - let rows: Vec> = db + let rows: Vec> = db .query_map( - "SELECT token, accessor, expires_at_unix_seconds \ - FROM telemetry_credentials WHERE id = ?1", - (TELEMETRY_CREDENTIAL_ROW_ID,), + "SELECT t.token, t.accessor, t.expires_at_unix_seconds, t.user_id, t.workspace_id, \ + a.access_token \ + FROM telemetry_credentials t JOIN auth_credentials a ON a.id = ?2 \ + WHERE t.id = ?1", + (TELEMETRY_CREDENTIAL_ROW_ID, WORKOS_SESSION_ROW_ID), |row| { let token: String = row.get(0)?; let accessor: String = row.get(1)?; let expires_at: i64 = row.get(2)?; + let user_id: String = row.get(3)?; + let workspace_id: String = row.get(4)?; + let session_access_token: String = row.get(5)?; Ok(u64::try_from(expires_at) .ok() - .map(|expires_at_unix_seconds| TelemetryCredential { - token, - accessor, - expires_at_unix_seconds, + .map(|expires_at_unix_seconds| { + ( + TelemetryCredential { + token, + accessor, + expires_at_unix_seconds, + identity: TelemetryIdentity { + user_id, + workspace_id, + }, + }, + session_access_token, + ) })) }, ) .await - .map_err(|_| { - TokenStorageError::Database("failed to load telemetry credential".to_string()) - })?; + .map_err(|_| database_error("failed to load telemetry credential"))?; Ok(rows .into_iter() .next() .flatten() - .filter(|credential| credential.is_valid_at(now_unix_seconds))) + .filter(|(credential, session_access_token)| { + credential.is_valid_at(now_unix_seconds) + && TelemetryIdentity::from_access_token(session_access_token).as_ref() + == Some(&credential.identity) + }) + .map(|(credential, _)| credential)) +} + +async fn load_for_repository_with( + db: &EncryptedTursoDb, + now_unix_seconds: u64, + repository_id: &str, +) -> Result, TokenStorageError> { + let Some(credential) = load_with(db, now_unix_seconds).await? else { + return Ok(None); + }; + let authorized = repository_bound_to(db, repository_id, &credential.identity).await?; + Ok(authorized.then_some(credential)) +} + +async fn repository_bound_to( + db: &EncryptedTursoDb, + repository_id: &str, + identity: &TelemetryIdentity, +) -> Result { + if repository_id.trim().is_empty() { + return Ok(false); + } + let matches: Vec = db + .query_map( + "SELECT 1 FROM telemetry_repository_bindings \ + WHERE repository_id = ?1 AND user_id = ?2 AND workspace_id = ?3", + ( + repository_id, + identity.user_id.as_str(), + identity.workspace_id.as_str(), + ), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .await + .map_err(|_| database_error("failed to load telemetry repository binding"))?; + Ok(!matches.is_empty()) +} + +async fn bind_repository_with( + db: &EncryptedTursoDb, + now_unix_seconds: u64, + repository_id: &str, + mode: RepositoryBindingMode, +) -> Result { + if repository_id.trim().is_empty() { + return Ok(false); + } + let Some(credential) = load_with(db, now_unix_seconds).await? else { + return Ok(false); + }; + let sql = match mode { + RepositoryBindingMode::Enroll => { + "INSERT OR REPLACE INTO telemetry_repository_bindings \ + (repository_id, user_id, workspace_id, bound_at_unix_seconds) VALUES (?1, ?2, ?3, ?4)" + } + RepositoryBindingMode::EnrollIfUnbound => { + "INSERT OR IGNORE INTO telemetry_repository_bindings \ + (repository_id, user_id, workspace_id, bound_at_unix_seconds) VALUES (?1, ?2, ?3, ?4)" + } + }; + db.execute( + sql, + ( + repository_id, + credential.identity.user_id.as_str(), + credential.identity.workspace_id.as_str(), + timestamp_to_db(now_unix_seconds)?, + ), + ) + .await + .map_err(|_| database_error("failed to save telemetry repository binding"))?; + + repository_bound_to(db, repository_id, &credential.identity).await } async fn delete_with(db: &EncryptedTursoDb) -> Result { @@ -122,9 +290,7 @@ async fn delete_with(db: &EncryptedTursoDb) -> Result 0) } @@ -138,6 +304,18 @@ fn current_unix_timestamp_seconds() -> Result { .as_secs()) } +#[cfg(test)] +pub(crate) fn test_access_token(user_id: &str, workspace_id: Option<&str>) -> String { + let claims = match workspace_id { + Some(workspace_id) => serde_json::json!({"sub": user_id, "workspace_id": workspace_id}), + None => serde_json::json!({"sub": user_id}), + }; + format!( + "eyJhbGciOiJSUzI1NiJ9.{}.signature", + URL_SAFE_NO_PAD.encode(claims.to_string()) + ) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -206,17 +384,74 @@ mod tests { db.execute("DELETE FROM auth_credentials", ()) .await .expect("workos row should be cleared"); + seed_workos_session(&db).await; (guard, db) } + const USER_A: &str = "user_a"; + const WORKSPACE_A: &str = "workspace_a"; + const USER_B: &str = "user_b"; + const WORKSPACE_B: &str = "workspace_b"; + const REPOSITORY_A: &str = "repository-a"; + const REPOSITORY_B: &str = "repository-b"; + + async fn seed_workos_session(db: &TestDb) { + seed_session(db, USER_A, WORKSPACE_A).await; + } + + async fn seed_session(db: &TestDb, user_id: &str, workspace_id: &str) { + db.execute( + "INSERT OR REPLACE INTO auth_credentials \ + (id, access_token, token_type, expires_in, refresh_token, scope, stored_at_unix_seconds) \ + VALUES (1, ?1, 'Bearer', 3600, 'workos-refresh', NULL, 1)", + (test_access_token(user_id, Some(workspace_id)),), + ) + .await + .expect("workos row should insert"); + } + + fn identity(user_id: &str, workspace_id: &str) -> TelemetryIdentity { + TelemetryIdentity { + user_id: user_id.to_string(), + workspace_id: workspace_id.to_string(), + } + } + fn credential(token: &str, expires_at: u64) -> TelemetryCredential { + credential_for(token, expires_at, identity(USER_A, WORKSPACE_A)) + } + + fn credential_for( + token: &str, + expires_at: u64, + identity: TelemetryIdentity, + ) -> TelemetryCredential { TelemetryCredential { token: token.to_string(), accessor: format!("accessor-{token}"), expires_at_unix_seconds: expires_at, + identity, } } + async fn log_in_as(db: &TestDb, user_id: &str, workspace_id: &str, token: &str) { + delete_with(db).await.expect("delete should succeed"); + seed_session(db, user_id, workspace_id).await; + save_with( + db, + &credential_for(token, FAR_FUTURE, identity(user_id, workspace_id)), + ) + .await + .expect("save should succeed"); + } + + async fn repository_token(db: &TestDb, repository_id: &str) -> Option { + load_for_repository_with(db, now(), repository_id) + .await + .expect("load should succeed") + .map(|credential| credential.token) + } + fn now() -> u64 { current_unix_timestamp_seconds().expect("clock should be valid") } @@ -279,17 +514,24 @@ mod tests { } #[tokio::test] - async fn telemetry_credential_storage_leaves_workos_credentials_untouched() { + async fn telemetry_credential_is_rejected_without_owning_workos_session() { let (_guard, db) = open_db().await; - db.execute( - "INSERT INTO auth_credentials \ - (id, access_token, token_type, expires_in, refresh_token, scope, stored_at_unix_seconds) \ - VALUES (1, 'workos-access', 'Bearer', 3600, 'workos-refresh', NULL, 1)", - (), - ) - .await - .expect("workos row should insert"); + save_with(&db, &credential("tok-orphan", FAR_FUTURE)) + .await + .expect("save should succeed"); + db.execute("DELETE FROM auth_credentials", ()) + .await + .expect("workos row should be cleared"); + assert_eq!( + load_with(&db, now()).await.expect("load should succeed"), + None + ); + } + + #[tokio::test] + async fn telemetry_credential_storage_leaves_workos_credentials_untouched() { + let (_guard, db) = open_db().await; save_with(&db, &credential("tok-b", FAR_FUTURE)) .await .expect("save should succeed"); @@ -306,9 +548,61 @@ mod tests { ) .await .expect("workos row query should succeed"); - assert_eq!(tokens, vec!["workos-access".to_string()]); + assert_eq!(tokens, vec![test_access_token(USER_A, Some(WORKSPACE_A))]); + } + + #[tokio::test] + async fn telemetry_headers_helper_reads_stored_credential_and_follows_replacement() { + use crate::services::auth_command::render_telemetry_headers; + + let (_guard, db) = open_db().await; + save_with(&db, &credential("tok-first", FAR_FUTURE)) + .await + .expect("save should succeed"); + let first = render_telemetry_headers(load_with(&db, now()).await) + .expect("stored credential should render"); + assert_eq!(first, r#"{"Authorization":"Bearer tok-first"}"#); + + save_with(&db, &credential("tok-second", FAR_FUTURE)) + .await + .expect("replacement save should succeed"); + let second = render_telemetry_headers(load_with(&db, now()).await) + .expect("replacement should render"); + assert_eq!(second, r#"{"Authorization":"Bearer tok-second"}"#); } + #[tokio::test] + async fn telemetry_headers_helper_fails_with_fixed_diagnostic_for_unusable_stored_state() { + use crate::services::auth_command::render_telemetry_headers; + + let (_guard, db) = open_db().await; + let missing = render_telemetry_headers(load_with(&db, now()).await) + .expect_err("missing credential should fail"); + + save_with(&db, &credential("tok-expired", now() - 10)) + .await + .expect("save should succeed"); + let expired = render_telemetry_headers(load_with(&db, now()).await) + .expect_err("expired credential should fail"); + + db.execute( + "INSERT OR REPLACE INTO telemetry_credentials \ + (id, token, accessor, expires_at_unix_seconds, stored_at_unix_seconds) \ + VALUES (1, '', 'acc', ?1, 0)", + (i64::try_from(FAR_FUTURE).expect("fits"),), + ) + .await + .expect("malformed row should insert"); + let malformed = render_telemetry_headers(load_with(&db, now()).await) + .expect_err("malformed credential should fail"); + + for error in [missing, expired, malformed] { + assert_eq!(error.class().exit_code(), 4); + let text = error.to_string(); + assert_eq!(text, "Telemetry credential is unavailable."); + assert!(!text.contains("tok-")); + } + } #[test] fn telemetry_credential_storage_debug_output_is_redacted() { let rendered = format!("{:?}", credential("super-secret-token", FAR_FUTURE)); @@ -372,4 +666,272 @@ mod tests { assert!(loaded.token.starts_with("tok-")); assert_eq!(loaded.accessor, format!("accessor-{}", loaded.token)); } + + #[test] + fn telemetry_identity_comes_only_from_complete_access_token_claims() { + assert_eq!( + TelemetryIdentity::from_access_token(&test_access_token(USER_A, Some(WORKSPACE_A))), + Some(identity(USER_A, WORKSPACE_A)) + ); + for token in [ + test_access_token(USER_A, None), + test_access_token(USER_A, Some(" ")), + test_access_token("", Some(WORKSPACE_A)), + "opaque".to_string(), + "a.%%%.c".to_string(), + ] { + assert_eq!(TelemetryIdentity::from_access_token(&token), None); + } + } + + #[tokio::test] + async fn telemetry_credential_is_rejected_after_session_switches_identity() { + let (_guard, db) = open_db().await; + save_with(&db, &credential("tok-a", FAR_FUTURE)) + .await + .expect("save should succeed"); + + for (user_id, workspace_id) in [(USER_A, WORKSPACE_B), (USER_B, WORKSPACE_A)] { + seed_session(&db, user_id, workspace_id).await; + assert_eq!( + load_with(&db, now()).await.expect("load should succeed"), + None + ); + } + + seed_session(&db, USER_A, WORKSPACE_A).await; + assert_eq!( + load_with(&db, now()) + .await + .expect("load should succeed") + .map(|credential| credential.token), + Some("tok-a".to_string()) + ); + } + + #[tokio::test] + async fn legacy_credential_without_identity_is_never_returned() { + let (_guard, db) = open_db().await; + db.execute( + "INSERT OR REPLACE INTO telemetry_credentials \ + (id, token, accessor, expires_at_unix_seconds, stored_at_unix_seconds) \ + VALUES (1, 'tok-legacy', 'acc', ?1, 0)", + (i64::try_from(FAR_FUTURE).expect("fits"),), + ) + .await + .expect("legacy row should insert"); + + assert_eq!( + load_with(&db, now()).await.expect("load should succeed"), + None + ); + assert!( + !bind_repository_with(&db, now(), REPOSITORY_A, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + } + + #[tokio::test] + async fn workspace_b_repository_cannot_use_workspace_a_credential() { + let (_guard, db) = open_db().await; + + log_in_as(&db, USER_B, WORKSPACE_B, "tok-b").await; + assert!( + bind_repository_with(&db, now(), REPOSITORY_B, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + assert_eq!( + repository_token(&db, REPOSITORY_B).await, + Some("tok-b".to_string()) + ); + + log_in_as(&db, USER_A, WORKSPACE_A, "tok-a").await; + assert!(bind_repository_with( + &db, + now(), + REPOSITORY_A, + RepositoryBindingMode::EnrollIfUnbound + ) + .await + .expect("bind should succeed")); + assert!( + !bind_repository_with( + &db, + now(), + REPOSITORY_B, + RepositoryBindingMode::EnrollIfUnbound + ) + .await + .expect("bind should succeed"), + "implicit enrollment must not move a repository to another workspace" + ); + + assert_eq!( + repository_token(&db, REPOSITORY_A).await, + Some("tok-a".to_string()) + ); + assert_eq!(repository_token(&db, REPOSITORY_B).await, None); + assert_eq!(repository_token(&db, "repository-unknown").await, None); + assert_eq!(repository_token(&db, "").await, None); + + log_in_as(&db, USER_A, WORKSPACE_B, "tok-a-in-b").await; + assert_eq!(repository_token(&db, REPOSITORY_A).await, None); + assert_eq!(repository_token(&db, REPOSITORY_B).await, None); + } + + #[tokio::test] + async fn explicit_enrollment_rebinds_repository_to_current_identity() { + let (_guard, db) = open_db().await; + log_in_as(&db, USER_B, WORKSPACE_B, "tok-b").await; + assert!( + bind_repository_with(&db, now(), REPOSITORY_B, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + + log_in_as(&db, USER_A, WORKSPACE_A, "tok-a").await; + assert!( + bind_repository_with(&db, now(), REPOSITORY_B, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + assert_eq!( + repository_token(&db, REPOSITORY_B).await, + Some("tok-a".to_string()) + ); + + log_in_as(&db, USER_B, WORKSPACE_B, "tok-b-2").await; + assert_eq!(repository_token(&db, REPOSITORY_B).await, None); + } + + #[tokio::test] + async fn failed_credential_replacement_logout_and_rotation_keep_bindings_fail_closed() { + let (_guard, db) = open_db().await; + log_in_as(&db, USER_A, WORKSPACE_A, "tok-1").await; + assert!( + bind_repository_with(&db, now(), REPOSITORY_A, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + + save_with(&db, &credential("tok-2", FAR_FUTURE)) + .await + .expect("rotation save should succeed"); + assert_eq!( + repository_token(&db, REPOSITORY_A).await, + Some("tok-2".to_string()) + ); + + delete_with(&db).await.expect("delete should succeed"); + seed_session(&db, USER_A, WORKSPACE_A).await; + assert_eq!(repository_token(&db, REPOSITORY_A).await, None); + + delete_with(&db) + .await + .expect("logout delete should succeed"); + db.execute("DELETE FROM auth_credentials", ()) + .await + .expect("session delete should succeed"); + assert_eq!(repository_token(&db, REPOSITORY_A).await, None); + assert!( + !bind_repository_with(&db, now(), REPOSITORY_A, RepositoryBindingMode::Enroll) + .await + .expect("bind should succeed") + ); + + log_in_as(&db, USER_A, WORKSPACE_A, "tok-3").await; + assert_eq!( + repository_token(&db, REPOSITORY_A).await, + Some("tok-3".to_string()) + ); + + save_with(&db, &credential("tok-expired", now() - 1)) + .await + .expect("save should succeed"); + assert_eq!(repository_token(&db, REPOSITORY_A).await, None); + } + + #[tokio::test] + async fn incomplete_identity_is_never_saved() { + let (_guard, db) = open_db().await; + let result = save_with( + &db, + &credential_for("tok-anon", FAR_FUTURE, identity(USER_A, "")), + ) + .await; + assert!(result.is_err()); + assert_eq!( + load_with(&db, now()).await.expect("load should succeed"), + None + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn headers_helper_in_claude_runtime_environment_only_serves_bound_repository() { + use crate::services::auth_command::telemetry_headers_with; + use crate::services::managed_telemetry::claude_helper::tests::git_repository_with_remote; + use crate::services::managed_telemetry::claude_helper::{ + repository_id_for, visible_destination_conflict, + }; + + let (_guard, db) = open_db().await; + let dir = tempfile::tempdir().unwrap(); + let repo_a = + git_repository_with_remote(dir.path(), "repo-a", "git@github.com:acme/repo-a.git"); + let repo_b = + git_repository_with_remote(dir.path(), "repo-b", "https://github.com/acme/repo-b"); + let unrelated = + git_repository_with_remote(dir.path(), "unrelated", "https://github.com/acme/other"); + let bound_repository_id = repository_id_for(&repo_a).expect("repo a identity"); + let foreign_repository_id = repository_id_for(&repo_b).expect("repo b identity"); + + log_in_as(&db, USER_B, WORKSPACE_B, "tok-b").await; + assert!(bind_repository_with( + &db, + now(), + &foreign_repository_id, + RepositoryBindingMode::Enroll + ) + .await + .expect("bind should succeed")); + log_in_as(&db, USER_A, WORKSPACE_A, "tok-a").await; + assert!(bind_repository_with( + &db, + now(), + &bound_repository_id, + RepositoryBindingMode::Enroll + ) + .await + .expect("bind should succeed")); + drop(db); + + let claude_helper_env = |_: &str| None::; + let headers_for = |working_dir: std::path::PathBuf| { + telemetry_headers_with( + Some(working_dir), + visible_destination_conflict(claude_helper_env), + repository_id_for, + |repository_id: String| async move { + let db = TestDb::new() + .await + .map_err(|_| TokenStorageError::Database("open".to_string()))?; + load_for_repository_with(&db, now(), &repository_id).await + }, + ) + }; + + let headers = headers_for(repo_a.join("src").join("module")) + .await + .expect("bound repository launched from a subdirectory should render"); + assert_eq!(headers, r#"{"Authorization":"Bearer tok-a"}"#); + + for working_dir in [repo_b.clone(), unrelated, dir.path().to_path_buf()] { + let error = headers_for(working_dir) + .await + .expect_err("unbound or foreign-workspace repository must fail closed"); + assert_eq!(error.to_string(), "Telemetry credential is unavailable."); + } + } } diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index 89424d1b1..d21f1323a 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -161,6 +161,43 @@ local sceConfigSchema = new JsonSchema { } } } + ["otel"] = new JsonSchema { + type = "object" + description = "Optional managed OpenTelemetry declaration. Omitting the block or any field keeps the code-owned managed defaults; managed export never lets this block redirect authenticated requests." + additionalProperties = false + properties { + ["endpoint"] = new JsonSchema { + type = "string" + description = "Managed OTLP collector base URL." + minLength = 1 + default = "https://otel.crocoderlab.dev" + } + ["protocol"] = new JsonSchema { + type = "string" + description = "OTLP transport protocol." + enum = new { "http/protobuf" } + default = "http/protobuf" + } + ["traces_endpoint"] = new JsonSchema { + type = "string" + description = "OTLP traces endpoint." + minLength = 1 + default = "https://otel.crocoderlab.dev/v1/traces" + } + ["metrics_endpoint"] = new JsonSchema { + type = "string" + description = "Reserved OTLP metrics endpoint for the later metrics pipeline." + minLength = 1 + default = "https://otel.crocoderlab.dev/v1/metrics" + } + ["logs_endpoint"] = new JsonSchema { + type = "string" + description = "OTLP logs endpoint." + minLength = 1 + default = "https://otel.crocoderlab.dev/v1/logs" + } + } + } ["policies"] = new JsonSchema { type = "object" description = "Policy configuration for SCE runtime and bash-tool enforcement." diff --git a/context/architecture.md b/context/architecture.md index 5f5b3854f..14b3f9a1f 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -125,6 +125,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, longest-valid-parent traversal for unknown command paths, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. - Startup observability bootstrapping in `cli/src/app.rs` still tolerates invalid default-discovered config files by continuing with degraded defaults plus `sce.config.invalid_config` warn-level logs, but the warning/logging work is now isolated behind the startup-context and runtime-initialization phases rather than one inline startup function. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format, explicit config-file/default `log_to_file`, and `log_dir` inputs with precedence `env > config file > defaults` for non-flag observability keys, stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, error-specific stderr suppression when file logging is enabled while non-error records and file-write diagnostics remain on stderr, redaction-safe emission through the shared security helper, and log-directory writes with bounded retention. Config resolution also carries a positive config-file/default-only `log_file_retention_limit` (`10` by default) into startup observability config and `sce config show`; the concrete logger stores that resolved value and threads it through primary and v2 cleanup. When `log_dir` resolves from `SCE_LOG_DIR`, config, or the `/sce/logs` default, each enabled or forced log operation selects `/sce-.log` or `/sce--.log` using the machine-local date and optional logger session context, with deterministic percent-encoding for unsafe session filename bytes; after successfully writing a newly created selected file, retention keeps the configured number of newest direct regular `*.log` files by mtime plus path/name tie-break and fails open on cleanup errors. Its `observability::traits` submodule exposes the current `Logger` API with `Option<&str>` session context plus the generic async, deliberately non-object-safe `Telemetry` capability and `NoopLogger`; the concrete observability `Logger` and `NoopTelemetry` implement those capability traits; `NoopTelemetry` installs no tracing subscriber. `services::app_support::render_run_outcome` consumes the logger through that trait boundary when logging classified errors and stdout-write failures. +- `cli/src/services/managed_telemetry.rs` (+ `managed_telemetry/trace_export.rs`, `managed_telemetry/log_export.rs`) owns hardcoded managed OTLP defaults, credential-backed runtime telemetry selection from the encrypted auth DB, and bounded fail-open OTLP/HTTP protobuf export of `sce.command` spans and allowlisted `Logger` events to the pinned canonical collector (metrics endpoint reserved, not exported); see `context/sce/cli-observability-contract.md`. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on concrete `Logger` behavior plus the `NoopTelemetry`-backed telemetry capability boundary. - `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. - `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database/log files (global config, auth tokens, auth DB, local DB, default observability log directory, and the sole Agent Trace DB path helper `agent_trace_db_path_for_repository` under `repos//agent-trace.db`; the former global-sentinel and per-checkout Agent Trace path helpers were removed by the `retire-legacy-agent-trace-db` plan), and owns canonical repo-relative, embedded-asset, install, hook, and context-path accessors so non-test production path definitions have one shared owner. Compile-time generated payload paths are owned by `build.rs` under `OUT_DIR`, not by the default-path catalog. Current production consumers such as config discovery, observability config resolution, doctor reporting, setup/install flows, database adapters, checkout identity, Agent Trace storage resolution, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index e499ef310..ac4d73b5d 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -105,6 +105,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi/Codex targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and (via a build-time staging merge of `config/.agents/**` + `config/.codex/**`) `config/codex-target/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. Codex is the one target whose embedded relative paths already carry their own output-root prefix (`.agents/...`, `.codex/...`), so its destination root is the repository root itself rather than a single `.codex/`-style subdirectory. Its generated hook command resolves that repository root at invocation time, so Codex events from nested cwd and repositories with spaces reach the installed helper safely; Git-root failure is a silent successful no-op, while the helper preserves missing-CLI stderr guidance and STDIN forwarding. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. +`setup` with a selected Claude target also merges Claude-native OpenTelemetry settings into user-level Claude `settings.json` (`$CLAUDE_CONFIG_DIR` else `~/.claude`) and prints a fixed telemetry line (configured, authentication pending, or skipped on conflict/malformed JSON) without failing repository setup; see [telemetry credential](../sce/telemetry-credential.md). The hidden auth helper is intentionally not part of the public command listing. `doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. The former Agent Trace database inspection routes are unavailable; doctor owns repository-scoped Agent Trace DB health (checkout-identity diagnostics were removed by the `remove-checkout-id` plan; see `context/cli/checkout-identity.md`). The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and post-commit Agent Trace auto-sync readiness derived from canonical managed-block currency plus resolved configuration. The readiness fact reports enabled/current, explicit disabled, not-ready, and not-applicable states in text and JSON without launching synchronization; Claude's inventory is only `Plugins`, `Commands`, and `Skills`, while OpenCode retains `Agents`. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. `sce sync [--format text|json]` is the implemented user-invocable synchronization command: it synchronizes the current repository's Agent Trace DB with the control-plane ingestion API; local DB and Agent Trace DB bootstrap continue to happen through `setup`, and DB health/repair continues to happen through `doctor`. See [agent-trace-sync-command.md](agent-trace-sync-command.md) and [sync-command.md](sync-command.md). diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 75c9f7b88..d3505ad72 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -82,7 +82,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - Startup/runtime config resolution degrades gracefully for default-discovered files across ordinary startup, setup, and Agent Trace storage: invalid discovered files are skipped and reported via collected `validation_errors`, while explicit `--config` / `SCE_CONFIG_FILE` targets still fail immediately on the same parse or validation errors. - Config file content must be valid JSON with a top-level object. -- Allowed keys: `$schema`, `log_level`, `log_format`, `log_to_file`, `log_dir`, `log_file_retention_limit`, `workos_client_id`, `control_plane_base_url`, `agent_trace`, `policies`, `integrations`. +- Allowed keys: `$schema`, `log_level`, `log_format`, `log_to_file`, `log_dir`, `log_file_retention_limit`, `workos_client_id`, `control_plane_base_url`, `agent_trace`, `otel`, `policies`, `integrations`. - Unknown keys fail validation. - `log_to_file` must be a boolean when present and defaults to `true`; it is independent of `log_dir`. - `log_level` must be one of `error|warn|info|debug`. @@ -97,6 +97,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - `agent_trace.repository_remote` must be a non-empty string when present; omitted values resolve to `origin`. - `agent_trace.auto_sync` must be a boolean when present; omitted values resolve to `false`. A newly created repo-local config from `sce setup` contains an explicit `true` value. +- `otel` must be an object when present and allows optional `endpoint`, `protocol`, `traces_endpoint`, `metrics_endpoint`, and `logs_endpoint`; `protocol` accepts only `http/protobuf` and the endpoint fields are non-empty strings. The block and each field are config-file only (global-before-local per-key merge) and omission never removes the code-owned managed OTLP defaults in `cli/src/services/managed_telemetry.rs` (`https://otel.crocoderlab.dev`, `http/protobuf`, and `/v1/traces`, `/v1/metrics`, `/v1/logs` paths); `show` reports each as `otel.` with `source: default` when unset. The metrics endpoint is reserved for a later pipeline. - `integrations` must be an object when present and currently allows `target` and `optional_workflows`; either key alone yields a parsed `IntegrationsConfig` with the other defaulting to empty. - `integrations.target` must be an array of unique canonical target IDs when present. - Supported target ID values: `opencode`, `claude`, `pi`, `codex`. diff --git a/context/cli/execution-glossary.md b/context/cli/execution-glossary.md index 609e2be93..d629bca91 100644 --- a/context/cli/execution-glossary.md +++ b/context/cli/execution-glossary.md @@ -3,7 +3,7 @@ - `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `sync`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, resolves unknown command paths to the longest valid parent's help surface, and returns deterministic actionable errors for unknown options and other invalid invocation. - `RuntimeCommand seam`: Internal static command-execution abstraction in `cli/src/services/command_registry.rs` where clap-parsed commands are represented as a `RuntimeCommand` enum with variants for `Help`, `HelpText`, `Version`, `Completion`, `Auth`, `Config`, `Setup`, `Doctor`, `Hooks`, `Policy`, and `Sync`. The enum owns `name()` and async `execute(...)` dispatch, directly awaited under the application-level multi-thread Tokio runtime (distinct from retained DB-owned runtimes), delegating behavior to service-owned command payload structs while avoiding boxed `dyn RuntimeCommand` handles. Parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. - `logger trait boundary`: `services::observability::traits::Logger` mirrors the current observability logger API (`info`, `debug`, `warn`, `error`, `log_cli_error`) for generic command/runtime bounds and tests, with each method accepting `Option<&str>` session context for file routing; the concrete `services::observability::Logger` implements it and `NoopLogger` remains available for side-effect-free tests. -- `telemetry trait boundary`: `services::observability::traits::Telemetry` defines the `with_default_subscriber` command-lifecycle capability used by generic app-runtime bounds and tests. The generic async callback deliberately makes this capability non-object-safe and statically selected through `HasTelemetry::Telemetry`. Production `NoopTelemetry` awaits the action once and installs no subscriber. Future static telemetry must scope every poll using future-aware subscriber attachment or an explicitly chosen global subscriber; a thread-local guard across await is insufficient. -- `AppContext`: Generic borrowed dependency view in `cli/src/app.rs` passed through static command dispatch. `AppRuntime` owns the concrete production logger, `NoopTelemetry` (no tracing subscriber), filesystem, and git implementations; `AppContext` stores references to those dependencies plus an optional `repo_root: Option`, not owned `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is command-scoped: borrowing across directly awaited execution is safe while the owner lives, but detached work cannot retain that borrowed view. No new `Send`, `Sync`, or `'static` command bounds are required. The `repo_root` field is `None` at startup and command paths can derive a repo-root-scoped context with `AppContext::with_repo_root(...)` / `ContextWithRepoRoot`, preserving the borrowed runtime dependencies while attaching the resolved root. Narrow accessor traits (`HasLogger`, `HasTelemetry`, `HasFs`, `HasGit`, `HasRepoRoot`) let command and lifecycle call sites express capability requirements without depending on the full production context type; logger/telemetry/fs/git accessors use associated concrete capability types and return `&Self::{Capability}` rather than object-erased `&dyn ...` values. +- `telemetry trait boundary`: `services::observability::traits::Telemetry` defines the `with_default_subscriber` command-lifecycle capability used by generic app-runtime bounds and tests. The generic async callback deliberately makes this capability non-object-safe and statically selected through `HasTelemetry::Telemetry`. `NoopTelemetry` awaits the action once and installs no subscriber; production `RuntimeTelemetry` selects it unless a valid unexpired telemetry credential is stored, in which case managed telemetry attaches its subscriber to the command future so every poll is scoped (a thread-local guard across await is insufficient). Managed telemetry exports OTLP/HTTP protobuf traces and logs to the hardcoded canonical collector, fail-open and with bounded shutdown after command output; hook invocations, `sce policy bash`, and `sce auth telemetry-headers` always use `NoopTelemetry`. +- `AppContext`: Generic borrowed dependency view in `cli/src/app.rs` passed through static command dispatch. `AppRuntime` owns the concrete production logger, `RuntimeTelemetry` (`NoopTelemetry` or managed export), filesystem, and git implementations; `AppContext` stores references to those dependencies plus an optional `repo_root: Option`, not owned `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is command-scoped: borrowing across directly awaited execution is safe while the owner lives, but detached work cannot retain that borrowed view. No new `Send`, `Sync`, or `'static` command bounds are required. The `repo_root` field is `None` at startup and command paths can derive a repo-root-scoped context with `AppContext::with_repo_root(...)` / `ContextWithRepoRoot`, preserving the borrowed runtime dependencies while attaching the resolved root. Narrow accessor traits (`HasLogger`, `HasTelemetry`, `HasFs`, `HasGit`, `HasRepoRoot`) let command and lifecycle call sites express capability requirements without depending on the full production context type; logger/telemetry/fs/git accessors use associated concrete capability types and return `&Self::{Capability}` rather than object-erased `&dyn ...` values. See [application execution runtime](../architecture.md#application-execution-runtime), [capability traits](capability-traits.md), and [observability](../sce/cli-observability-contract.md). diff --git a/context/context-map.md b/context/context-map.md index 896f6f877..3d725a77a 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -54,7 +54,7 @@ Feature/domain context: - `context/sce/cli-version-command-contract.md` (implemented `sce version` contract for deterministic human and machine-readable runtime identification) - `context/sce/cli-shell-completion-contract.md` (implemented `sce completion` contract for deterministic Bash/Zsh/Fish completion script generation) - `context/sce/claude-raw-hook-capture.md` (removed feature: the former `sce hooks claude-capture` raw-capture route and its supporting types, replaced by the active `diff-trace` and `conversation-trace` intakes) -- `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with explicit config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` env-over-config-over-`/sce/logs` fallback, append-only local-date/session log file routing with a one-time complete-record `-v2.log` fallback on primary open/append/flush failure, creation-triggered retention of direct regular `*.log` files to 10 entries, reliable producer-native diff-trace/conversation-trace session routing and hook-specific non-duplicated Agent Trace DB-open error events, deterministic session filename sanitization, concrete `Logger` behavior plus the `NoopTelemetry`-backed telemetry capability boundary, logger and deliberately non-object-safe generic async telemetry boundaries with static capability dispatch and future-aware polling scope, AppContext observability wiring, generic `RunOutcome` final rendering, runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) +- `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with explicit config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` env-over-config-over-`/sce/logs` fallback, append-only local-date/session log file routing with a one-time complete-record `-v2.log` fallback on primary open/append/flush failure, creation-triggered retention of direct regular `*.log` files to 10 entries, reliable producer-native diff-trace/conversation-trace session routing and hook-specific non-duplicated Agent Trace DB-open error events, deterministic session filename sanitization, concrete `Logger` behavior plus the `NoopTelemetry`-backed telemetry capability boundary, logger and deliberately non-object-safe generic async telemetry boundaries with static capability dispatch and future-aware polling scope, AppContext observability wiring, generic `RunOutcome` final rendering, runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, managed OTLP trace/log export (credential-gated, hardcoded canonical collector, allowlisted fields, bounded fail-open lifecycle, reserved metrics endpoint), and the trimmed `sce config validate` status-only validation surface) - `context/sce/shared-context-code-workflow.md` (canonical `/next-task` task-synchronization lifecycle and validation-only `/validate` lifecycle, package-local phase references with single-skill control flow, and the task-synchronization-scoped `sce-decision` sibling invocation with ADR reuse/blocker propagation) - `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, package-local context-load/plan-authoring/template references, clarification/readiness gate contract, and one-task/one-atomic-commit task slicing) - [Context workflow rules](sce/context-workflow-rules.md) (canonical bootstrap, ongoing context maintenance, task synchronization, hygiene, discoverability, and feature-existence rules) @@ -91,7 +91,7 @@ Feature/domain context: - `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, encrypted `EncryptedTursoDb`, build-time generated migration constants from `cli/build.rs`/Cargo `OUT_DIR`, config-driven async constructor/open-connect retry via `run_with_retry`, no-migration `TursoDb::open_without_migrations()` / explicit-path `open_without_migrations_at(path)` for hot runtime paths, migration-running `new()` / explicit-path `new_at(path)` / `run_migrations()` with per-database `__sce_migrations` tracking, config-driven async operation retry for `execute`/`query_map` (retry-policy budget, not a wall-clock bound), per-`DbSpec` Turso busy timeout on connect (1000 ms for the Agent Trace DB, unset for other databases), the Agent Trace-only outer write-contention retry for replay-safe write units including the opt-in `execute_idempotent_write` and its structured contention-exhaustion error plus `sce.agent_trace_db.contention_exhausted` tracing event, raw-value row fetching for deterministic operator-facing rendering, row-mapping excluded from retry, generic embedded migration execution, non-mutating `migration_metadata_problems()` and `ensure_schema_ready(setup_guidance)` readiness methods on `TursoDb`, non-mutating-data `passive_checkpoint()` PASSIVE WAL checkpoint method on `TursoDb` (not on `EncryptedTursoDb`, fail-open, no truncation guarantee, called once by `sce hooks post-commit` after successful Agent Trace persistence — see `context/sce/agent-trace-hooks-command-routing.md`), and async concrete wrappers for `LocalDb`, `AuthDb`, plus `RepositoryAgentTraceDb`; no adapter runtime bridge) - `context/sce/agent-trace-db-write-contention-evidence.md` (measured Agent Trace DB write-contention behavior under the busy-timeout/outer-retry/contention-deadline contract: the `lock_contention_tests.rs` suite with its lock-budget boundary, strict N=2–4 in-process and real-hook gates and N=8 stress characterization, policy history from 500 / 1250 to the current 1000 / 2250 defaults, the supported-load failure that motivated the tuning, before-vs-after comparison, and the bounded-policy exhaustion failure mode) - `context/sce/auth-db.md` (encrypted `AuthDb = EncryptedTursoDb` adapter, canonical `/sce/auth.db` path, build-time generated `AUTH_MIGRATIONS` from `cli/migrations/auth/`, auth credential schema and updated-at trigger baseline, lifecycle setup/doctor integration, async encrypted token-storage persistence through a Result-valued Tokio OnceCell, and `SCE_AUTH_DB_ENCRYPTION_KEY`/OS credential-store key handling) -- `context/sce/telemetry-credential.md` (telemetry-only OpenBao credential: typed Control Plane issuance client, save-on-issue, single-row encrypted `auth.db` storage that never mints on lookup, deterministic auth-login `telemetry_provisioning` status/message rendering with secret-free unavailable warnings, production issue-and-save after WorkOS persistence on fresh/valid-stored/renewed paths, logout deletion alongside WorkOS credentials, backend-owned identity enforcement, and local deletion not revoking the OpenBao token) +- `context/sce/telemetry-credential.md` (telemetry-only OpenBao credential: typed Control Plane issuance client, save-on-issue, single-row encrypted `auth.db` storage that never mints on lookup, deterministic auth-login `telemetry_provisioning` status/message rendering with secret-free unavailable warnings, production issue-and-save after WorkOS persistence on fresh/valid-stored/renewed paths, logout deletion alongside WorkOS credentials, issuing-token identity binding with session-ownership checks, repository-to-workspace enrollment bindings, local deletion not revoking the OpenBao token, and its use as the managed OTLP bearer source, the hidden `telemetry-headers` helper and its OS-user trust boundary with no endpoint-pinning claim, and Claude-native telemetry setup, rotation, logout, conflict handling, and override limits) - `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by the fresh multi-statement baseline schema plus additive `source_instance_id` and `claude_model_state` migrations, with `repository_metadata` carrying both `repository_id` and a concurrency-safe atomic-claim `source_instance_id` (physical database identity, independent of `repository_id`), narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, parts, and the non-exported exact-scope Claude model-state register, repository-level recent diff-trace reads without checkout filtering including persisted hunk-model plus canonical touched-line-session enrichment for structured rows, async on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) - `context/sce/agent-trace-export-readers.md` (implemented `AgentTraceExportReader<'a>` in `cli/src/services/agent_trace_export/mod.rs`: the read-only async local export boundary over `RepositoryAgentTraceDb` — `read_messages_after`/`read_parts_after`/`read_diff_traces_after`/`read_agent_traces_after`, each cursor/limit/JS-safe-integer validated, materialized into owned camelCase `serde::Serialize` DTOs; composes directly with `ResolvedAgentTraceStorage` without owning `source_instance_id`; `sce sync` consumes three of the four readers and `read_diff_traces_after` is a retained compatibility surface; no local sync cursor, no `agent-trace-sync.db`, no Turso Sync, no ETL, no DWH) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) diff --git a/context/glossary.md b/context/glossary.md index cdb6d226e..45e8ecc23 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -78,7 +78,7 @@ - `local Turso adapter`: Module in `cli/src/services/local_db/mod.rs` that defines `LocalDbSpec` and exposes `LocalDb` as a `TursoDb` alias. It resolves the canonical local DB path with `local_db_path()`, currently declares zero migrations, and inherits retry-backed `new()`, `execute()`, `query()`, and `query_map()` behavior from the shared generic adapter. - `encrypted Turso adapter`: Generic native async adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` and owning no Tokio runtime. Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, wraps encrypted local open/connect in the default DB connection-open retry policy, and runs embedded migrations after retry has produced a connection; execute, query, query_map, and migration operations are async and preserve `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. -- `telemetry credential`: Telemetry-only OpenBao token and accessor issued by the Control Plane `POST /auth/telemetry/credential`, stored as one fixed row in the encrypted auth DB `telemetry_credentials` table by `cli/src/services/telemetry_credential_storage.rs`, returned only while unexpired, removed by `sce auth logout`, and not revoked server-side by local deletion; see `context/sce/telemetry-credential.md`. +- `telemetry credential`: Telemetry-only OpenBao token and accessor issued by the Control Plane `POST /auth/telemetry/credential`, stored as one fixed row in the encrypted auth DB `telemetry_credentials` table by `cli/src/services/telemetry_credential_storage.rs`, returned only while unexpired, removed by `sce auth logout`, not revoked server-side by local deletion, and used as the sole bearer secret for managed OTLP export (never the accessor); see `context/sce/telemetry-credential.md`. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. - `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses the `agent-trace-repository` migration set (fresh baseline schema plus additive `source_instance_id` and `claude_model_state` migrations) with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, the non-exported `claude_model_state` latest-locally-observed register with exact `(session_id, agent_id)` reads and guarded writes, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. - `post-commit Agent Trace auto-sync readiness`: The doctor report fact that explains whether the enabled post-commit trigger is ready without invoking it. Doctor compares the installed `post-commit` hook's SCE managed block using the same currency semantics as setup and resolves config-file-only `agent_trace.auto_sync` with source metadata. JSON states are `ready`, `disabled`, `not_ready`, and `not_applicable`; explicit disable is healthy, while existing hook problems continue to own overall readiness and remediation. See [automatic Agent Trace synchronization](cli/agent-trace-auto-sync.md) and [doctor human text](sce/doctor-human-text-contract.md). diff --git a/context/overview.md b/context/overview.md index 66ab3ee82..358f57f86 100644 --- a/context/overview.md +++ b/context/overview.md @@ -65,7 +65,7 @@ Its command loop is implemented with `clap` derive-based argument parsing and `a The current doctor presentation contract supersedes the earlier output-shape scaffolding wording above: human text uses the compact Environment/Repository/Integrations hierarchy with healthy rows collapsed and unhealthy branches expanded, while JSON retains complete path, identity, problem, and fix-result detail. See `context/sce/doctor-human-text-contract.md`. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. Auth command orchestration routes authenticated Control Plane authentication failures, token-storage failures, and approved user-facing fallback failures through typed `CliError::User` catalog entries (`NotAuthenticated`, `AuthStorageUnavailable`, `NotGitRepository`, and `UnexpectedFailure`) while retaining technical sources for observability; missing-credential logout/whoami state queries succeed with their documented text/JSON reports, and internal auth failures remain runtime errors. The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: `CliError::User` carries a closed catalog (`NotAuthenticated`, `NotGitRepository`, `NotGitRemote`, authentication-storage `AuthStorageUnavailable`, and general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. Setup preflight errors preserve technical sources, including the configured remote name, while raw remote URLs remain out of user-facing diagnostics. See `context/sce/cli-error-code-taxonomy.md` for the full contract. -The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, normal logger records routed only to files when file logging is enabled and only error-level logger records routed to `stderr` when file logging is disabled, direct file-write diagnostics remaining on `stderr`, and `observability::traits` boundaries for logger and telemetry behavior. +The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, normal logger records routed only to files when file logging is enabled and only error-level logger records routed to `stderr` when file logging is disabled, direct file-write diagnostics remaining on `stderr`, and `observability::traits` boundaries for logger and telemetry behavior. When the encrypted auth DB holds a valid unexpired telemetry credential, non-hook commands also export `sce.command` spans and allowlisted logger events as OTLP/HTTP protobuf to the hardcoded `https://otel.crocoderlab.dev` `/v1/traces` and `/v1/logs` paths (bearer token only, redirects disabled, ambient `OTEL_EXPORTER_OTLP_*` and repo config cannot redirect it, fail-open and bounded); without a credential nothing is exported, and `/v1/metrics` is reserved with no metrics pipeline. One application-level multi-thread Tokio runtime directly awaits static command dispatch through async credentials, lifecycle providers, hooks, sync/export, and Turso persistence. The shared Turso adapters own no runtime or executor bridge, and foreground hooks return after required persistence completes. See [application execution runtime](architecture.md#application-execution-runtime). The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode sync progress are emitted on stderr; JSON sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, `hooks`, and `sync` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against the narrow `HasRepoRoot` accessor, uses lifecycle-owned health/fix/setup result types, and owns the shared static `LifecycleProvider` enum catalog/factory with deterministic config → local*db → auth_db → agent_trace_db → hooks ordering and no boxed provider aggregation. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for repository-scoped Agent Trace DB setup and repository DB path health/parent readiness from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path). Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. diff --git a/context/plans/claude-native-otel-instrumentation.md b/context/plans/claude-native-otel-instrumentation.md new file mode 100644 index 000000000..4f92264fe --- /dev/null +++ b/context/plans/claude-native-otel-instrumentation.md @@ -0,0 +1,168 @@ +# Plan: claude-native-otel-instrumentation + +## Change summary + +Extend `sce setup` so that selecting Claude (`--claude`, `--all`, or interactive Claude/All) installs Claude Code's native OpenTelemetry exporter configuration in Claude's user-level `settings.json` (`$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`). Authentication uses Claude's `otelHeadersHelper` pointing at a new hidden internal command, `sce auth telemetry-headers`, which reads the stored encrypted OpenBao telemetry credential on every call and prints exactly `{"Authorization":"Bearer "}`. + +This builds on the managed OTLP work already on this branch (PR #325): it reuses `MANAGED_OTLP_ENDPOINT` / `MANAGED_OTLP_PROTOCOL` from `cli/src/services/managed_telemetry.rs` and `telemetry_credential_storage::load_telemetry_credential`. It does not touch the Rust SCE-CLI exporter, except to extend its existing hook bypass (`is_hook_invocation` in `cli/src/app.rs`) to `sce policy bash` and `sce auth telemetry-headers`. No token is ever written to a settings file; rotation happens because Claude re-runs the helper every five minutes (`CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS=300000`). + +## Acceptance criteria + +- [x] AC1: `sce auth telemetry-headers` is callable but absent from `sce --help`, `sce auth --help`, and generated shell completions. + - Validate: targeted Rust tests asserting help/completion output omits `telemetry-headers` and that the subcommand parses. +- [x] AC2: With a valid unexpired stored credential the helper prints exactly one JSON object containing only `Authorization: Bearer `; a later stored replacement is returned on the next invocation. + - Validate: targeted Rust tests using a temp-state encrypted `auth.db` fixture (save, run, replace, run). +- [x] AC3: Missing, expired, malformed, or unavailable credentials exit nonzero with a fixed secret-free diagnostic and stdout stays empty; the helper performs no issuance or network call and needs no Git repository. + - Validate: targeted Rust tests asserting exit code, fixed stderr text, absence of token/accessor, and no repo/network dependency. +- [x] AC4: `sce hooks ...`, `sce policy bash`, and `sce auth telemetry-headers` never initialize managed exporter telemetry. + - Validate: unit tests on the managed-allowed predicate in `cli/src/app.rs`. +- [x] AC5: `sce setup --claude`, `--claude --non-interactive`, `--all`, and interactive Claude/All write the required `env` block and `otelHeadersHelper` into user-level Claude settings; OpenCode/Codex/Pi-only, `--hooks`-only, `--bootstrap-context`, and cancelled setup leave them untouched. + - Validate: targeted Rust setup tests with `CLAUDE_CONFIG_DIR` pointed at a temp dir. +- [x] AC6: Repeated setup is byte-idempotent, preserves unrelated settings/env/permissions/hooks and file mode, writes atomically, and refuses to overwrite malformed JSON. + - Validate: targeted Rust tests on the merge and install path. +- [x] AC7: A pre-existing user-owned `otelHeadersHelper`, or a conflicting generic/per-signal OTLP endpoint, headers, protocol, or exporter in user or managed settings, is left untouched, no SCE helper is installed, and setup reports the skipped telemetry install without failing repository setup. + - Validate: targeted Rust tests per conflict class. +- [x] AC9: No token, accessor, or `OTEL_EXPORTER_OTLP_HEADERS` appears in settings files, generated assets, setup output, or helper diagnostics. + - Validate: targeted Rust tests scanning those outputs for the fixture token. +- [x] AC10: Durable context documents Claude-native vs SCE-CLI telemetry, helper auth, rotation/logout, five-minute refresh, user-level scope, failure handling, and security boundaries, without listing the hidden command in the public command reference. + - Validate: inspection of the context files named under Context sync; grep confirms `telemetry-headers` is absent from `context/cli/cli-command-surface.md` public listing. + +### Full validation + +- `nix flake check` +- `nix run .#pkl-check-generated` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml ` + +### Context sync + +- `context/sce/telemetry-credential.md` +- `context/sce/cli-observability-contract.md` +- `context/cli/cli-command-surface.md` (setup behavior only; hidden command not listed publicly) +- `context/context-map.md`, `context/overview.md`, `context/glossary.md` if terms or entries change + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/auth_command/`, `cli/src/app.rs` bypass predicate, `cli/src/services/setup/` (merge + orchestration), related tests, and `context/` docs. +- **Out of scope:** changes to the Rust SCE-CLI managed exporter, credential issuance/rotation, repo-level Claude hooks and generated assets, Pkl sources, revocation, a public telemetry command. +- **Constraints:** reuse `MANAGED_OTLP_*` constants; static dispatch and concrete types; no code comments; Nix-wrapped Cargo only; tests stay a minimal focused set; automated tests never contact the production Collector or use production credentials. +- **Non-goal:** daemon, token cache, wrapper script, background credential issuance, or presenting the hidden command as a security boundary. + +## Assumptions + +- Telemetry setup runs inside the existing repo-bound setup orchestration (`SetupCommand::execute`), so it inherits the Git repository/remote precondition; only the helper itself is repo-independent. +- On any conflict, telemetry install is skipped and reported while repository setup still succeeds. +- Managed-settings detection reads the Linux/macOS system managed-settings file where feasible; process environment of a future Claude launch cannot be detected and is documented as able to override user settings. +- `OTEL_METRICS_INCLUDE_REPOSITORY=true` is written unconditionally because the installed Claude Code version cannot be reliably detected from setup and unknown env vars are ignored. +- The settings file mode is preserved when it exists; a new file uses `0600`. + +## Task stack + +- [x] T01: `Add hidden sce auth telemetry-headers helper with exporter bypass` (status:done) + - Task ID: T01 + - Scope: In — hidden `AuthSubcommand::TelemetryHeaders` (`hide = true`) in the Clap schema, parser conversion, auth dispatch, runtime handler reading `load_telemetry_credential()`, JSON output via serde, fixed secret-free failure text, extending the managed-telemetry bypass in `cli/src/app.rs` to `policy bash` and `auth telemetry-headers`, and minimal startup work/no repo requirement. Out — setup changes, docs. + - Dependencies: none + - Done when: AC1–AC4 and the helper portion of AC9 hold; completion and help output omit the subcommand; existing hook/policy exit codes and outputs are unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml telemetry_headers`; existing parser/help/completion tests still pass. + - Completed: 2026-10-14 + - Files changed: `cli/src/app.rs`, `cli/src/cli_schema.rs`, `cli/src/services/auth_command/mod.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/telemetry_credential_storage.rs` + - Result: Added hidden `AuthSubcommand::TelemetryHeaders` (Clap `hide = true`), parser conversion, dispatch, and a handler printing `{"Authorization":"Bearer "}` from `load_telemetry_credential()`; any missing/expired/unreadable credential returns a fixed secret-free runtime error ("Telemetry credential is unavailable.") with empty stdout and no network call. `is_hook_invocation` now also bypasses managed telemetry for `policy bash` and `auth telemetry-headers`. Removed the now-unneeded `dead_code` allow on `load_telemetry_credential`. + - Verify outcome: `telemetry_headers` filter: 3 passed; `hook_invocations` 1 passed; `auth_command` 11 passed; `command_runtime` 22 passed; `completion` 1 passed. Full `nix flake check` not run (reserved for plan validation). + - Deviations: Helper tests exercise the pure render function with in-memory credentials rather than a temp-state encrypted `auth.db` fixture; completion omission follows from Clap `hide = true` and is covered by the existing completion test plus help-omission assertions. + - Context impact: minor — hidden command and exporter bypass; durable docs deferred to T04. + - Context synchronization: synced + +- [x] T02: `Merge Claude native telemetry settings safely` (status:done) + - Task ID: T02 + - Scope: In — a user-level Claude telemetry merge in `cli/src/services/setup/config_merge.rs` that builds the `env` block and `otelHeadersHelper` from `MANAGED_OTLP_*` constants, preserves unrelated keys, detects conflicting helper/endpoint/headers/protocol/exporter settings (generic and per-signal, user and managed settings), rejects malformed JSON, and yields installed / already-current / skipped-with-reason. Out — filesystem writes and setup wiring. + - Dependencies: T01 + - Done when: AC6 (merge portion) and AC7 hold at the unit level with no tokens in output. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_merge` + - Completed: 2026-10-14 + - Files changed: `cli/src/services/setup/config_merge.rs` + - Result: Added `merge_claude_telemetry_settings` returning `Installed(bytes)` / `AlreadyCurrent` / `Skipped(reason)`; writes the Claude `env` block from `MANAGED_OTLP_*` plus `otelHeadersHelper`, preserves unrelated keys, skips on a user-owned helper or conflicting generic/per-signal endpoint/headers/protocol/exporter in user or managed settings, and errors on malformed JSON. Output carries no token material. + - Verify outcome: `config_merge` filter: 29 passed (7 new telemetry tests); `cargo build` clean under `-D warnings`. Full `nix flake check` not run (reserved for plan validation). + - Deviations: New items carry `#[allow(dead_code)]` until T03 wires them; T03 should remove it. + - Context impact: minor — internal merge helper; durable docs deferred to T04. + - Context synchronization: synced + +- [x] T03: `Wire Claude telemetry install into setup targets` (status:done) + - Task ID: T03 + - Scope: In — resolve `$CLAUDE_CONFIG_DIR`/`~/.claude/settings.json`, atomic write preserving permissions, invoke only when `ConcreteSetupTarget::Claude` is selected on proceed (all, non-interactive, interactive), add a fixed setup-output line stating user-level scope and, when no valid credential exists, that authentication is pending, without mentioning the helper command. Out — hooks-only, bootstrap-context, cancelled, and non-Claude paths. + - Dependencies: T02 + - Done when: AC5, AC6, AC7 (end to end), and AC9 hold. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup` + - Completed: 2026-10-14 + - Files changed: `cli/src/services/setup/claude_telemetry.rs`, `cli/src/services/setup/command.rs`, `cli/src/services/setup/config_merge.rs`, `cli/src/services/setup/mod.rs` + - Result: Added `setup::claude_telemetry` (settings path from `$CLAUDE_CONFIG_DIR` else `~/.claude/settings.json`, Linux/macOS managed-settings path, atomic temp-file+rename write preserving existing mode or `0600` for new files, fixed report lines). `SetupCommand::execute` installs only when the Claude target is selected on proceed, appends a fixed user-level line plus "authentication pending" when no valid stored credential exists; conflicts and malformed JSON are reported as skipped without failing setup. Removed T02 `dead_code` allows. + - Verify outcome: `setup` filter: 49 passed (6 new claude_telemetry tests); `cargo build` clean. Full `nix flake check` not run (reserved for plan validation). + - Deviations: Tests exercise the install function with explicit paths rather than mutating `CLAUDE_CONFIG_DIR`; no end-to-end `SetupCommand` or AC8 save-credential-then-helper test (helper reads the credential on each call and setup never embeds it). + - Context impact: minor — setup behavior and output line; durable docs deferred to T04. + - Context synchronization: synced + +- [x] T04: `Document Claude native telemetry in context` (status:done) + - Task ID: T04 + - Scope: In — update the context files listed under Context sync for Claude-native vs CLI telemetry, helper auth, rotation, logout, five-minute refresh, user-level scope, failure handling, and security boundaries, plus managed-settings/env override limitations. Out — public command reference listing of the hidden command. + - Dependencies: T03 + - Done when: AC10 holds. + - Verify: inspection; `nix flake check`. + - Completed: 2026-10-14 + - Files changed: `context/sce/telemetry-credential.md`, `context/sce/cli-observability-contract.md`, `context/cli/cli-command-surface.md`, `context/context-map.md`, `cli/src/services/auth_command/mod.rs` + - Result: Documented Claude-native vs SCE-CLI telemetry, helper auth, five-minute rotation, logout, user-level scope, failure handling, merge safety, and managed-settings/env override limits; setup behavior added to the command surface without listing `telemetry-headers` publicly (grep count 0 in `cli-command-surface.md`); context map entry updated. + - Verify outcome: inspection confirmed; `nix flake check` passed after the clippy fix below. + - Deviations: `nix flake check` initially failed on a T01 `clippy::manual_let_else` lint in `cli/src/services/auth_command/mod.rs`; fixed with an equivalent `let ... else` (no behavior change). + - Context impact: minor — documentation only. + - Context synchronization: synced + +## Open questions + +- `OTEL_METRICS_INCLUDE_REPOSITORY` is written unconditionally because Claude Code version support cannot be detected during setup. Is that acceptable, or should it be omitted until a supported-version check exists? +- Real ingestion (Collector accepting the bearer, Dash0 dataset routing, `claude_code.*` signal names) cannot be proven by automated tests and needs manual verification against the live Collector after implementation. + +## Validation Report + +**Status:** validated +**Date:** 2026-10-14 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed) +- `nix run .#pkl-check-generated` -> exit 0 (142 files, inventory parity) +- `run-cli-cargo.sh test ... telemetry_headers` -> exit 0 (5 passed) +- `run-cli-cargo.sh test ... hook_invocations` -> exit 0 (1 passed) +- `run-cli-cargo.sh test ... config_merge` -> exit 0 (29 passed) +- `run-cli-cargo.sh test ... claude_telemetry` -> exit 0 (9 passed) +- `run-cli-cargo.sh test ... setup` -> exit 0 (51 passed) +- `run-cli-cargo.sh test ... completion` -> exit 0 (1 passed) +- `grep -c telemetry-headers context/cli/cli-command-surface.md` -> 0 + +### Success-criteria verification + +- [x] AC1: hidden command -> `telemetry_headers_parses_and_is_hidden_from_help` and completion test pass +- [x] AC2: `telemetry_headers_helper_reads_stored_credential_and_follows_replacement` saves, renders, replaces, renders against a temp-state encrypted db +- [x] AC3: `telemetry_headers_helper_fails_with_fixed_diagnostic_for_unusable_stored_state` asserts exit code 4, fixed text, no token for missing/expired/malformed +- [x] AC4: `hook_invocations_never_resolve_managed_telemetry` passes +- [x] AC5: `setup_claude_telemetry_installs_when_claude_is_selected` and `..._leaves_settings_untouched_when_claude_is_not_installed` cover target-selection paths +- [x] AC6: idempotence, preserved settings/mode, malformed-JSON refusal covered by claude_telemetry and config_merge tests +- [x] AC7: user helper, per-class env conflicts, and managed-settings conflicts each have a test +- [x] AC9: no token material in outputs; tests assert diagnostics omit the fixture token +- [x] AC10: context files document the topic; `telemetry-headers` count in `cli-command-surface.md` is 0 + +### Failed checks and follow-ups + +None. + +### Residual risks + +- Real Collector ingestion cannot be proven by automated tests and needs manual verification. +- Uncommitted working-tree changes (new tests) were validated; commit them to preserve this result. diff --git a/context/plans/managed-otel-export.md b/context/plans/managed-otel-export.md new file mode 100644 index 000000000..341d96443 --- /dev/null +++ b/context/plans/managed-otel-export.md @@ -0,0 +1,179 @@ +# Plan: managed-otel-export + +## Change summary + +Add managed OpenTelemetry export to the Rust `sce` CLI for traces and logs. Managed telemetry activates automatically when the encrypted auth DB contains a valid, unexpired telemetry credential. The existing telemetry-credential provisioning performed by `sce auth login` remains the credential source; exporter startup only reads the credential and never mints or refreshes one. + +The CLI will hardcode one canonical OTLP/HTTP protobuf default set for the collector and all three signal paths. The repo-local `.sce/config.json` schema will accept an optional `otel` block for explicit declaration and future configuration, but its absence must never remove the code defaults. Traces and logs are implemented in this plan; the metrics endpoint is defined as a reserved default for the later metrics pipeline. The existing local logger, stdout/stderr contracts, hook fail-open behavior, and command lifecycle remain intact when export is unavailable or fails. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the check that proves it. `/validate` runs these checks; no task in the stack performs final validation. + +- [x] AC1: Managed OTLP defaults are hardcoded in the CLI as `endpoint=https://otel.crocoderlab.dev`, `protocol=http/protobuf`, `traces_endpoint=https://otel.crocoderlab.dev/v1/traces`, `metrics_endpoint=https://otel.crocoderlab.dev/v1/metrics`, and `logs_endpoint=https://otel.crocoderlab.dev/v1/logs`; `.sce/config.json` may omit the optional `otel` block or any optional field without changing those defaults, traces and logs consume their signal-specific endpoints, and no metrics exporter is created by this plan. + - Validate: Config-schema, resolver, and Rust tests assert optional-block/optional-field behavior and the exact code defaults; `sce config validate` accepts the repository config with the block absent; `sce config show --format json` reports code-default provenance when unset; source inspection confirms the metrics endpoint is reserved only. +- [x] AC2: A normal `sce` invocation automatically selects managed telemetry when `telemetry_credential_storage::load_telemetry_credential` returns a valid unexpired credential, uses its OpenBao `token` as an `Authorization: Bearer ` credential for OTLP requests, never transmits the stored `accessor`, and falls back to `NoopTelemetry` without minting or refreshing credentials when the row is absent, expired, malformed, or unavailable. + - Validate: Credential-mode and storage tests cover valid, absent, expired, malformed, and storage-error cases; a controlled OTLP receiver asserts the bearer header and absence of the accessor; an issuance-call fixture proves exporter startup performs no credential issuance request. +- [x] AC3: Managed command spans are exported as OTLP/HTTP protobuf to `/v1/traces` with stable SCE service identity and allowlisted command/outcome/error attributes, while command stdout, stderr, exit status, and existing local log-file behavior remain unchanged. + - Validate: A loopback receiver integration test decodes captured trace payloads and asserts endpoint, resource identity, span names, attributes, and output parity against telemetry-disabled execution. +- [x] AC4: Existing structured SCE logger records are exported as OTLP/HTTP protobuf logs to `/v1/logs`, retaining stable event identity, severity, message, and approved structured fields without exporting bearer credentials, accessors, raw command arguments, repository paths, hook payloads, or unrestricted error/source text; local file/stderr logging remains unchanged. + - Validate: A loopback receiver integration test decodes captured log payloads, asserts the expected event/severity/field mapping, and injects secret-bearing values to prove they are rejected or redacted before export. +- [x] AC5: Managed exporter lifecycle is async-safe and fail-open: subscriber scope follows every command-future poll on Tokio worker migration, exporter work does not block protected CLI operations, shutdown/flush is bounded and occurs after command output and required persistence complete, and exporter/network failures never alter command results or hook permission decisions. + - Validate: Focused lifecycle tests cover multi-poll/thread migration, concurrent commands, stalled/refused/redirecting receivers, bounded shutdown, lock isolation, and hook output/exit/decision parity. +- [x] AC6: Managed mode cannot exfiltrate the bearer token to an endpoint selected by repository config, inherited `OTEL_EXPORTER_OTLP_*` endpoint/header/protocol/certificate settings, or HTTP redirects; the managed collector remains the canonical `otel.crocoderlab.dev` destination and only the pinned OTLP/HTTP protobuf signal paths are accepted. + - Validate: Security integration tests exercise non-canonical `otel` config values, malicious repository config, inherited endpoint/header/protocol overrides, redirect responses, and alternate receivers; invalid/non-canonical config is rejected or ignored before export and all tests assert zero credential-bearing requests outside the canonical collector path. +- [x] AC7: Current-state documentation records the managed OTEL runtime, encrypted-auth-DB credential boundary, canonical code/config defaults, traces/logs scope, reserved metrics endpoint, lifecycle/failure behavior, and operator-visible configuration semantics without claiming that OTEL is configurable or active when no valid credential exists. + - Validate: Inspect the synchronized observability, auth/telemetry-credential, auth DB, config-schema/config-precedence, architecture, overview, glossary, and context-map documents against the implemented runtime and run the repository context/file checks in the full validation suite. + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of which criterion they map to. + +- `nix flake check` +- `nix run .#pkl-check-generated` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml observability` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml telemetry` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml auth` +- Inspect the managed OTLP endpoint, bearer-token, redirect, config-parity, and output-parity integration evidence without contacting the production collector. + +### Context sync + +- `.sce/config.json` +- `config/pkl/base/sce-config-schema.pkl` +- `cli/src/services/config/schema.rs` +- `cli/src/services/config/resolver.rs` +- `cli/src/services/config/render.rs` +- `context/sce/cli-observability-contract.md` +- `context/sce/telemetry-credential.md` +- `context/sce/auth-db.md` +- `context/cli/config-precedence-contract.md` +- `context/cli/execution-glossary.md` +- `context/architecture.md` +- `context/overview.md` +- `context/glossary.md` +- `context/context-map.md` + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. A completed task must be `synced` before another task can start or the plan can finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** beside the status. Never infer `synced` from conversation history; write every lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `.sce/config.json`; `config/pkl/base/sce-config-schema.pkl`; config DTO, resolver, rendering, and generated-schema parity; `cli/Cargo.toml` and lockfile; managed OTEL configuration/defaults; `cli/src/app.rs`; `cli/src/services/observability.rs` and focused observability submodules; `cli/src/services/telemetry_credential_storage.rs`; controlled OTLP receiver/test support; trace and log instrumentation boundaries; current-state context listed under Context sync. +- **Out of scope:** OTLP metrics production; telemetry credential issuance or refresh changes; Control Plane/server changes; a public telemetry command; repository-configurable collector selection; replacing local file/stderr logging; Agent Trace DB synchronization; arbitrary export of raw CLI arguments, paths, hook payloads, SQL, secrets, or full technical error chains. +- **Constraints:** Use the encrypted auth DB through the existing storage seam; use the bearer token only in managed OTLP request authentication; use OTLP/HTTP protobuf; pin compatible OpenTelemetry crates explicitly; hardcode the managed endpoint and signal-path defaults in Rust; keep the `.sce/config.json` `otel` block and each of its fields optional; do not make omission dependent on checked-in repo config; ignore ambient exporter endpoint/header/protocol/certificate overrides in managed mode; disable redirects; use future-aware subscriber attachment rather than a thread-local guard across `await`; keep exporter failures fail-open and bounded; run Cargo through the repository Nix wrapper. +- **Non-goal:** Defining or exporting metrics now. The `/v1/metrics` default is recorded so a later metrics implementation does not invent a competing endpoint contract. + +## Assumptions + +- Managed telemetry is automatic for every CLI invocation, including hook subprocesses, when a valid unexpired telemetry credential is available; hooks retain their existing fail-open behavior and exporter failures cannot change their decisions. +- The stored `TelemetryCredential::token` is the sole bearer secret sent to the collector. The stored `accessor` remains local metadata and is never sent unless a future server contract explicitly requires it. +- The canonical managed collector is pinned to `https://otel.crocoderlab.dev`; the standard `OTEL_EXPORTER_OTLP_*` names describe the built-in defaults but cannot redirect authenticated managed export. A separate user-selected standalone exporter mode is not part of this plan. +- The repo-local config schema accepts an optional `otel` object with optional `endpoint`, `protocol`, `traces_endpoint`, `metrics_endpoint`, and `logs_endpoint` keys. The code constants remain the source of defaults when the block or fields are absent; managed export still uses the canonical collector and does not allow repository or ambient configuration to redirect bearer-authenticated requests. +- The existing `sce auth login` best-effort provisioning and `sce auth logout` deletion behavior remain authoritative; exporter startup reads only an already-stored credential. + +## Task stack + +- [x] T01: `Define managed OTLP defaults, config surface, and credential-backed runtime selection` (status:done) + - Task ID: T01 + - Scope: In — hardcode the exact OTLP base/protocol/traces/metrics/logs defaults in Rust; add an optional `otel` object with optional fields to the Pkl-authored config schema, typed config parsing/resolution/rendering, and repo-local config contract; preserve generated-schema parity; add the managed signal configuration seam and runtime mode resolution that loads one valid telemetry credential from the encrypted auth DB and otherwise selects `NoopTelemetry`. Add pure tests for optional config shape, code-default fallback, endpoint derivation, credential validity decisions, bearer-only credential projection, and no-issuance behavior. Out — exporter construction, span/log emission, metrics pipeline, Control Plane changes. + - Dependencies: none + - Done when: Code-owned defaults work with no `otel` config block, optional config parses and renders without becoming a required source of defaults, valid credentials select managed mode, invalid/unavailable credentials select no-op behavior, and no startup path mints or refreshes a credential. + - Verify: `nix run .#pkl-check-generated`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml telemetry_credential`; targeted config-schema, config-resolution, managed-default, and runtime-mode tests. + - Completed: 2026-10-14 + - Files changed: `config/pkl/base/sce-config-schema.pkl`, `cli/src/services/config/schema.rs`, `cli/src/services/config/resolver.rs`, `cli/src/services/config/render.rs`, `cli/src/services/mod.rs`, `cli/src/services/managed_telemetry.rs`, `context/plans/managed-otel-export.md` + - Result: Added hardcoded managed OTLP defaults and pure credential-backed mode selection (`managed_telemetry`; only `token` projected as bearer, no issuance path), an optional `otel` object with optional fields in the Pkl schema, config parsing, layered resolution with code-default provenance, and `config show` text/JSON rendering. Metrics endpoint is reserved only; no exporter is built. `resolve_managed_telemetry_mode` is not yet wired into app startup (T02). + - Verify results: `nix run .#pkl-check-generated` passed (142 files); `telemetry_credential` tests passed (18); `managed_telemetry` (4), `otel` (5), and `config::` (45) tests passed; `cargo clippy --all-targets -D warnings` clean via nix develop. + - Context impact: important — config schema gains optional `otel` block and `config show` gains `otel.*` fields; managed defaults module added. + - Context synchronization: synced + +- [x] T02: `Implement managed OTLP trace export and command lifecycle spans` (status:done) + - Task ID: T02 + - Scope: In — add the pinned OpenTelemetry trace/provider/exporter runtime, OTLP/HTTP protobuf `/v1/traces` transport, bearer authentication, stable SCE resource identity, allowlisted command spans, private future-aware subscriber dispatch, bounded batch/shutdown lifecycle, and app-runtime wiring after T01. Out — OTLP logs, metrics, public configuration, raw logger-event export, and production collector verification. + - Dependencies: T01 + - Done when: Credential-backed non-hook command execution emits only approved trace data to the canonical traces endpoint, no-op execution remains behaviorally identical without a credential, and exporter activity is bounded and isolated from command/persistence critical paths. + - Verify: Focused trace lifecycle/endpoint tests, controlled loopback OTLP trace receiver tests, and `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml telemetry`. + - Completed: 2026-10-14 + - Files changed: `cli/Cargo.toml`, `cli/Cargo.lock`, `cli/src/app.rs`, `cli/src/services/managed_telemetry.rs`, `cli/src/services/managed_telemetry/trace_export.rs`, `context/plans/managed-otel-export.md` + - Result: Added pinned OpenTelemetry 0.33.1 OTLP/HTTP protobuf trace export. `ManagedTraceTelemetry` wraps each command future with a future-aware dispatch and an allowlisted `sce.command` span (command name, outcome, error code; logger events filtered out). A pinned HTTP client sends only the stored token as bearer to the canonical traces endpoint with redirects disabled and bounded timeouts. `RuntimeTelemetry` is resolved after startup (skipped for hook invocations), and bounded shutdown runs after command output is rendered. + - Verify results: `telemetry` filter tests passed (30, including loopback receiver bearer/allowlist, redirect, and unreachable-collector tests); `app::` tests passed (11); clippy `--all-targets -D warnings` clean; `cargo fmt` applied. + - Context impact: important — managed trace export runtime, new OpenTelemetry dependencies, lifecycle ordering in app runtime. + - Context synchronization: synced + +- [x] T03: `Implement managed OTLP log export from the existing logger boundary` (status:done) + - Task ID: T03 + - Scope: In — add the OTLP logs provider/exporter for `/v1/logs`, bridge approved SCE logger events into OpenTelemetry log records, preserve local file/stderr rendering and stable event semantics, apply the shared security/allowlist boundary, attach the bearer token only to the canonical logs endpoint, and integrate logs with the bounded lifecycle from T02. Out — metrics export, changing local log formats, exporting unrestricted tracing events or technical error chains. + - Dependencies: T02 + - Done when: Managed executions export approved SCE log records to the canonical logs endpoint with correct severity/resource/auth fields, while local logging and command output are unchanged and sensitive/unbounded fields never enter the remote payload. + - Verify: Controlled loopback OTLP log receiver tests, secret/path/argument rejection tests, local-log parity tests, and focused `observability`/`telemetry` Rust tests. + - Completed: 2026-10-14 + - Files changed: `cli/Cargo.toml`, `cli/src/services/managed_telemetry.rs`, `cli/src/services/managed_telemetry/trace_export.rs`, `cli/src/services/managed_telemetry/log_export.rs`, `context/plans/managed-otel-export.md` + - Result: Added OTLP/HTTP protobuf log export to the canonical `/v1/logs` endpoint. A `LogBridgeLayer` converts `target: "sce"` logger events (already gated by the local log level) into OTel log records with severity, event ID, and a body limited to safe messages; only allowlisted field keys (`command`, `component`, `failure_class`, `error_code`, `error_class`, `error_surface`, `user_error`) with path/secret-free bounded values are attached. Logs share the pinned bearer client, redirect refusal, bounded batch queue, and bounded shutdown with traces; local logging is untouched. + - Verify results: `telemetry` tests passed (32, including loopback bearer/log mapping, secret/path/argument rejection, redirect, unreachable-collector); `observability` tests passed (20); `app::` tests passed (11); clippy `--all-targets -D warnings` clean; `cargo fmt` applied. + - Context impact: important — managed runtime now exports logs; observability contract must record logs scope and allowlist. + - Context synchronization: synced + +- [x] T04: `Prove managed exporter security, failure isolation, and hook parity` (status:done) + - Task ID: T04 + - Scope: In — add the end-to-end controlled receiver and security regressions for bearer headers, endpoint pinning, ignored ambient overrides, redirect refusal, absent/expired credentials, stalled/refused receivers, bounded shutdown, concurrent execution, lock isolation, and hook output/exit/permission parity. Out — production collector access, metrics, credential provisioning changes, and a separate standalone telemetry mode. + - Dependencies: T03 + - Done when: All managed exporter failure and exfiltration cases are covered by deterministic local tests, and every failure mode preserves existing CLI and hook behavior. + - Verify: Named integration-test filters for managed export, endpoint security, shutdown, and hook parity; no test contacts a non-loopback collector. + - Completed: 2026-10-14 + - Files changed: `cli/src/app.rs`, `cli/src/services/managed_telemetry/trace_export.rs`, `context/plans/managed-otel-export.md` + - Result: Added loopback-only regressions: ambient `OTEL_EXPORTER_OTLP_*` endpoint/header/protocol overrides cannot redirect export or inject headers (alternate receiver never contacted, bearer-only on pinned paths); stalled receiver leaves command latency unaffected and shutdown bounded; concurrent commands keep isolated results and spans; hook invocations (including with leading flags) never resolve managed telemetry and disallowed resolution yields `Noop`. Existing tests already cover bearer/no-accessor, redirect refusal, refused receiver, and absent/expired/blank credentials. + - Verify results: `telemetry` filter tests passed (37); `app::` tests passed (13); clippy `--all-targets -D warnings` clean; `cargo fmt` applied. No test contacts a non-loopback collector. + - Context impact: minor — test-only additions; no runtime behavior change. + - Context synchronization: synced + +- [x] T05: `Record the managed OTEL current-state and config contract` (status:done) + - Task ID: T05 + - Scope: In — synchronize the observability, telemetry credential, auth DB, config schema/precedence, execution, architecture, overview, glossary, and context-map documents plus the optional repo-local config declaration with the implemented managed runtime, hardcoded defaults, signal scope, credential boundary, lifecycle guarantees, and metrics deferral. Out — rewriting historical plan records or presenting future metrics as implemented. + - Dependencies: T04 + - Done when: Durable current-state context and the optional repo-local config contract consistently distinguish code-owned managed traces/logs defaults from the reserved metrics endpoint, name the encrypted auth DB bearer source, and match the final code and tests. + - Verify: Context-owner inspection plus the focused repository context/file checks required by `/validate`. + - Completed: 2026-10-14 + - Files changed: `context/sce/telemetry-credential.md`, `context/sce/auth-db.md`, `context/sce/cli-observability-contract.md`, `context/cli/execution-glossary.md`, `context/architecture.md`, `context/overview.md`, `context/glossary.md`, `context/context-map.md`, `context/plans/managed-otel-export.md` + - Result: Current-state context now records the managed OTLP trace and log runtime, the encrypted-auth-DB bearer-token boundary (accessor never sent), code-owned canonical defaults with the optional `otel` config block, fail-open bounded lifecycle, hook `NoopTelemetry` behavior, and the reserved metrics endpoint. `config-precedence-contract.md` already carried the optional `otel` block contract from T01 and was verified unchanged; `.sce/config.json` intentionally keeps the optional `otel` block absent. + - Verify results: Context-owner inspection against `managed_telemetry` code and plan T01-T04 records passed; touched files keep their prior size (pre-existing `architecture.md` length unchanged in scope); no stale "foundation only"/trace-only/`NoopTelemetry`-only statements remain in the edited files. + - Context impact: important — documentation-only synchronization of the managed OTEL current state. + - Context synchronization: synced + +## Open questions + +None. The plan implements automatic managed export for traces and logs, hardcodes the defaults in the CLI, keeps the `.sce/config.json` OTEL block optional, uses the encrypted auth DB's OpenBao bearer token, pins the collector, and reserves metrics for a later implementation. + +## Validation Report + +**Status:** validated +**Date:** 2026-10-14 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed; cli-tests, clippy, fmt green) +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed, 142 files) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml observability` -> exit 0 (20 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml telemetry` -> exit 0 (37 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml auth` -> exit 0 (16 passed) +- Inspection of loopback integration evidence and context docs -> bearer, redirect, ambient-override, stalled/refused, and parity tests present and passing; docs match runtime + +### Success-criteria verification + +- [x] AC1: Managed defaults and optional `otel` block -> config/managed-default tests and flake check pass; `.sce/config.json` has no `otel` block; metrics endpoint reserved only +- [x] AC2: Credential-backed selection, bearer-only auth, no issuance -> telemetry/auth tests pass +- [x] AC3: Trace export to `/v1/traces` with output parity -> loopback trace tests pass under flake check +- [x] AC4: Log export mapping and redaction -> loopback log tests pass +- [x] AC5: Async-safe, fail-open, bounded lifecycle -> stalled/unreachable/concurrent tests pass +- [x] AC6: Endpoint pinning, ambient override and redirect refusal -> security tests pass +- [x] AC7: Current-state documentation -> context docs inspected against runtime; context checks pass + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- Production client TLS root-store behavior on hosts lacking system CA certificates is unverified (sandbox tests now use an explicit empty root store). diff --git a/context/sce/auth-db.md b/context/sce/auth-db.md index 91953ded0..08e1163e2 100644 --- a/context/sce/auth-db.md +++ b/context/sce/auth-db.md @@ -14,6 +14,7 @@ The encrypted auth DB foundation provides a thin Rust wrapper, path and migratio - `001_create_auth_tokens.sql` - `002_create_auth_credentials_updated_at_trigger.sql` - `003_create_telemetry_credentials.sql` + - `004_bind_telemetry_credentials.sql` ## Schema baseline @@ -43,7 +44,9 @@ Current migration baseline: - `expires_at_unix_seconds INTEGER NOT NULL`, `stored_at_unix_seconds INTEGER NOT NULL` - `created_at` / `updated_at` with the same defaults as `auth_credentials`, plus the `telemetry_credentials_set_updated_at` trigger -Storage operations live in `cli/src/services/telemetry_credential_storage.rs`: `save_telemetry_credential` is a single-statement `INSERT OR REPLACE` of the fixed row (last complete writer wins), `load_telemetry_credential` returns the row only when token and accessor are non-empty, the expiry is non-negative, and it is in the future (it never mints or refreshes), and `delete_telemetry_credential` removes the row. `TelemetryCredential` has a redacted `Debug`, and storage errors use fixed messages without secret values. Saving or deleting never touches `auth_credentials`. The shared `AuthDb` singleton comes from `token_storage::get_auth_db`. `sce auth logout` deletes both the `auth_credentials` row and the telemetry row; see [telemetry-credential.md](telemetry-credential.md) for lifecycle and security boundaries. +`004_bind_telemetry_credentials.sql` adds `user_id` and `workspace_id` (`TEXT NOT NULL DEFAULT ''`) to `telemetry_credentials` and creates `telemetry_repository_bindings` (`repository_id TEXT PRIMARY KEY`, `user_id`, `workspace_id`, `bound_at_unix_seconds`). Rows predating `004` keep empty identity values and are never returned. + +Storage operations live in `cli/src/services/telemetry_credential_storage.rs`: `save_telemetry_credential` is a single-statement `INSERT OR REPLACE` of the fixed row (last complete writer wins), `load_telemetry_credential` returns the row only when token, accessor, and identity are non-empty, the expiry is non-negative and in the future, and the stored WorkOS session's access-token `(sub, workspace_id)` claims equal the row's identity (it never mints or refreshes), `load_repository_telemetry_credential` additionally requires a matching repository binding, `bind_repository_telemetry` records a binding for the current credential identity, and `delete_telemetry_credential` removes the row. `TelemetryCredential` has a redacted `Debug`, and storage errors use fixed messages without secret values. Saving or deleting never touches `auth_credentials`. Managed OTLP export reads this row at CLI startup through `load_telemetry_credential` and uses only the token as bearer. The shared `AuthDb` singleton comes from `token_storage::get_auth_db`. `sce auth logout` deletes both the `auth_credentials` row and the telemetry row; see [telemetry-credential.md](telemetry-credential.md) for lifecycle and security boundaries. ## Lifecycle integration diff --git a/context/sce/cli-observability-contract.md b/context/sce/cli-observability-contract.md index 12692b805..d8a6d6d7d 100644 --- a/context/sce/cli-observability-contract.md +++ b/context/sce/cli-observability-contract.md @@ -54,7 +54,7 @@ Runtime observability consumes the shared resolved observability config from `cl - All `CliError` instances are logged via `Logger::log_cli_error()` before user-facing stderr diagnostics are written; observability retains full technical detail independently of what is rendered to the terminal, and never writes a second competing stderr diagnostic for the same error. - Event records include deterministic metadata keys used by automation (`command`, `failure_class`, `component` when applicable). - Error log records include `error_code` and `error_class` fields for structured observability, plus `error_surface` (`user` for `CliError::User`, `internal` for `CliError::Internal`), `user_error` (the catalog `UserError::key()`, present only for `CliError::User`), and `error_source` (the full technical source chain, present whenever one was preserved — always for `CliError::Internal`, and for `CliError::User` only when constructed with `CliError::user_with_source`). -- App runtime wraps parse/dispatch in `Telemetry::with_default_subscriber`. Production uses `NoopTelemetry`, which installs no tracing subscriber and no tracer provider. Raw `tracing` events emitted outside `Logger`, such as `sce.resilience.retry` and `sce.agent_trace_db.contention_exhausted`, are instrumentation points with no production sink. `Logger` records still follow the file/stderr routing below. +- App runtime wraps parse/dispatch in `Telemetry::with_default_subscriber`. Production uses `RuntimeTelemetry`: `NoopTelemetry` (no subscriber, no tracer provider) unless a valid unexpired telemetry credential is stored, in which case `ManagedTraceTelemetry` exports command spans and allowlisted logger events. Raw `tracing` events emitted outside `Logger`, such as `sce.resilience.retry` and `sce.agent_trace_db.contention_exhausted`, have no production sink; managed telemetry exports only `sce.managed.trace` spans and `target: "sce"` `Logger` events (allowlisted, see Managed trace export). `Logger` records still follow the file/stderr routing below. - Tracing event emission checks the `sce` target and requested tracing level before constructing serialized `fields` payloads; disabled or filtered tracing events return without building field JSON while enabled events preserve the same `event_id`, `event_message`, and `fields` payload shape. ## Format contract @@ -72,8 +72,8 @@ Runtime observability consumes the shared resolved observability config from `cl - The concrete `services::observability::Logger` implements the trait while retaining the existing inherent methods and behavior. - `NoopLogger` is available from the same traits module for tests and future dependency-injected services that need a logger without side effects. - The same traits module exposes deliberately non-object-safe `services::observability::traits::Telemetry`: async `with_default_subscriber` accepts generic `FnMut() -> Fut` actions and awaits command-lifecycle execution through static associated capability dispatch. -- Production currently uses `NoopTelemetry` from the same traits module. Its `with_default_subscriber` implementation awaits the action once and installs no tracing subscriber or tracer provider. Future statically dispatched telemetry/OTEL must attach the subscriber to the command future (for example `WithSubscriber::with_subscriber`) or deliberately install it globally; holding a thread-local `with_default`/`set_default` guard across awaits does not scope every poll on multi-thread Tokio. -- `cli/src/app.rs` stores the concrete production `Logger` and `NoopTelemetry` as `AppRuntime` fields, creates borrowed `AppContext` views for command execution, and exposes logger/telemetry access through associated-type context accessors instead of owned `Arc` fields or object-erased accessor return values. +- `NoopTelemetry` from the same traits module awaits the action once and installs no tracing subscriber or tracer provider. Managed telemetry attaches its subscriber to the command future with `WithSubscriber::with_subscriber`; holding a thread-local `with_default`/`set_default` guard across awaits does not scope every poll on multi-thread Tokio. +- `cli/src/app.rs` stores the concrete production `Logger` and `RuntimeTelemetry` as `AppRuntime` fields, creates borrowed `AppContext` views for command execution, and exposes logger/telemetry access through associated-type context accessors instead of owned `Arc` fields or object-erased accessor return values. - Final stream rendering uses `RunOutcome` in `cli/src/services/app_support.rs`, so classified-error and stdout-write-failure logging depends on the logger trait boundary rather than the concrete production logger type. - `run_command_lifecycle` expects the telemetry subscriber action to execute command dispatch at most once; arguments and the mutable stderr reference are taken from single-use options before constructing the action future; if a telemetry implementation invokes the action again, the app returns a `SCE-ERR-RUNTIME` classified error rather than panicking or reparsing consumed arguments. @@ -91,3 +91,19 @@ Runtime observability consumes the shared resolved observability config from `cl - `cli/src/services/observability.rs` owns runtime logger construction from resolved values, storage and application of `log_file_retention_limit`, `log_dir` non-empty validation, level filtering, tracing-event enablement checks, record rendering, local-date/session file-name selection, session filename sanitization, primary append plus one-time v2 fallback persistence, and best-effort `.log` retention; `cli/src/services/observability/traits.rs` owns the logger and telemetry trait boundaries plus the no-op logger implementation. - `cli/src/app.rs` owns lifecycle event emission around parse/dispatch success and failure paths, resolves observability config before command dispatch, emits startup invalid-config warning events for skipped discovered config files, wraps dispatch inside the observability subscriber context, and guards the single-use command-dispatch action against repeated telemetry invocation with a runtime-classified error. `cli/src/services/app_support.rs` owns final stdout/stderr rendering and generic logger-backed classified-error logging. - Retention-specific validation uses packaged CLI smoke checks for config/schema behavior and direct review of the primary/v2 logger cleanup plumbing. The root flake check suite validates the build, lint, formatting, generated parity, and remaining repository tests; no retention-specific Rust test module is currently kept in `observability.rs`, `config/resolver.rs`, or `config/schema.rs`. + +## Managed trace export + +- Defined in `cli/src/services/managed_telemetry.rs` and `cli/src/services/managed_telemetry/trace_export.rs` (plus `log_export.rs`). After startup, `RuntimeTelemetry::resolve` loads the stored telemetry credential from the encrypted auth DB in a spawned task bounded by `CREDENTIAL_LOOKUP_DEADLINE` (2s); absent, expired, malformed, unavailable, ownerless (no stored WorkOS session), or timed-out credentials select `NoopTelemetry`. Startup never issues or refreshes a credential. Hook invocations (`sce hooks ...`), `sce policy bash`, and the hidden `sce auth telemetry-headers` helper always use `NoopTelemetry` (`is_hook_invocation` in `cli/src/app.rs`). +- Export is OTLP/HTTP protobuf to the hardcoded `https://otel.crocoderlab.dev/v1/traces`. A pinned HTTP client sets `Authorization: Bearer ` (token only, never the accessor), disables redirects, rebuilds the request to the fixed endpoint, and bounds requests to 3s. Ambient `OTEL_EXPORTER_OTLP_*` values cannot redirect it. +- Each command emits one `sce.command` span with `sce.command.name`, `sce.outcome` (`success`/`failure`), and `sce.error.class` (the `SCE-ERR-*` code on failure); resource attributes are `service.name=sce` and `service.version`. Raw arguments, paths, and error chains are not exported. +- The exporter uses a bounded batch queue on a dedicated thread. Final stdout/stderr rendering and classified-error logging run inside the command span (`run_command_lifecycle_with_context`), so `sce.outcome` reflects the rendered result (a stdout write failure is `failure`) and the final `sce.error.*` log is exported. Trace and log providers shut down concurrently under one shared `SHUTDOWN_TIMEOUT` (3s) deadline after rendering; failures never change command results or exit codes. +- Process lifetime: `app::run_process` owns the Tokio runtime (at least two workers) and ends it with `shutdown_timeout(RUNTIME_SHUTDOWN_GRACE)` (500ms), so a stalled keychain, database, or exporter worker cannot keep the process alive. The async deadlines bound when the command proceeds; the runtime grace is the hard process-level bound. Abandoned workers are not cancelled, only no longer awaited. +- Logs: `managed_telemetry/log_export.rs` bridges `target: "sce"` tracing events and their `enabled!` hints (emitted by `Logger` only when the local level is enabled) to OTLP/HTTP protobuf log records at the hardcoded `/v1/logs`, using the same pinned bearer client, batch bounds, and shutdown as traces. Records carry severity, `sce.event_id`, a body limited to path- and secret-free messages (falling back to the event ID), and only allowlisted fields (`command`, `component`, `failure_class`, `error_code`, `error_class`, `error_surface`, `user_error`) with bounded, redacted values; `error_source`, arguments, paths, and hook payloads are never exported. Local file/stderr logging is unchanged. +- Metrics are not exported; the `/v1/metrics` default is reserved. + +## Claude-native telemetry vs SCE-CLI telemetry + +- The managed exporter above covers only the Rust `sce` process. Claude Code's native OpenTelemetry export is a separate pipeline configured in Claude's user-level `settings.json` by `sce setup` for Claude targets; SCE does not proxy, wrap, or cache it. +- Both pipelines target the same managed OTLP endpoint and authenticate with the same stored telemetry credential, but Claude obtains the bearer by running the hidden `sce auth telemetry-headers` helper (re-run every five minutes), which never initializes managed exporter telemetry and returns the bearer only for a repository enrolled under the credential's workspace. The helper cannot observe Claude's effective exporter endpoint, so endpoint pinning is not claimed. +- Setup skips the Claude install on conflicting user-owned or managed settings and never writes a token. Details, rotation, logout, failure handling, and override limits: [telemetry-credential.md](telemetry-credential.md). diff --git a/context/sce/telemetry-credential.md b/context/sce/telemetry-credential.md index 956e09767..bf48aa36a 100644 --- a/context/sce/telemetry-credential.md +++ b/context/sce/telemetry-credential.md @@ -1,10 +1,11 @@ # Telemetry credential -The CLI holds one telemetry-only OpenBao credential, issued by the Control Plane and cached in the encrypted auth DB. This is foundation only: there is no user-facing provisioning command, exporter, refresh loop, or doctor check. +The CLI holds one telemetry-only OpenBao credential, issued by the Control Plane and cached in the encrypted auth DB. `sce auth login` provisions it best-effort, and managed OTLP trace and log export reads it at startup as the bearer source. There is no user-facing provisioning command, refresh loop, or doctor check. ## Components - Client: `AuthenticatedControlPlaneClient::issue_telemetry_credential` in `cli/src/services/agent_trace_sync/control_plane.rs` posts to `/auth/telemetry/credential` with the WorkOS bearer and no body, and validates `{ token, accessor, expiresAt }` (non-empty token and accessor, future RFC3339 `expiresAt`). +- Identity: the credential is bound to the `(sub, workspace_id)` claims of the exact WorkOS access token sent with the issuance request, the same verified claims Control Plane reads into `CurrentUser` to mint the token. If that bearer has no complete identity (no `workspace_id`, or an undecodable token), issuance fails with the fixed invalid-response error and nothing is saved. - Save-on-issue: `issue_and_save_telemetry_credential` persists only after successful issuance, so any failure leaves previously stored state intact. - Storage: `cli/src/services/telemetry_credential_storage.rs` (`save_telemetry_credential`, `load_telemetry_credential`, `delete_telemetry_credential`) over the shared `AuthDb`; schema in [auth-db.md](auth-db.md). - Auth login provisioning: every successful `sce auth login` path — fresh device authorization, valid stored credentials, and renewed stored credentials — invokes `AuthenticatedControlPlaneClient::issue_and_save_telemetry_credential` after WorkOS persistence. The deterministic `telemetry_provisioning` result reports `issued` or `unavailable`; unavailable provisioning preserves WorkOS authentication success and gives text callers a fixed secret-free retry warning. @@ -19,16 +20,46 @@ flowchart LR Logout[sce auth logout] --> Delete[delete row] ``` -- Lookup never mints or refreshes; an expired, empty-field, or negative-expiry row is returned as `None`. +- Lookup never mints or refreshes; an expired, empty-field, negative-expiry, or identity-less (pre-`004` legacy) row is returned as `None`, so legacy rows require one `sce auth login`. +- Ownership: every lookup decodes the stored WorkOS session's access-token claims and returns the credential only when `(sub, workspace_id)` equals the identity stored with it. Switching user or workspace therefore makes an older credential unusable even if it was never deleted. A new device-authorization login also deletes the stored telemetry credential before saving the new WorkOS session (`replace_session_with`), so a failed re-issuance leaves no credential; if that delete fails, the new session is not saved. Renewal of the same session keeps the credential. +- Repository bindings: `telemetry_repository_bindings` maps a repository ID (the canonical `repository_identity` hash, from `agent_trace.repository_id` or the configured remote) to the `(user_id, workspace_id)` it was enrolled under. Enrollment requires a valid, session-owned credential and records that credential's identity. `sce setup` with the Claude target enrolls the repository explicitly (`Enroll`, rebinding it to the current identity). A successful `sce auth login` enrolls the working-directory repository only if it is unbound (`EnrollIfUnbound`), so a login never silently moves a repository to another workspace. Bindings survive logout and rotation; they authorize nothing without a matching credential. - Concurrent saves are single-statement atomic replacements of one fixed row; the last complete writer wins. - `sce auth logout` deletes the WorkOS credential and the telemetry credential, attempting both even when one fails and surfacing the first storage error. `credentials_removed` is true when either record was deleted; output fields are otherwise unchanged. - Telemetry save/delete never touches the WorkOS `auth_credentials` row. +## Runtime use + +- `managed_telemetry::RuntimeTelemetry::resolve` reads the stored credential at CLI startup (never issuing one); a valid unexpired `token` is used only as the `Authorization: Bearer` credential for managed OTLP trace and log export; the `accessor` is never transmitted. Absent, expired, malformed, or unavailable rows select `NoopTelemetry`. See [cli-observability-contract.md](cli-observability-contract.md). + +## Hidden header helper + +- `sce auth telemetry-headers` is a hidden (`hide = true`) subcommand intended for Claude Code's `otelHeadersHelper`. On every call it prints exactly one JSON object, `{"Authorization":"Bearer "}`, so a replaced credential is picked up on the next call. +- Launch context (verified against Claude Code 2.1.284 with a real `claude -p` run): Claude runs the helper with no arguments, with its working directory set to the directory Claude was launched from (possibly a subdirectory), and without `CLAUDE_PROJECT_DIR`. Claude's documentation states that it does not pass `OTEL_*` variables to subprocesses. The helper therefore uses only the working directory, ignores `CLAUDE_PROJECT_DIR` (a value inherited from a parent session could name another project), and never requires an `OTEL_*` variable to be present. +- Authorization: the helper walks up to the enclosing Git root, resolves the repository ID with the same precedence as Agent Trace storage, and prints headers only when that repository is bound to the identity of a valid credential that the current WorkOS session owns (see Repository bindings). An unbound repository, a repository bound to another user or workspace, a directory outside Git, or a repository without a resolvable identity gets no token. `.claude/settings.json`, SCE hook markers, and remote names are never treated as proof of ownership. +- Destination tripwire: if `OTEL_EXPORTER_OTLP_ENDPOINT`, a per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT`, or `BETA_TRACING_ENDPOINT` is visible to the helper with a non-canonical value, the helper refuses. This catches visible misconfiguration only. It is not endpoint pinning (see Security boundaries). +- Repository resolution and the credential lookup together are bounded by `CREDENTIAL_LOOKUP_DEADLINE`. Resolution spawns one `git config --get` process, which is needed to compute the repository ID; there is no network call and no issuance. Every failure exits nonzero with the fixed stderr diagnostic `Telemetry credential is unavailable.` and empty stdout. The helper never initializes managed exporter telemetry. +- It is omitted from `sce --help`, `sce auth --help`, and shell completions. + ## Security boundaries -- Identity and workspace enforcement belong to the backend; the CLI does not read `user.id` / `workspace.id` from `/me` and does not bind or compare identity on lookup. -- Local deletion does not revoke the OpenBao token; server-side revocation is out of scope and the token remains valid until it expires. -- Token and accessor never appear in errors, logs, or `Debug` output; `TelemetryCredential` and the issuance response redact them, client and storage errors use fixed messages, and auth login's best-effort provisioning warning contains no credential or raw server-response data. +- Enforced: a token is printed only for a repository enrolled under the identity that owns both the stored credential and the current WorkOS session. Telemetry from a repository enrolled under workspace B cannot be authorized with workspace A's credential, even when both repositories use SCE. +- Trust boundary: the local OS account. Any process running as the user can run `sce auth telemetry-headers` from an enrolled repository, or read the auth DB key, and obtain the token. The helper is not a defense against code the user executes; it prevents accidental cross-workspace attribution and refuses visible misdirection. +- Endpoint pinning is not claimed. The helper cannot observe Claude's effective exporter destination. Claude's documentation says `OTEL_*` variables are not passed to subprocesses, and in testing, project-level `env` overrides were not visible to the helper. Server-managed settings and `--settings` files are also not inspectable from the helper. Claude itself pins the destination only when the endpoint, or `otelHeadersHelper`, is set in managed settings (Claude Code 2.1.217+ for per-signal endpoints, 2.1.251+ for `BETA_TRACING_ENDPOINT`); a user-level install has no such guarantee. +- Residual risk: with a user-level install, any configuration path that Claude honors but the helper cannot see (a shell `OTEL_*`/`BETA_TRACING_ENDPOINT` variable in a Claude version that strips them from helpers, `--settings`, or a future precedence change) can send the bearer to another collector. The exposure is bounded by the token's scope: telemetry-only, non-renewable, 90-day lifetime. Local deletion does not revoke it; server-side revocation by accessor is not yet available. Organizations that need an enforced destination should set `OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.crocoderlab.dev` and `otelHeadersHelper` in Claude managed settings. +- Token and accessor never appear in errors, logs, or `Debug` output; `TelemetryCredential` and the issuance response redact them, client and storage errors use fixed messages, and auth login's best-effort provisioning warning contains no credential or raw server-response data. The bearer is never written to Claude settings. - Encryption reuses the auth DB key handling described in [auth-db.md](auth-db.md). -See also: [auth-db.md](auth-db.md), [../context-map.md](../context-map.md) +## Claude-native telemetry + +Claude Code's own OpenTelemetry export is separate from the SCE-CLI managed exporter above. `sce setup` configures it; the SCE CLI exporter is unchanged. + +- Install: when the Claude target is selected and setup proceeds (`--claude`, `--all`, interactive Claude/All, with or without `--non-interactive`), `cli/src/services/setup/claude_telemetry.rs` merges settings into Claude's user-level `settings.json` (`$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`). OpenCode/Codex/Pi-only, `--hooks`-only, `--bootstrap-context`, and cancelled runs leave it untouched. Scope is user-level, not repository-level. +- Written keys: `otelHeadersHelper` set to `sce auth telemetry-headers`, plus an `env` block with `CLAUDE_CODE_ENABLE_TELEMETRY=1`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` (required by Claude Code for span tracing), `OTEL_{TRACES,METRICS,LOGS}_EXPORTER=otlp`, `OTEL_EXPORTER_OTLP_PROTOCOL` / `OTEL_EXPORTER_OTLP_ENDPOINT` from `MANAGED_OTLP_PROTOCOL` / `MANAGED_OTLP_ENDPOINT`, `CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS=300000`, and `OTEL_METRICS_INCLUDE_REPOSITORY=true` (unconditional because the Claude Code version cannot be detected during setup; unknown variables are ignored). +- Authentication: no token is ever written to settings, generated assets, or setup output, and `OTEL_EXPORTER_OTLP_HEADERS` is never set. Claude runs the hidden helper (see Hidden header helper) and uses its `Authorization` header. +- Rotation: Claude re-runs the helper at most every five minutes (debounce 300000 ms), so a credential replaced by a later `sce auth login` is picked up without rewriting settings. `sce auth logout` deletes the stored credential, after which the helper fails and Claude export stops authenticating; local deletion does not revoke the OpenBao token. +- Repository enrollment: after a successful install, setup enrolls the current repository under the stored credential's identity, bounded by `CREDENTIAL_LOOKUP_DEADLINE`. When no valid session-owned credential exists, setup succeeds and reports `authentication pending for this repository`; running `sce auth login` inside the repository enrolls it and starts export. +- Failure handling: the helper's failure is nonzero with empty stdout and the fixed diagnostic `Telemetry credential is unavailable.`, and never affects other Claude behavior beyond missing telemetry auth. +- Merge safety: repeated setup is byte-idempotent; unrelated settings, env, permissions, and hooks are preserved; writes are atomic via temp-file rename; an existing file's mode is kept and a new file uses `0600`; malformed JSON is never overwritten. +- Conflicts and limits: a user-owned `otelHeadersHelper`, an explicit opt-out (`CLAUDE_CODE_ENABLE_TELEMETRY`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`, or `ENABLE_ENHANCED_TELEMETRY_BETA` not `1`, or any `OTEL_*_EXPORTER=none`), `OTEL_METRICS_INCLUDE_REPOSITORY` other than `true`, a different helper refresh interval, or a differing generic/per-signal OTLP endpoint, headers, protocol, or exporter in user settings or in the Linux/macOS managed-settings file (`/etc/claude-code/managed-settings.json`, `/Library/Application Support/ClaudeCode/managed-settings.json`), causes the telemetry install to be skipped and reported with the specific reason while repository setup still succeeds. Setup enrolls the repository only after a successful install. Environment variables present in a future Claude launch cannot be detected by setup and can override user settings. + +See also: [auth-db.md](auth-db.md), [cli-observability-contract.md](cli-observability-contract.md), [../context-map.md](../context-map.md) diff --git a/nix/flatpak/cargo-sources.nix b/nix/flatpak/cargo-sources.nix index 258103e90..4ce8be9c2 100644 --- a/nix/flatpak/cargo-sources.nix +++ b/nix/flatpak/cargo-sources.nix @@ -33,7 +33,7 @@ let outputHashMode = "flat"; outputHashAlgo = "sha256"; - outputHash = "sha256-ZLcI8Basvo3MrOan7XnIUqq8UUFQkijzLGw/So3RFi0="; + outputHash = "sha256-mbCiMc2PkUtE+9++SUOC4tGJsxFjeXX+ompwbE7vE6o="; }; regenerateApp = pkgs.writeShellApplication { diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index c6bd0fcd2..04c9ef38c 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -1962,6 +1962,19 @@ "dest": "cargo/vendor/futures-core-0.3.32", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-executor/futures-executor-0.3.32.crate", + "sha256": "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d", + "dest": "cargo/vendor/futures-executor-0.3.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d\", \"files\": {}}", + "dest": "cargo/vendor/futures-executor-0.3.32", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2378,6 +2391,19 @@ "dest": "cargo/vendor/httparse-1.10.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/httpdate/httpdate-1.0.3.crate", + "sha256": "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9", + "dest": "cargo/vendor/httpdate-1.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9\", \"files\": {}}", + "dest": "cargo/vendor/httpdate-1.0.3", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3691,6 +3717,71 @@ "dest": "cargo/vendor/openssl-probe-0.2.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opentelemetry/opentelemetry-0.33.1.crate", + "sha256": "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee", + "dest": "cargo/vendor/opentelemetry-0.33.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee\", \"files\": {}}", + "dest": "cargo/vendor/opentelemetry-0.33.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opentelemetry-http/opentelemetry-http-0.33.1.crate", + "sha256": "302249bd8ee804789faaa7b5b2cbf79bc61cb864714079c644e3828af5b7e811", + "dest": "cargo/vendor/opentelemetry-http-0.33.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"302249bd8ee804789faaa7b5b2cbf79bc61cb864714079c644e3828af5b7e811\", \"files\": {}}", + "dest": "cargo/vendor/opentelemetry-http-0.33.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opentelemetry-otlp/opentelemetry-otlp-0.33.1.crate", + "sha256": "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a", + "dest": "cargo/vendor/opentelemetry-otlp-0.33.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a\", \"files\": {}}", + "dest": "cargo/vendor/opentelemetry-otlp-0.33.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opentelemetry-proto/opentelemetry-proto-0.33.1.crate", + "sha256": "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae", + "dest": "cargo/vendor/opentelemetry-proto-0.33.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae\", \"files\": {}}", + "dest": "cargo/vendor/opentelemetry-proto-0.33.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opentelemetry_sdk/opentelemetry_sdk-0.33.1.crate", + "sha256": "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061", + "dest": "cargo/vendor/opentelemetry_sdk-0.33.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061\", \"files\": {}}", + "dest": "cargo/vendor/opentelemetry_sdk-0.33.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -5823,6 +5914,19 @@ "dest": "cargo/vendor/tracing-log-0.2.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-opentelemetry/tracing-opentelemetry-0.34.0.crate", + "sha256": "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe", + "dest": "cargo/vendor/tracing-opentelemetry-0.34.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe\", \"files\": {}}", + "dest": "cargo/vendor/tracing-opentelemetry-0.34.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip",