diff --git a/bt-daemon/README.md b/bt-daemon/README.md index 0041c29..aee0f40 100644 --- a/bt-daemon/README.md +++ b/bt-daemon/README.md @@ -85,6 +85,25 @@ run`, from that invocation's settings. An explicit `--additional-metadata` flag or environment variable on `bt trace run` overrides the persisted route for that invocation only, without mutating the file. +### Root-span tags + +Use repeatable `--tag` options to add filterable Braintrust tags to every root +span produced by a route. The option is available on persistent setup, one-off +runs, and transcript imports. + +```bash +bt trace enable claude --tag ci --tag release-validation +bt trace run codex --tag ci -- "summarize this change" +bt trace import claude session-id --tag historical-import +``` + +For automation, set `BRAINTRUST_TAGS` to a comma-separated list before running +one of those commands: + +```bash +BRAINTRUST_TAGS=ci,release-validation bt trace run codex -- "summarize this change" +``` + Use `bt trace disable ` to remove the installed tracing plugin and its Braintrust settings. `bt trace setup ` remains an alias for `bt trace enable ` for backwards compatibility. diff --git a/bt-daemon/src/delivery_ledger.rs b/bt-daemon/src/delivery_ledger.rs index e3650ed..6fe64d4 100644 --- a/bt-daemon/src/delivery_ledger.rs +++ b/bt-daemon/src/delivery_ledger.rs @@ -238,6 +238,7 @@ mod tests { }), flush_mode: FlushMode::FireAndForget, additional_metadata: None, + tags: Vec::new(), } } diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 4831773..9a7d833 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -220,6 +220,9 @@ pub struct ImportArgs { /// JSON object merged into every imported root span's metadata. #[arg(long, env = "BRAINTRUST_ADDITIONAL_METADATA")] pub additional_metadata: Option, + /// Tag applied to each imported root span. May be repeated or comma-separated. + #[arg(long = "tag", env = "BRAINTRUST_TAGS", value_delimiter = ',')] + pub tags: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -266,6 +269,9 @@ pub struct RunArgs { /// JSON object merged into root-span metadata for this invocation. #[arg(long, env = "BRAINTRUST_ADDITIONAL_METADATA")] pub additional_metadata: Option, + /// Tag applied to each root span for this invocation. May be repeated or comma-separated. + #[arg(long = "tag", env = "BRAINTRUST_TAGS", value_delimiter = ',')] + pub tags: Vec, /// Arguments forwarded verbatim to the coding agent. #[arg(allow_hyphen_values = true)] pub agent_args: Vec, @@ -407,6 +413,26 @@ pub(crate) fn apply_additional_metadata( Ok(()) } +/// Apply invocation-local root-span tags to a route. Tags are normalized once +/// at the CLI boundary so hook shims and translators only receive valid values. +pub(crate) fn apply_tags(route: &mut SessionRoute, tags: &[String]) -> anyhow::Result<()> { + if tags.is_empty() { + return Ok(()); + } + let mut normalized = Vec::with_capacity(tags.len()); + for tag in tags { + let tag = tag.trim(); + if tag.is_empty() { + anyhow::bail!("--tag must not be empty"); + } + if !normalized.iter().any(|existing| existing == tag) { + normalized.push(tag.to_string()); + } + } + route.tags = normalized; + Ok(()) +} + fn initialize_params(env: &Envelope) -> serde_json::Value { serde_json::json!({ "protocol_version": PROTOCOL_VERSION, @@ -1443,6 +1469,19 @@ mod tests { .contains("invalid --additional-metadata JSON")); } + #[test] + fn tags_override_a_route_and_are_normalized() { + let mut route = SessionRoute { + tags: vec!["saved".into()], + ..SessionRoute::default() + }; + apply_tags(&mut route, &[" ci ".into(), "ci".into(), "docs".into()]).unwrap(); + assert_eq!(route.tags, ["ci", "docs"]); + + let error = apply_tags(&mut route, &[" ".into()]).unwrap_err(); + assert!(error.to_string().contains("must not be empty")); + } + #[test] fn import_args_accept_multiple_sessions_or_all() { let explicit = ImportCli::try_parse_from([ @@ -1544,6 +1583,7 @@ mod tests { parent_project: None, attach: true, additional_metadata: None, + tags: Vec::new(), }; assert!(validate_import_selection(&args) .unwrap_err() @@ -1624,6 +1664,7 @@ mod tests { }), flush_mode: wire::FlushMode::FireAndForget, additional_metadata: None, + tags: Vec::new(), } } @@ -1715,6 +1756,7 @@ mod tests { RunArgs { source: RunSource::Codex, additional_metadata: None, + tags: Vec::new(), agent_args: Vec::new(), }, test_run_hook_command(), @@ -1732,6 +1774,7 @@ mod tests { RunArgs { source: RunSource::Codex, additional_metadata: None, + tags: Vec::new(), agent_args: vec![OsString::from("--dangerously-bypass-hook-trust")], }, test_run_hook_command(), diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index 34606f1..82b01d3 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -561,6 +561,20 @@ fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> .filter(|metadata| metadata.is_object()) .cloned(); } + if route.tags.is_empty() { + route.tags = settings + .get("route") + .and_then(|route| route.get("tags")) + .or_else(|| settings.get("tags")) + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); + } settings.insert("trace_to_braintrust".into(), Value::Bool(true)); settings.insert("route".into(), serde_json::to_value(route)?); for key in [ @@ -573,6 +587,7 @@ fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> "project", "destination", "additional_metadata", + "tags", ] { settings.remove(key); } @@ -1203,7 +1218,11 @@ mod tests { 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(); + std::fs::write( + &path, + r#"{"route":{"additional_metadata":{"ci":true},"tags":["saved"]}}"#, + ) + .unwrap(); let route = SessionRoute::default(); enable_tracing_at(&path, route).unwrap(); @@ -1212,9 +1231,11 @@ mod tests { settings["route"]["additional_metadata"], serde_json::json!({"ci": true}) ); + assert_eq!(settings["route"]["tags"], serde_json::json!(["saved"])); let route = SessionRoute { additional_metadata: Some(serde_json::json!({"run_id": "new"})), + tags: vec!["replacement".to_string()], ..SessionRoute::default() }; enable_tracing_at(&path, route).unwrap(); @@ -1223,6 +1244,10 @@ mod tests { settings["route"]["additional_metadata"], serde_json::json!({"run_id": "new"}) ); + assert_eq!( + settings["route"]["tags"], + serde_json::json!(["replacement"]) + ); } #[test] diff --git a/bt-daemon/src/trace_command.rs b/bt-daemon/src/trace_command.rs index 07f1f32..bf242b4 100644 --- a/bt-daemon/src/trace_command.rs +++ b/bt-daemon/src/trace_command.rs @@ -101,6 +101,14 @@ pub struct EnableArgs { /// 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, + /// Tag applied to each root span. May be repeated or comma-separated. + #[arg( + long = "tag", + global = true, + env = "BRAINTRUST_TAGS", + value_delimiter = ',' + )] + pub tags: Vec, } /// Backwards-compatible API name for hosts that mounted the former setup command. @@ -155,6 +163,7 @@ mod tests { TraceCommand::Setup(SetupArgs { agent: SetupAgent::Claude, additional_metadata: Some(ref value), + .. }) if value == r#"{"setup":true}"# )); @@ -218,6 +227,37 @@ mod tests { )); } + #[test] + fn setup_run_and_import_accept_tags() { + let setup = + Cli::try_parse_from(["bt", "setup", "claude", "--tag", "ci", "--tag", "docs"]).unwrap(); + assert!(matches!( + setup.trace.command, + TraceCommand::Setup(SetupArgs { ref tags, .. }) if tags.as_slice() == ["ci", "docs"] + )); + + let run = Cli::try_parse_from(["bt", "run", "codex", "--tag", "ci,docs", "--", "status"]) + .unwrap(); + assert!(matches!( + run.trace.command, + TraceCommand::Run(RunArgs { ref tags, .. }) if tags.as_slice() == ["ci", "docs"] + )); + + let import = Cli::try_parse_from([ + "bt", + "import", + "claude", + "session-id", + "--tag", + "historical", + ]) + .unwrap(); + assert!(matches!( + import.trace.command, + TraceCommand::Import(ImportArgs { ref tags, .. }) if tags.as_slice() == ["historical"] + )); + } + #[test] fn doctor_accepts_every_supported_agent_alias() { for (agent, expected, source, display_name) in [ diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 91a5a3b..1ba17ff 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -8,11 +8,11 @@ use crate::trace_command::{DoctorAgent, DoctorArgs, TraceCommand}; use crate::wire::{AuthSelection, AuthSource, SessionConfig, SessionRoute}; use crate::{ - apply_additional_metadata, braintrust_serve_options, paths, run_disable, run_enable, run_hook, - run_import, run_serve, run_status, run_traced, shutdown_daemon, AuthDiagnostic, AuthLease, - AuthProvider, AuthResolveReason, BraintrustSinkConfig, DoctorCommandOutput, HostInfo, - OutputFormat, Registry, RunHookCommand, ServeOptions, StatusArgs, TraceArgs, - TraceCommandOutput, + apply_additional_metadata, apply_tags, braintrust_serve_options, paths, run_disable, + run_enable, run_hook, run_import, run_serve, run_status, run_traced, shutdown_daemon, + AuthDiagnostic, AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, + DoctorCommandOutput, HostInfo, OutputFormat, Registry, RunHookCommand, ServeOptions, + StatusArgs, TraceArgs, TraceCommandOutput, }; use async_trait::async_trait; use std::ffi::OsString; @@ -178,6 +178,7 @@ async fn session_config( destination: route.destination.clone(), flush_mode: route.flush_mode, additional_metadata: route.additional_metadata.clone(), + tags: route.tags.clone(), }) } @@ -328,6 +329,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul ) .await?; apply_additional_metadata(&mut route, enable_args.additional_metadata.as_deref())?; + apply_tags(&mut route, &enable_args.tags)?; print_output(run_enable(enable_args, route)?, host.output_format) } TraceCommand::Disable(disable_args) => { @@ -382,6 +384,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul }) .await?; apply_additional_metadata(&mut route, import_args.additional_metadata.as_deref())?; + apply_tags(&mut route, &import_args.tags)?; let config = session_config(&host, &route).await?; let summaries = run_import(import_args, serve_options(&host), Some(config)).await?; print_output(TraceCommandOutput::import(summaries), host.output_format) @@ -397,6 +400,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul ) .await?; apply_additional_metadata(&mut route, run_args.additional_metadata.as_deref())?; + apply_tags(&mut route, &run_args.tags)?; let hook_command = child_command(&host.command, "hook"); let status = run_traced(run_args, hook_command, route).await?; if status.success() { @@ -572,6 +576,7 @@ mod tests { TraceCommand::Setup(SetupArgs { agent: SetupAgent::OpenCode, additional_metadata: None, + tags: Vec::new(), }), true, ), @@ -579,6 +584,7 @@ mod tests { TraceCommand::Run(RunArgs { source: RunSource::Codex, additional_metadata: None, + tags: Vec::new(), agent_args: Vec::new(), }), false, @@ -673,6 +679,7 @@ mod tests { parent_project: None, attach: false, additional_metadata: None, + tags: Vec::new(), }; let error = run_trace( TraceArgs { diff --git a/bt-daemon/src/translate/antigravity.rs b/bt-daemon/src/translate/antigravity.rs index 9f8fd10..3261dc2 100644 --- a/bt-daemon/src/translate/antigravity.rs +++ b/bt-daemon/src/translate/antigravity.rs @@ -8,7 +8,8 @@ use super::git::GitMetadataCache; use super::tool::{with_tool_approval, ToolApproval}; use super::{ - local_username, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, + local_username, root_tags, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, + TranslatorFactory, }; use crate::ids; use crate::wire::Envelope; @@ -155,6 +156,7 @@ impl AntigravityTranslator { span_type: SpanType::Task, start_ms: Some(event.ts_ms), metadata: Some(Value::Object(metadata)), + tags: root_tags(ctx), ..Default::default() })); } diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index a7b669e..7e4e446 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -10,7 +10,8 @@ use super::git::GitMetadataCache; use super::recent::RecentSet; use super::tool::{add_tool_approval, nonempty_error_text, ToolApproval}; use super::{ - local_username, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, + local_username, root_tags, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, + TranslatorFactory, }; use crate::ids; use crate::wire::Envelope; @@ -259,6 +260,7 @@ impl ClaudeTranslator { start_ms: Some(event.ts_ms), input: Some(json!(format!("Session: {workspace}"))), metadata: Some(Value::Object(metadata)), + tags: root_tags(ctx), ..Default::default() })); } diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index 7f06b2c..a3e8a7e 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -63,6 +63,7 @@ impl TranslatorFactory for CodexTranslatorFactory { root_cwd: None, project: None, additional_metadata: Map::new(), + tags: Vec::new(), main_path: None, // The main scope is created lazily once we learn its transcript path. scopes: HashMap::new(), @@ -167,6 +168,7 @@ struct CodexTranslator { root_cwd: Option, project: Option, additional_metadata: Map, + tags: Vec, main_path: Option, scopes: HashMap, spawn_turn_by_call_id: RecentMap, @@ -195,6 +197,7 @@ impl AgentTranslator for CodexTranslator { .and_then(Value::as_object) .cloned() .unwrap_or_default(); + self.tags = config.tags.clone(); } // --- hook-specific side effects (before catch-up) --- @@ -627,6 +630,7 @@ impl CodexTranslator { "source": self.session_source, })), metadata: Some(Value::Object(md)), + tags: (!self.tags.is_empty()).then(|| self.tags.clone()), ..Default::default() })); } @@ -652,6 +656,7 @@ impl CodexTranslator { "agent_type": scope.agent_type, "transcript_path": scope.path, })), + tags: (!self.tags.is_empty()).then(|| self.tags.clone()), ..Default::default() })); } diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs index 473e996..a9a5e23 100644 --- a/bt-daemon/src/translate/mod.rs +++ b/bt-daemon/src/translate/mod.rs @@ -104,6 +104,14 @@ pub struct SessionCtx { pub config: Option, } +/// Tags configured for every root span in the current agent session. +pub(crate) fn root_tags(ctx: &SessionCtx) -> Option> { + ctx.config + .as_ref() + .map(|config| config.tags.clone()) + .filter(|tags| !tags.is_empty()) +} + /// A per-session state machine. One instance per session; `&mut self` so it /// can hold open-span maps, transcript offsets, etc. pub trait AgentTranslator: Send { diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index ee4dc2a..91a6957 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -5,7 +5,8 @@ use super::git::GitMetadataCache; use super::recent::RecentSet; use super::tool::{error_text, with_tool_approval, ToolApproval}; use super::{ - local_username, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, + local_username, root_tags, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, + TranslatorFactory, }; use crate::ids; use crate::wire::Envelope; @@ -330,6 +331,7 @@ impl OpenCodeTranslator { span_type: SpanType::Task, start_ms: Some(ts), metadata: Some(Value::Object(metadata)), + tags: root_tags(ctx), ..Default::default() })] } diff --git a/bt-daemon/src/translate/pi.rs b/bt-daemon/src/translate/pi.rs index fb54fd8..6b132ef 100644 --- a/bt-daemon/src/translate/pi.rs +++ b/bt-daemon/src/translate/pi.rs @@ -4,7 +4,8 @@ use super::git::GitMetadataCache; use super::tool::{error_text, with_tool_approval, ToolApproval}; use super::{ - local_username, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, + local_username, root_tags, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, + TranslatorFactory, }; use crate::ids; use crate::wire::Envelope; @@ -545,6 +546,7 @@ impl PiTranslator { span_type: SpanType::Task, start_ms: Some(envelope.ts_ms), metadata: Some(Value::Object(metadata)), + tags: root_tags(ctx), ..Default::default() })] } diff --git a/bt-daemon/src/wire/envelope.rs b/bt-daemon/src/wire/envelope.rs index dd00df3..9acacde 100644 --- a/bt-daemon/src/wire/envelope.rs +++ b/bt-daemon/src/wire/envelope.rs @@ -146,6 +146,9 @@ pub struct SessionRoute { pub flush_mode: FlushMode, #[serde(default, skip_serializing_if = "Option::is_none")] pub additional_metadata: Option, + /// Tags applied to each root span for this session. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, } impl SessionRoute { @@ -155,6 +158,7 @@ impl SessionRoute { destination: self.destination.clone(), flush_mode: self.flush_mode, additional_metadata: self.additional_metadata.clone(), + tags: self.tags.clone(), } } @@ -184,6 +188,8 @@ pub struct SessionConfig { pub flush_mode: FlushMode, #[serde(default, skip_serializing_if = "Option::is_none")] pub additional_metadata: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, } /// Where a session's root span should be logged. @@ -367,6 +373,7 @@ mod tests { }, destination: None, flush_mode: FlushMode::FireAndForget, + tags: Vec::new(), additional_metadata: None, }), } diff --git a/bt-daemon/tests/antigravity_translator.rs b/bt-daemon/tests/antigravity_translator.rs index b6d2eac..91ed465 100644 --- a/bt-daemon/tests/antigravity_translator.rs +++ b/bt-daemon/tests/antigravity_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; @@ -147,7 +147,19 @@ fn hooks_and_full_transcript_build_model_and_tool_spans() { let mut translator = registry.create("antigravity", "antigravity\u{1f}conversation-1"); let ctx = SessionCtx { session_id: "conversation-1".into(), - config: None, + config: Some( + SessionRoute { + tags: vec!["ci".into(), "docs".into()], + ..SessionRoute::default() + } + .with_auth(BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }), + ), }; let mut ops = Vec::new(); ops.extend( @@ -235,6 +247,7 @@ fn hooks_and_full_transcript_build_model_and_tool_spans() { "conversation-1" ); assert!(root.end_ms.is_some()); + assert_eq!(root.tags, Some(vec!["ci".into(), "docs".into()])); let turn = rows .values() diff --git a/bt-daemon/tests/braintrust_sink.rs b/bt-daemon/tests/braintrust_sink.rs index 0e1f9c5..d9aafed 100644 --- a/bt-daemon/tests/braintrust_sink.rs +++ b/bt-daemon/tests/braintrust_sink.rs @@ -26,6 +26,7 @@ fn session_config(base: &str) -> SessionConfig { }), flush_mode: FlushMode::FireAndForget, additional_metadata: None, + tags: Vec::new(), } } diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index c81f64d..704f8cb 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -248,6 +248,7 @@ fn claude_additional_metadata_reaches_roots_without_overriding_session_fields() config: Some( SessionRoute { additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + tags: vec!["ci".into(), "docs".into()], ..SessionRoute::default() } .with_auth(BackendAuth { @@ -286,6 +287,7 @@ fn claude_additional_metadata_reaches_roots_without_overriding_session_fields() .unwrap(); assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); assert_eq!(root.metadata.as_ref().unwrap()["source"], "claude-code"); + assert_eq!(root.tags, Some(vec!["ci".into(), "docs".into()])); } #[test] diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index b0045b3..702da18 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -507,6 +507,7 @@ fn configured_ctx(session_id: &str, additional_metadata: Value) -> SessionCtx { }), flush_mode: FlushMode::FireAndForget, additional_metadata: Some(additional_metadata), + tags: vec!["ci".into(), "docs".into()], }), } } @@ -657,6 +658,7 @@ fn root_preserves_config_input_and_git_metadata() { assert_eq!(root.input.as_ref().unwrap()["model"], json!("gpt-5.5")); assert_eq!(root.input.as_ref().unwrap()["source"], json!("resume")); assert_eq!(root.input.as_ref().unwrap()["cwd"], json!(repo)); + assert_eq!(root.tags, Some(vec!["ci".into(), "docs".into()])); assert!(rows.values().all(|row| { let metadata = row.metadata.as_ref().and_then(Value::as_object).unwrap(); metadata.get("git_origin_url") == Some(&json!("https://example.com/acme/app.git")) diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index e59f7e2..597d4a9 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -284,6 +284,7 @@ fn opencode_additional_metadata_reaches_roots_without_overriding_session_fields( config: Some( SessionRoute { additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + tags: vec!["ci".into(), "docs".into()], ..SessionRoute::default() } .with_auth(BackendAuth { @@ -311,6 +312,7 @@ fn opencode_additional_metadata_reaches_roots_without_overriding_session_fields( assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); assert_eq!(root.metadata.as_ref().unwrap()["source"], "opencode"); assert!(root.metadata.as_ref().unwrap().get("username").is_some()); + assert_eq!(root.tags, Some(vec!["ci".into(), "docs".into()])); } #[test] diff --git a/bt-daemon/tests/pi_translator.rs b/bt-daemon/tests/pi_translator.rs index f2a16ea..64bf0ac 100644 --- a/bt-daemon/tests/pi_translator.rs +++ b/bt-daemon/tests/pi_translator.rs @@ -235,6 +235,7 @@ fn pi_additional_metadata_reaches_roots_without_overriding_session_fields() { config: Some( SessionRoute { additional_metadata: Some(json!({"team": "platform", "source": "custom"})), + tags: vec!["ci".into(), "docs".into()], ..SessionRoute::default() } .with_auth(BackendAuth { @@ -259,6 +260,7 @@ fn pi_additional_metadata_reaches_roots_without_overriding_session_fields() { assert!(root.metadata.as_ref().unwrap().get("username").is_some()); assert!(root.parent_span_ids.is_empty()); assert_eq!(root.root_span_id, root.span_id); + assert_eq!(root.tags, Some(vec!["ci".into(), "docs".into()])); } #[test] diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 333f2c4..f843af8 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -1950,6 +1950,7 @@ esac RunArgs { source: RunSource::Codex, additional_metadata: None, + tags: Vec::new(), agent_args: vec![session_id.into(), mode.into()], }, RunHookCommand { diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index 22450e8..86156e5 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -222,6 +222,7 @@ async fn attached_import_summary_reports_the_effective_parent_root() { }), flush_mode: FlushMode::FireAndForget, additional_metadata: None, + tags: Vec::new(), }; let summaries = import_transcript( diff --git a/src/plugins/claude/content/README.md b/src/plugins/claude/content/README.md index 97f0b41..a20e8c5 100644 --- a/src/plugins/claude/content/README.md +++ b/src/plugins/claude/content/README.md @@ -60,3 +60,22 @@ For one invocation without changing the persistent configuration, use `bt trace run --additional-metadata '{"ci":true,"run_id":"abc-123"}' claude`, or set `BRAINTRUST_ADDITIONAL_METADATA` before that command (`bt trace run` still accepts it; a launched `claude` session's live hooks do not). + +#### Root-span tags + +Use repeatable `--tag` options to apply filterable tags to every root span in a +route. Tags can be persisted with setup, supplied to one invocation, or added +while importing a transcript: + +```bash +bt trace enable claude --tag ci --tag release-validation +bt trace run claude --tag ci -- "review this change" +bt trace import claude session-id --tag historical-import +``` + +For automation, set `BRAINTRUST_TAGS` to a comma-separated list before +invoking setup, a run, or an import: + +```bash +BRAINTRUST_TAGS=ci,release-validation bt trace run claude -- "review this change" +```