From 180292a01a9d1fdccc09558b371d386ae226df1f Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 18 Aug 2026 18:40:48 +0800 Subject: [PATCH] Support additional metadata across trace commands --- bt-daemon/README.md | 17 ++++ bt-daemon/src/lib.rs | 66 ++++++++++++--- bt-daemon/src/settings.rs | 14 +++- bt-daemon/src/setup.rs | 35 +++++++- bt-daemon/src/trace_command.rs | 84 +++++++++++++++++++ bt-daemon/src/trace_runtime.rs | 34 ++++++-- bt-daemon/src/translate/opencode.rs | 18 ++-- bt-daemon/src/translate/pi.rs | 38 ++------- bt-daemon/tests/claude_translator.rs | 50 ++++++++++- bt-daemon/tests/opencode_translator.rs | 39 ++++++++- bt-daemon/tests/pi_translator.rs | 36 +++++++- bt-daemon/tests/pipeline.rs | 1 + scripts/test-hook-forwarders.sh | 28 +++++++ src/plugins/claude/content/README.md | 13 +++ .../plugins/trace-claude-code/README.md | 4 + .../content/plugins/trace-codex/README.md | 12 +++ 16 files changed, 426 insertions(+), 63 deletions(-) diff --git a/bt-daemon/README.md b/bt-daemon/README.md index 2e3c346..5143a0d 100644 --- a/bt-daemon/README.md +++ b/bt-daemon/README.md @@ -59,6 +59,23 @@ the default `bt` profile. Credentials and backend URLs are never stored here; production resolves and refreshes them through `bt`. `bt trace run` supplies a process-local settings overlay and never changes any of these files. +### Additional root metadata + +`additional_metadata` is a JSON object merged into each traced session's root +span. Standard agent metadata (such as the session id, source, and workspace) +takes precedence over keys supplied by users. Set it persistently during setup +or provide it for one process with `BRAINTRUST_ADDITIONAL_METADATA`: + +```bash +bt trace setup claude --additional-metadata '{"team":"platform"}' +BRAINTRUST_ADDITIONAL_METADATA='{"ci":true,"run_id":"abc-123"}' \ + bt trace run codex -- "summarize this change" +``` + +The same option/environment value applies to `bt trace hook`, `bt trace import`, +and `bt trace import --attach`. An explicit `--additional-metadata` flag wins +over the environment, which wins over metadata saved in an agent route. + ## Build / test ```bash diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 1ec5532..aaa3ff1 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -109,7 +109,7 @@ pub struct HookArgs { #[arg(long, default_value_t = 10_000)] pub flush_timeout_ms: u64, /// JSON object merged into root-span metadata. - #[arg(long)] + #[arg(long, env = "BRAINTRUST_ADDITIONAL_METADATA")] pub additional_metadata: Option, /// Marks the hook definition injected by `run`; inherited plugin hooks do /// not carry this flag and are suppressed for the managed child. @@ -155,6 +155,9 @@ pub struct ImportArgs { /// coding-agent session grows. #[arg(long, conflicts_with = "all")] pub attach: bool, + /// JSON object merged into every imported root span's metadata. + #[arg(long, env = "BRAINTRUST_ADDITIONAL_METADATA")] + pub additional_metadata: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -171,6 +174,9 @@ pub struct RunArgs { /// Coding agent to launch. #[arg(value_enum)] pub source: RunSource, + /// JSON object merged into root-span metadata for this invocation. + #[arg(long, env = "BRAINTRUST_ADDITIONAL_METADATA")] + pub additional_metadata: Option, /// Arguments forwarded verbatim to the coding agent. #[arg(allow_hyphen_values = true)] pub agent_args: Vec, @@ -238,14 +244,7 @@ pub async fn run_hook( if args.flush_on_turn_end { route.flush_mode = wire::FlushMode::FlushOnTurnEnd; } - if let Some(metadata) = &args.additional_metadata { - let value: serde_json::Value = serde_json::from_str(metadata) - .map_err(|e| anyhow::anyhow!("invalid --additional-metadata JSON: {e}"))?; - if !value.is_object() { - anyhow::bail!("--additional-metadata must be a JSON object"); - } - route.additional_metadata = Some(value); - } + apply_additional_metadata(&mut route, args.additional_metadata.as_deref())?; let env = Envelope { source: args.source.clone(), source_version: args.source_version.clone(), @@ -274,6 +273,27 @@ pub async fn run_hook( Ok(()) } +/// Apply one invocation-local JSON metadata override to a non-secret route. +/// +/// The route is then carried unchanged through live hooks, managed runs, and +/// transcript import. Keeping validation here gives every public command the +/// same contract and prevents individual agent shims from parsing JSON. +pub(crate) fn apply_additional_metadata( + route: &mut SessionRoute, + additional_metadata: Option<&str>, +) -> anyhow::Result<()> { + let Some(metadata) = additional_metadata else { + return Ok(()); + }; + let value: serde_json::Value = serde_json::from_str(metadata) + .map_err(|e| anyhow::anyhow!("invalid --additional-metadata JSON: {e}"))?; + if !value.is_object() { + anyhow::bail!("--additional-metadata must be a JSON object"); + } + route.additional_metadata = Some(value); + Ok(()) +} + /// Ensure a daemon is up and forward one already-built [`Envelope`] to it /// (`initialize` handshake + `event.log`). Also the seam in-process clients and /// tests use to send events without going through stdin. @@ -492,7 +512,11 @@ pub async fn run_traced( .args(args.agent_args) .env("_BT_TRACE_MANAGED_RUN", "1") .env(MANAGED_RUN_ID_ENV, &managed_run_id) - .env(settings::INVOCATION_SETTINGS_ENV, invocation_settings); + .env(settings::INVOCATION_SETTINGS_ENV, invocation_settings) + // The parent has already resolved the public environment variable into + // the invocation route. Do not let a child hook re-apply it and defeat + // an explicit `bt trace run --additional-metadata` override. + .env_remove("BRAINTRUST_ADDITIONAL_METADATA"); if args.source == RunSource::OpenCode { command.env( "OPENCODE_CONFIG_CONTENT", @@ -916,6 +940,26 @@ mod tests { args: ImportArgs, } + #[test] + fn additional_metadata_overrides_a_route_only_with_a_json_object() { + let mut route = SessionRoute { + additional_metadata: Some(serde_json::json!({"saved": true})), + ..SessionRoute::default() + }; + apply_additional_metadata(&mut route, Some(r#"{"run_id":"123"}"#)).unwrap(); + assert_eq!( + route.additional_metadata, + Some(serde_json::json!({"run_id": "123"})) + ); + + let error = apply_additional_metadata(&mut route, Some("[]")).unwrap_err(); + assert!(error.to_string().contains("must be a JSON object")); + let error = apply_additional_metadata(&mut route, Some("not-json")).unwrap_err(); + assert!(error + .to_string() + .contains("invalid --additional-metadata JSON")); + } + #[test] fn import_args_accept_multiple_sessions_or_all() { let explicit = ImportCli::try_parse_from([ @@ -951,6 +995,7 @@ mod tests { destination: None, parent: None, attach: true, + additional_metadata: None, }; assert!(validate_import_selection(&args) .unwrap_err() @@ -1008,6 +1053,7 @@ mod tests { let error = run_traced( RunArgs { source: RunSource::Codex, + additional_metadata: None, agent_args: Vec::new(), }, test_run_hook_command(), diff --git a/bt-daemon/src/settings.rs b/bt-daemon/src/settings.rs index 45a25ee..fe15176 100644 --- a/bt-daemon/src/settings.rs +++ b/bt-daemon/src/settings.rs @@ -188,6 +188,7 @@ mod tests { project_id: None, project_name: Some(project.to_string()), }), + additional_metadata: Some(serde_json::json!({"profile": profile})), ..SessionRoute::default() })) .unwrap() @@ -203,10 +204,17 @@ mod tests { assert!(work.tracing_enabled_with(None)); assert!(personal.tracing_enabled_with(None)); - assert_eq!(work.route.unwrap().auth.profile.as_deref(), Some("work")); + let work_route = work.route.unwrap(); + assert_eq!(work_route.auth.profile.as_deref(), Some("work")); assert_eq!( - personal.route.unwrap().auth.profile.as_deref(), - Some("personal") + work_route.additional_metadata, + Some(serde_json::json!({"profile": "work"})) + ); + let personal_route = personal.route.unwrap(); + assert_eq!(personal_route.auth.profile.as_deref(), Some("personal")); + assert_eq!( + personal_route.additional_metadata, + Some(serde_json::json!({"profile": "personal"})) ); assert!(!global.tracing_enabled_with(None)); let global_route = global.route.unwrap(); diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index af7a26b..50940a8 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -258,8 +258,15 @@ fn setup_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> { runner.run("pi", &["install", PI_PLUGIN]) } -fn enable_tracing_at(path: &Path, route: SessionRoute) -> anyhow::Result<()> { +fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> { let mut settings = load_object(path)?; + if route.additional_metadata.is_none() { + route.additional_metadata = settings + .get("route") + .and_then(|route| route.get("additional_metadata")) + .filter(|metadata| metadata.is_object()) + .cloned(); + } settings.insert("trace_to_braintrust".into(), Value::Bool(true)); settings.insert("route".into(), serde_json::to_value(route)?); settings.remove("traceToBraintrust"); @@ -542,4 +549,30 @@ mod tests { assert!(settings.get("traceToBraintrust").is_none()); assert!(settings.get("project").is_none()); } + + #[test] + fn tracing_settings_preserve_metadata_until_setup_explicitly_replaces_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("braintrust.json"); + std::fs::write(&path, r#"{"route":{"additional_metadata":{"ci":true}}}"#).unwrap(); + + let route = SessionRoute::default(); + enable_tracing_at(&path, route).unwrap(); + let settings: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!( + settings["route"]["additional_metadata"], + serde_json::json!({"ci": true}) + ); + + let route = SessionRoute { + additional_metadata: Some(serde_json::json!({"run_id": "new"})), + ..SessionRoute::default() + }; + enable_tracing_at(&path, route).unwrap(); + let settings: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!( + settings["route"]["additional_metadata"], + serde_json::json!({"run_id": "new"}) + ); + } } diff --git a/bt-daemon/src/trace_command.rs b/bt-daemon/src/trace_command.rs index d41e501..6577b17 100644 --- a/bt-daemon/src/trace_command.rs +++ b/bt-daemon/src/trace_command.rs @@ -50,6 +50,9 @@ pub struct StopArgs { pub struct SetupArgs { #[command(subcommand)] pub agent: SetupAgent, + /// JSON object persisted in this agent's tracing route and merged into root-span metadata. + #[arg(long, global = true, env = "BRAINTRUST_ADDITIONAL_METADATA")] + pub additional_metadata: Option, } #[derive(Debug, Clone, Copy, Subcommand)] @@ -64,3 +67,84 @@ pub enum SetupAgent { /// Install the published Pi tracing extension. Pi, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Debug, Parser)] + struct Cli { + #[command(flatten)] + trace: TraceArgs, + } + + #[test] + fn every_public_trace_ingress_accepts_additional_metadata() { + let setup = Cli::try_parse_from([ + "bt", + "setup", + "claude", + "--additional-metadata", + r#"{"setup":true}"#, + ]) + .unwrap(); + assert!(matches!( + setup.trace.command, + TraceCommand::Setup(SetupArgs { + agent: SetupAgent::Claude, + additional_metadata: Some(ref value), + }) if value == r#"{"setup":true}"# + )); + + let hook = Cli::try_parse_from([ + "bt", + "hook", + "--source", + "claude-code", + "--additional-metadata", + r#"{"hook":true}"#, + ]) + .unwrap(); + assert!(matches!( + hook.trace.command, + TraceCommand::Hook(HookArgs { + additional_metadata: Some(ref value), + .. + }) if value == r#"{"hook":true}"# + )); + + let run = Cli::try_parse_from([ + "bt", + "run", + "--additional-metadata", + r#"{"run":true}"#, + "codex", + ]) + .unwrap(); + assert!(matches!( + run.trace.command, + TraceCommand::Run(RunArgs { + additional_metadata: Some(ref value), + .. + }) if value == r#"{"run":true}"# + )); + + let import = Cli::try_parse_from([ + "bt", + "import", + "codex", + "session-id", + "--additional-metadata", + r#"{"import":true}"#, + ]) + .unwrap(); + assert!(matches!( + import.trace.command, + TraceCommand::Import(ImportArgs { + additional_metadata: Some(ref value), + .. + }) if value == r#"{"import":true}"# + )); + } +} diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index bbd5045..461e01d 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -8,10 +8,10 @@ use crate::trace_command::TraceCommand; use crate::wire::{AuthSelection, SessionConfig, SessionRoute}; use crate::{ - braintrust_serve_options, paths, run_hook, run_import, run_serve, run_setup, run_status, - run_traced, shutdown_daemon, AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, - HostInfo, OutputFormat, Registry, RunHookCommand, ServeOptions, StatusArgs, TraceArgs, - TraceCommandOutput, + apply_additional_metadata, braintrust_serve_options, paths, run_hook, run_import, run_serve, + run_setup, run_status, run_traced, shutdown_daemon, AuthLease, AuthProvider, AuthResolveReason, + BraintrustSinkConfig, HostInfo, OutputFormat, Registry, RunHookCommand, ServeOptions, + StatusArgs, TraceArgs, TraceCommandOutput, }; use async_trait::async_trait; use std::ffi::OsString; @@ -189,7 +189,7 @@ fn print_output(output: TraceCommandOutput, format: OutputFormat) -> anyhow::Res pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Result<()> { match args.command { TraceCommand::Setup(setup_args) => { - let route = resolve_command_route( + let mut route = resolve_command_route( &host, RouteRequirements { destination_required: true, @@ -197,6 +197,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul }, ) .await?; + apply_additional_metadata(&mut route, setup_args.additional_metadata.as_deref())?; print_output(run_setup(setup_args, route)?, host.output_format) } TraceCommand::Daemon(serve_args) => { @@ -235,7 +236,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul print_output(TraceCommandOutput::stop(true, true), host.output_format) } TraceCommand::Import(import_args) => { - let route = host + let mut route = host .services .resolve_route(RouteRequirements { destination_required: import_args.destination.is_none() @@ -243,11 +244,12 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul interactive_auth: true, }) .await?; + apply_additional_metadata(&mut route, import_args.additional_metadata.as_deref())?; let config = session_config(&host, &route).await?; run_import(import_args, serve_options(&host), Some(config)).await } TraceCommand::Run(run_args) => { - let route = resolve_command_route( + let mut route = resolve_command_route( &host, RouteRequirements { destination_required: true, @@ -255,6 +257,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul }, ) .await?; + apply_additional_metadata(&mut route, run_args.additional_metadata.as_deref())?; let hook_command = child_command(&host.command, "hook"); let status = run_traced(run_args, hook_command, route).await?; if status.success() { @@ -409,9 +412,11 @@ mod tests { for command in [ TraceCommand::Setup(SetupArgs { agent: SetupAgent::OpenCode, + additional_metadata: None, }), TraceCommand::Run(RunArgs { source: RunSource::Codex, + additional_metadata: None, agent_args: Vec::new(), }), ] { @@ -437,6 +442,20 @@ mod tests { assert_eq!(route.auth.org_name.as_deref(), Some("test-org")); } + #[tokio::test] + async fn session_config_carries_route_additional_metadata() { + let services = Arc::new(RecordingHost::new(None, None)); + let route = SessionRoute { + additional_metadata: Some(serde_json::json!({"import": true})), + ..SessionRoute::default() + }; + let config = session_config(&test_host(services), &route).await.unwrap(); + assert_eq!( + config.additional_metadata, + Some(serde_json::json!({"import": true})) + ); + } + #[tokio::test] async fn command_routes_reject_an_unresolved_organization() { let services = Arc::new(RecordingHost::without_org()); @@ -466,6 +485,7 @@ mod tests { destination, parent: None, attach: false, + additional_metadata: None, }; let error = run_trace( TraceArgs { diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index 62b2b2e..ad98613 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -5,7 +5,7 @@ use super::git::GitMetadataCache; use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; use crate::ids; use crate::wire::Envelope; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -171,21 +171,19 @@ impl OpenCodeTranslator { "OpenCode".to_string(), ) }; - let mut metadata = Map::new(); + let mut metadata = ctx + .config + .as_ref() + .and_then(|c| c.additional_metadata.as_ref()) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); metadata.insert("session_id".into(), Value::String(native_id.to_string())); metadata.insert("source".into(), Value::String("opencode".into())); if let Some(parent) = parent_id { metadata.insert("parent_session_id".into(), Value::String(parent.into())); metadata.insert("is_subagent".into(), Value::Bool(true)); } - if let Some(extra) = ctx - .config - .as_ref() - .and_then(|c| c.additional_metadata.as_ref()) - .and_then(Value::as_object) - { - metadata.extend(extra.clone()); - } self.sessions.insert( native_id.to_string(), NativeSession { diff --git a/bt-daemon/src/translate/pi.rs b/bt-daemon/src/translate/pi.rs index 5129aa0..4d52948 100644 --- a/bt-daemon/src/translate/pi.rs +++ b/bt-daemon/src/translate/pi.rs @@ -5,7 +5,7 @@ use super::git::GitMetadataCache; use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; use crate::ids; use crate::wire::Envelope; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::Arc; @@ -156,24 +156,18 @@ impl PiTranslator { .as_ref() .map(|c| c.attached_span_ids()) .unwrap_or_default(); - let settings = envelope.payload.get("trace_settings"); - self.external_parent = attached.0.or_else(|| { - settings - .and_then(|value| value.get("parent_span_id")) - .and_then(Value::as_str) - .map(str::to_owned) - }); + self.external_parent = attached.0; self.effective_root_span_id = attached .1 - .or_else(|| { - settings - .and_then(|value| value.get("root_span_id")) - .and_then(Value::as_str) - .map(str::to_owned) - }) .or_else(|| self.external_parent.clone()) .unwrap_or_else(|| self.root_span_id.clone()); - let mut metadata = Map::new(); + let mut metadata = ctx + .config + .as_ref() + .and_then(|c| c.additional_metadata.as_ref()) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); metadata.insert("session_id".into(), json!(self.session_id)); metadata.insert("source".into(), json!("pi")); metadata.insert("pi_version".into(), json!(envelope.source_version)); @@ -201,20 +195,6 @@ impl PiTranslator { .cloned() .unwrap_or(Value::Null), ); - if let Some(extra) = ctx - .config - .as_ref() - .and_then(|c| c.additional_metadata.as_ref()) - .and_then(Value::as_object) - { - metadata.extend(extra.clone()); - } - if let Some(extra) = settings - .and_then(|value| value.get("additional_metadata")) - .and_then(Value::as_object) - { - metadata.extend(extra.clone()); - } vec![SpanOp::Insert(SpanRow { span_id: self.root_span_id.clone(), root_span_id: self.effective_root_span_id.clone(), diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index b8e4710..8d035f6 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -1,4 +1,4 @@ -use bt_daemon::wire::Envelope; +use bt_daemon::wire::{BackendAuth, Envelope, SessionRoute}; use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -232,6 +232,54 @@ fn claude_real_fixture_matches_session_turn_tool_and_token_contract() { })); } +#[test] +fn claude_additional_metadata_reaches_roots_without_overriding_session_fields() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "session"); + let ctx = SessionCtx { + session_id: "session".into(), + config: Some( + SessionRoute { + additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + ..SessionRoute::default() + } + .with_auth(BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }), + ), + }; + let ops = translator + .handle( + &Envelope { + source: "claude-code".into(), + source_version: None, + plugin_version: None, + session_id: "session".into(), + event: "UserPromptSubmit".into(), + ts_ms: 1, + managed_run_id: None, + payload: json!({"session_id":"session","cwd":"/workspace","prompt":"go"}), + route: None, + config: None, + }, + &ctx, + ) + .unwrap(); + let root = ops + .into_iter() + .find_map(|op| match op { + SpanOp::Insert(row) if row.name.starts_with("Claude Code:") => Some(row), + _ => None, + }) + .unwrap(); + assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); + assert_eq!(root.metadata.as_ref().unwrap()["source"], "claude-code"); +} + #[test] fn claude_subagent_fixture_builds_nested_subagent_llms() { let rows = reduce(replay("subagent-compact")); diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index a63ff98..0d4bc69 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1,4 +1,4 @@ -use bt_daemon::wire::Envelope; +use bt_daemon::wire::{BackendAuth, Envelope, SessionRoute}; use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; use serde_json::json; use std::collections::HashMap; @@ -148,3 +148,40 @@ fn opencode_child_sessions_share_the_parent_trace_root() { assert_eq!(child.root_span_id, parent.root_span_id); assert_eq!(child.parent_span_ids.len(), 1); } + +#[test] +fn opencode_additional_metadata_reaches_roots_without_overriding_session_fields() { + let registry = Registry::default_agents(); + let mut translator = registry.create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: Some( + SessionRoute { + additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + ..SessionRoute::default() + } + .with_auth(BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }), + ), + }; + let rows = reduce( + translator + .handle( + &event( + "session.created", + 1, + json!({"properties":{"info":{"id":"native"}}}), + ), + &ctx, + ) + .unwrap(), + ); + let root = rows.values().next().unwrap(); + assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); + assert_eq!(root.metadata.as_ref().unwrap()["source"], "opencode"); +} diff --git a/bt-daemon/tests/pi_translator.rs b/bt-daemon/tests/pi_translator.rs index 0d53bc7..1d089f8 100644 --- a/bt-daemon/tests/pi_translator.rs +++ b/bt-daemon/tests/pi_translator.rs @@ -1,4 +1,4 @@ -use bt_daemon::wire::Envelope; +use bt_daemon::wire::{BackendAuth, Envelope, SessionRoute}; use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; use serde_json::json; use std::collections::HashMap; @@ -125,3 +125,37 @@ fn pi_builds_turn_llm_tool_compaction_and_shutdown_spans() { .filter(|r| r.span_type == SpanType::Task) .all(|r| r.end_ms.is_some())); } + +#[test] +fn pi_additional_metadata_reaches_roots_without_overriding_session_fields() { + let registry = Registry::default_agents(); + let mut translator = registry.create("pi", "pi-session"); + let ctx = SessionCtx { + session_id: "pi-session".into(), + config: Some( + SessionRoute { + additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + ..SessionRoute::default() + } + .with_auth(BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }), + ), + }; + let mut event = event("session_start", 1, json!({"reason":"new"})); + event.payload["trace_settings"] = json!({ + "additional_metadata": {"team": "payload"}, + "parent_span_id": "payload-parent", + "root_span_id": "payload-root", + }); + let rows = reduce(translator.handle(&event, &ctx).unwrap()); + let root = rows.values().next().unwrap(); + assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); + assert_eq!(root.metadata.as_ref().unwrap()["source"], "pi"); + assert!(root.parent_span_ids.is_empty()); + assert_eq!(root.root_span_id, root.span_id); +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index aedc19f..44bbeae 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -1309,6 +1309,7 @@ esac run_traced( RunArgs { source: RunSource::Codex, + additional_metadata: None, agent_args: vec![session_id.into(), mode.into()], }, RunHookCommand { diff --git a/scripts/test-hook-forwarders.sh b/scripts/test-hook-forwarders.sh index 11791b6..9537df2 100644 --- a/scripts/test-hook-forwarders.sh +++ b/scripts/test-hook-forwarders.sh @@ -14,6 +14,7 @@ cat > "$TEST_DIR/bt" <<'EOF' #!/bin/sh printf '%s\n' "$*" > "$BT_CAPTURE_ARGS" cat > "$BT_CAPTURE_STDIN" +[ -z "${BT_CAPTURE_METADATA:-}" ] || printf '%s' "${BRAINTRUST_ADDITIONAL_METADATA:-}" > "$BT_CAPTURE_METADATA" exit "${BT_STUB_STATUS:-0}" EOF chmod +x "$TEST_DIR/bt" @@ -50,6 +51,33 @@ exercise claude claude-code \ exercise codex codex \ bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" +# Additional metadata is consumed by the shared `bt trace hook` command from +# the environment, so every thin hook forwarder receives identical behavior. +exercise_metadata() { + local name="$1" + shift + local args_file="$TEST_DIR/$name.metadata.args" + local stdin_file="$TEST_DIR/$name.metadata.stdin" + local metadata_file="$TEST_DIR/$name.metadata.env" + + printf '%s' "$PAYLOAD" | env \ + PATH="$TEST_DIR:$PATH" \ + BT_CAPTURE_ARGS="$args_file" \ + BT_CAPTURE_STDIN="$stdin_file" \ + BT_CAPTURE_METADATA="$metadata_file" \ + BRAINTRUST_ADDITIONAL_METADATA='{"ci":true,"run_id":"shim-test"}' \ + "$@" + [[ "$(cat "$stdin_file")" == "$PAYLOAD" ]] + [[ "$(cat "$metadata_file")" == '{"ci":true,"run_id":"shim-test"}' ]] +} + +exercise_metadata claude \ + bash "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" +[[ "$(cat "$TEST_DIR/claude.metadata.args")" == 'trace hook --source claude-code' ]] +exercise_metadata codex \ + bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" +[[ "$(cat "$TEST_DIR/codex.metadata.args")" == 'trace hook --source codex' ]] + # Exercise first-use bootstrap without touching the developer's installation. # The fake curl materializes a fake bt binary and emits a no-op installer body. BOOTSTRAP_DIR="$TEST_DIR/bootstrap" diff --git a/src/plugins/claude/content/README.md b/src/plugins/claude/content/README.md index c4e2c9c..588996c 100644 --- a/src/plugins/claude/content/README.md +++ b/src/plugins/claude/content/README.md @@ -51,3 +51,16 @@ settings under `~/.claude/braintrust.json`. Restart Claude Code after setup. Every registered lifecycle event is forwarded synchronously to `bt trace hook --source claude-code`, preserving per-session ordering. Hook failures never fail a Claude Code turn. + +#### Additional root metadata + +Set `BRAINTRUST_ADDITIONAL_METADATA` to a JSON object to tag the root span of a +Claude Code session. Standard session metadata takes precedence if keys +conflict. + +```bash +BRAINTRUST_ADDITIONAL_METADATA='{"ci":true,"run_id":"abc-123"}' claude +``` + +For a persistent route, pass the same object to `bt trace setup claude +--additional-metadata ''`. diff --git a/src/plugins/claude/content/plugins/trace-claude-code/README.md b/src/plugins/claude/content/plugins/trace-claude-code/README.md index 577eac9..24242a3 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/README.md +++ b/src/plugins/claude/content/plugins/trace-claude-code/README.md @@ -11,3 +11,7 @@ The hook first installs the `bt` CLI with the official installer when it is not already available, then forwards the event. The plugin is credential-free and fail-open; the `bt` CLI and shared daemon own authentication, event journaling, trace construction, and delivery. + +To add fields to each root trace span, set `BRAINTRUST_ADDITIONAL_METADATA` to +a JSON object before launching Claude Code. For a persistent configuration, +pass the same JSON with `bt trace setup claude --additional-metadata`. diff --git a/src/plugins/codex/content/plugins/trace-codex/README.md b/src/plugins/codex/content/plugins/trace-codex/README.md index fd29bb1..8a3ee99 100644 --- a/src/plugins/codex/content/plugins/trace-codex/README.md +++ b/src/plugins/codex/content/plugins/trace-codex/README.md @@ -35,3 +35,15 @@ bt trace status Hook setup or forwarding never fails a Codex turn. If installation fails or the daemon cannot accept an event, the launcher reports a bounded diagnostic and exits successfully. + +## Additional root metadata + +Set `BRAINTRUST_ADDITIONAL_METADATA` to a JSON object to tag the root span of a +Codex session. Standard session metadata takes precedence if keys conflict. + +```bash +BRAINTRUST_ADDITIONAL_METADATA='{"ci":true,"run_id":"abc-123"}' codex +``` + +For a persistent route, pass the same object to `bt trace setup codex +--additional-metadata ''`.