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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 56 additions & 10 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Marks the hook definition injected by `run`; inherited plugin hooks do
/// not carry this flag and are suppressed for the managed child.
Expand Down Expand Up @@ -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<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
Expand All @@ -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<String>,
/// Arguments forwarded verbatim to the coding agent.
#[arg(allow_hyphen_values = true)]
pub agent_args: Vec<OsString>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -951,6 +995,7 @@ mod tests {
destination: None,
parent: None,
attach: true,
additional_metadata: None,
};
assert!(validate_import_selection(&args)
.unwrap_err()
Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 11 additions & 3 deletions bt-daemon/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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();
Expand Down
35 changes: 34 additions & 1 deletion bt-daemon/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"})
);
}
}
84 changes: 84 additions & 0 deletions bt-daemon/src/trace_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Debug, Clone, Copy, Subcommand)]
Expand All @@ -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}"#
));
}
}
Loading
Loading