diff --git a/bt-daemon/docs/protocol.md b/bt-daemon/docs/protocol.md index a496eec..5247251 100644 --- a/bt-daemon/docs/protocol.md +++ b/bt-daemon/docs/protocol.md @@ -166,6 +166,11 @@ Used for version handover and by tests. "app_url": "https://www.braintrust.dev", "org_name": "acme" }, + "destination": { + "type": "project_logs", + "project_id": "project-uuid", + "project_name": "codex" + }, "project": "codex", "parent_span_id": null, "root_span_id": null, @@ -194,6 +199,11 @@ Field notes: per session and only re-inits the Braintrust sink when it changes. `auth` is filled by `bt`'s `resolve_auth` when embedded, or from env/flags in the standalone binary. `flush_mode` ∈ `fire_and_forget` | `flush_on_turn_end`. + New front-ends set the typed `destination`: `project_logs` accepts a project + id and/or name, `experiment` accepts an experiment id, and `parent_span` + carries the complete exported `SpanComponents` object. The older `project`, + `parent_span_id`, `root_span_id`, and `_bt_experiment_id` fields remain + accepted when `destination` is absent. ### Redaction @@ -228,9 +238,12 @@ replay the live credentials must be re-supplied. Journal recovery and explicit transcript import are separate operations. Recovery consumes the daemon's auth-redacted event WAL to rebuild state and may idempotently re-emit rows under their original deterministic ids. The -`import ` command instead locates the selected -agent's native transcript and creates a trace for that past coding-agent -session by routing synthetic lifecycle events through the regular translator. +`import [project_logs: | +experiment:]` command instead locates the selected agent's +native transcript and creates a trace for that past coding-agent session by +routing synthetic lifecycle events through the regular translator. `--parent +` attaches it below an exported span and is mutually exclusive +with an object destination. - **Journal (WAL).** Every accepted event is appended (auth-redacted) to `/journal/.ndjson` before/at enqueue. `data_dir` diff --git a/bt-daemon/src/journal.rs b/bt-daemon/src/journal.rs index 52c7758..f7b8cc0 100644 --- a/bt-daemon/src/journal.rs +++ b/bt-daemon/src/journal.rs @@ -135,6 +135,7 @@ fn config_from_redacted(c: RedactedConfig) -> SessionConfig { org_name, org_id: None, }, + destination: c.destination, project: c.project, parent_span_id: c.parent_span_id, root_span_id: c.root_span_id, diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 446075d..5dd2ec8 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -31,6 +31,7 @@ pub use translate::{ AgentTranslator, Registry, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, }; +use braintrust_sdk_rust::SpanComponents; use clap::{Args, ValueEnum}; use std::path::PathBuf; use std::sync::Arc; @@ -116,6 +117,13 @@ pub struct ImportArgs { pub source: ImportSource, /// Codex or Claude Code session id shown by the agent's resume command. pub session_id: String, + /// Destination object reference, such as `project_logs:` or + /// `experiment:`. + #[arg(value_name = "DESTINATION", conflicts_with = "parent")] + pub destination: Option, + /// Attach the imported session below an exported Braintrust span. + #[arg(long, value_name = "SPAN_COMPONENTS", conflicts_with = "destination")] + pub parent: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -343,12 +351,33 @@ pub async fn shutdown_daemon(socket: &std::path::Path) -> anyhow::Result<()> { pub async fn run_import( args: ImportArgs, opts: ServeOptions, - config: Option, + mut config: Option, ) -> anyhow::Result<()> { + let destination = args + .parent + .map(|components| wire::TraceDestination::ParentSpan { components }) + .or(args.destination); + apply_import_destination(&mut config, destination)?; let file = transcript_import::resolve_transcript(&args.session_id, args.source)?; import_transcript(&file, args.source, opts, config).await } +fn apply_import_destination( + config: &mut Option, + destination: Option, +) -> anyhow::Result<()> { + if let Some(destination) = destination { + let config = config.as_mut().ok_or_else(|| { + anyhow::anyhow!( + "import destination requires a resolved Braintrust session configuration; \ + use `bt trace import` instead of the standalone `bt-daemon import` command" + ) + })?; + config.destination = Some(destination); + } + Ok(()) +} + /// Import a native transcript from a known path. Front-ends should normally /// expose [`run_import`] so users only need the agent's session id; this lower- /// level entry point is useful for embedding and isolated tests. @@ -500,4 +529,19 @@ mod tests { fn clock_returns_a_positive_epoch_timestamp() { assert!(now_ms() > 0); } + + #[test] + fn import_destination_without_session_config_fails_fast() { + let mut config = None; + let destination = wire::TraceDestination::ProjectLogs { + project_id: Some("project-id".to_string()), + project_name: None, + }; + + let error = apply_import_destination(&mut config, Some(destination)).unwrap_err(); + + assert!(error + .to_string() + .contains("import destination requires a resolved Braintrust session configuration")); + } } diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 8fbd8b0..f30ff5b 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -98,6 +98,7 @@ impl AuthArgs { org_name: self.org_name, org_id: self.org_id, }, + destination: None, project: self.project, parent_span_id: None, root_span_id: None, diff --git a/bt-daemon/src/sink/braintrust.rs b/bt-daemon/src/sink/braintrust.rs index da8cd6c..e240791 100644 --- a/bt-daemon/src/sink/braintrust.rs +++ b/bt-daemon/src/sink/braintrust.rs @@ -11,7 +11,7 @@ use super::{Sink, SinkFactory}; use crate::translate::{SpanOp, SpanRow, SpanType}; -use crate::wire::SessionConfig; +use crate::wire::{SessionConfig, TraceDestination}; use braintrust_sdk_rust::{ BraintrustClient, ParentSpanInfo, SpanHandle, SpanLog, SpanObjectType, SpanOrigin, SpanType as SdkSpanType, DEFAULT_API_URL, DEFAULT_APP_URL, @@ -111,6 +111,7 @@ struct Creds { token: String, org_id: String, org_name: Option, + destination: Option, project: Option, experiment_id: Option, parent_span_id: Option, @@ -135,14 +136,29 @@ struct BraintrustSink { impl BraintrustSink { fn project(&self, creds: &Creds) -> String { + if let Some(TraceDestination::ProjectLogs { + project_name: Some(project_name), + .. + }) = &creds.destination + { + return project_name.clone(); + } creds.project.clone().unwrap_or_else(|| self.source.clone()) } - fn parent_info(&self, row: &SpanRow, creds: &Creds, project: &str) -> ParentSpanInfo { + fn parent_info( + &self, + row: &SpanRow, + creds: &Creds, + project: &str, + ) -> anyhow::Result { if row.parent_span_ids.is_empty() { + if let Some(destination) = &creds.destination { + return root_destination(destination, project); + } // Session root: attach under an external trace if the shim supplied // one, else land it directly in the project's logs. - match (&creds.parent_span_id, &creds.root_span_id) { + Ok(match (&creds.parent_span_id, &creds.root_span_id) { (Some(p), Some(r)) => full_span(creds, project, p.clone(), r.clone()), _ if creds.experiment_id.is_some() => ParentSpanInfo::Experiment { object_id: creds.experiment_id.clone().unwrap(), @@ -150,17 +166,14 @@ impl BraintrustSink { _ => ParentSpanInfo::ProjectName { project_name: project.to_string(), }, - } + }) } else { - full_span( + Ok(full_span( creds, project, row.parent_span_ids[0].clone(), - creds - .root_span_id - .clone() - .unwrap_or_else(|| row.root_span_id.clone()), - ) + destination_root(creds).unwrap_or_else(|| row.root_span_id.clone()), + )) } } @@ -186,7 +199,7 @@ impl BraintrustSink { .as_ref() .ok_or_else(|| anyhow::anyhow!("session has no credentials/config yet"))?; let project = self.project(creds); - let parent = self.parent_info(row, creds, &project); + let parent = self.parent_info(row, creds, &project)?; let mut builder = client .span_builder_with_credentials(creds.token.clone(), creds.org_id.clone()) @@ -255,6 +268,7 @@ impl Sink for BraintrustSink { token: config.auth.token.clone(), org_id: config.auth.org_id.clone().unwrap_or_default(), org_name: config.auth.org_name.clone(), + destination: config.destination.clone(), project: config.project.clone(), experiment_id: config .additional_metadata @@ -297,6 +311,18 @@ fn full_span( span_id: String, root_span_id: String, ) -> ParentSpanInfo { + if let Some(destination) = &creds.destination { + let components = destination_components(destination, project); + return ParentSpanInfo::FullSpan { + object_type: components.object_type, + object_id: components.object_id, + compute_object_metadata_args: components.compute_object_metadata_args, + span_id, + root_span_id, + span_parents: None, + propagated_event: components.propagated_event, + }; + } if let Some(experiment_id) = &creds.experiment_id { return ParentSpanInfo::FullSpan { object_type: SpanObjectType::Experiment, @@ -324,6 +350,85 @@ fn full_span( } } +fn root_destination( + destination: &TraceDestination, + project: &str, +) -> anyhow::Result { + Ok(match destination { + TraceDestination::ProjectLogs { + project_id: Some(object_id), + .. + } => ParentSpanInfo::ProjectLogs { + object_id: object_id.clone(), + }, + TraceDestination::ProjectLogs { + project_name: Some(project_name), + .. + } => ParentSpanInfo::ProjectName { + project_name: project_name.clone(), + }, + TraceDestination::ProjectLogs { .. } => ParentSpanInfo::ProjectName { + project_name: project.to_string(), + }, + TraceDestination::Experiment { experiment_id } => ParentSpanInfo::Experiment { + object_id: experiment_id.clone(), + }, + TraceDestination::ParentSpan { components } => components + .to_parent_span_info_resolving_metadata() + .map_err(|error| anyhow::anyhow!("invalid parent span destination: {error}"))?, + }) +} + +fn destination_root(creds: &Creds) -> Option { + match &creds.destination { + Some(TraceDestination::ParentSpan { components }) => components.root_span_id.clone(), + _ => creds.root_span_id.clone(), + } +} + +struct DestinationComponents { + object_type: SpanObjectType, + object_id: Option, + compute_object_metadata_args: Option>, + propagated_event: Option>, +} + +fn destination_components(destination: &TraceDestination, project: &str) -> DestinationComponents { + match destination { + TraceDestination::ProjectLogs { + project_id, + project_name, + } => { + let mut args = Map::new(); + if let Some(project_id) = project_id { + args.insert("project_id".into(), Value::String(project_id.clone())); + } + args.insert( + "project_name".into(), + Value::String(project_name.as_deref().unwrap_or(project).to_string()), + ); + DestinationComponents { + object_type: SpanObjectType::ProjectLogs, + object_id: project_id.clone(), + compute_object_metadata_args: Some(args), + propagated_event: None, + } + } + TraceDestination::Experiment { experiment_id } => DestinationComponents { + object_type: SpanObjectType::Experiment, + object_id: Some(experiment_id.clone()), + compute_object_metadata_args: None, + propagated_event: None, + }, + TraceDestination::ParentSpan { components } => DestinationComponents { + object_type: components.object_type, + object_id: components.object_id.clone(), + compute_object_metadata_args: components.compute_object_metadata_args.clone(), + propagated_event: components.propagated_event.clone(), + }, + } +} + fn map_span_type(t: SpanType) -> SdkSpanType { match t { SpanType::Task => SdkSpanType::Task, diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index 08d375e..cfd8e8f 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -100,7 +100,12 @@ impl ClaudeTranslator { return; } self.root_open = true; - if let Some(root) = ctx.config.as_ref().and_then(|c| c.root_span_id.as_ref()) { + let (parent_span_id, root_span_id) = ctx + .config + .as_ref() + .map(|config| config.attached_span_ids()) + .unwrap_or_default(); + if let Some(root) = root_span_id { self.root_span_id = root.clone(); } let cwd = string_field(&event.payload, "cwd").unwrap_or_default(); @@ -139,12 +144,7 @@ impl ClaudeTranslator { ops.push(SpanOp::Insert(SpanRow { span_id: self.session_span_id.clone(), root_span_id: self.root_span_id.clone(), - parent_span_ids: ctx - .config - .as_ref() - .and_then(|c| c.parent_span_id.clone()) - .into_iter() - .collect(), + parent_span_ids: parent_span_id.into_iter().collect(), name: format!("Claude Code: {workspace}"), span_type: SpanType::Task, start_ms: Some(event.ts_ms), diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index 6312177..ae5f751 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -40,6 +40,7 @@ impl TranslatorFactory for CodexTranslatorFactory { Box::new(CodexTranslator { session_id: session_id.to_string(), root_span_id: ids::span_id(session_id, "root"), + external_parent_span_id: None, root_opened: false, root_ended: false, source: None, @@ -107,6 +108,7 @@ struct Scope { struct CodexTranslator { session_id: String, root_span_id: String, + external_parent_span_id: Option, root_opened: bool, root_ended: bool, source: Option, @@ -128,6 +130,7 @@ impl AgentTranslator for CodexTranslator { let mut ops = Vec::new(); if let Some(config) = &ctx.config { + self.external_parent_span_id = config.attached_span_ids().0; self.project = config.project.clone(); self.additional_metadata = config .additional_metadata @@ -415,6 +418,7 @@ impl CodexTranslator { ops.push(SpanOp::Insert(SpanRow { span_id: self.root_span_id.clone(), root_span_id: self.root_span_id.clone(), + parent_span_ids: self.external_parent_span_id.clone().into_iter().collect(), name, span_type: SpanType::Task, start_ms: Some(ts), diff --git a/bt-daemon/src/wire/envelope.rs b/bt-daemon/src/wire/envelope.rs index ae50463..89c0482 100644 --- a/bt-daemon/src/wire/envelope.rs +++ b/bt-daemon/src/wire/envelope.rs @@ -1,6 +1,7 @@ //! The `event.log` envelope and its session config, plus auth redaction for //! the journal. +use braintrust_sdk_rust::SpanComponents; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -31,6 +32,10 @@ pub struct Envelope { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { pub auth: BackendAuth, + /// Typed destination for new front-ends. When present, this takes + /// precedence over the legacy project and span-attachment fields below. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -43,6 +48,60 @@ pub struct SessionConfig { pub additional_metadata: Option, } +/// Where a session's root span should be logged. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TraceDestination { + /// Project logs selected by stable id, display name, or both. + ProjectLogs { + #[serde(default, skip_serializing_if = "Option::is_none")] + project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + project_name: Option, + }, + /// An existing experiment. + Experiment { experiment_id: String }, + /// A child of an exported Braintrust span. + ParentSpan { components: SpanComponents }, +} + +impl SessionConfig { + /// External parent/root ids used to shape translator rows. The sink keeps + /// the full destination components for object routing and propagation. + pub fn attached_span_ids(&self) -> (Option, Option) { + if let Some(TraceDestination::ParentSpan { components }) = &self.destination { + return (components.span_id.clone(), components.root_span_id.clone()); + } + (self.parent_span_id.clone(), self.root_span_id.clone()) + } +} + +impl std::str::FromStr for TraceDestination { + type Err = String; + + fn from_str(value: &str) -> Result { + let (kind, id) = value.split_once(':').ok_or_else(|| { + "destination must be project_logs: or experiment:" + .to_string() + })?; + if id.is_empty() { + return Err("destination id must not be empty".to_string()); + } + match kind { + "project_logs" => Ok(Self::ProjectLogs { + project_id: Some(id.to_string()), + project_name: None, + }), + "experiment" => Ok(Self::Experiment { + experiment_id: id.to_string(), + }), + _ => Err(format!( + "unsupported destination {kind:?}; expected project_logs or experiment" + )), + } + } +} + /// Backend credentials. `token` is an API key or an OAuth access token; the /// daemon does not care which. Never persisted (see [`SessionConfig::redacted`]). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -118,6 +177,7 @@ impl Envelope { payload: self.payload.clone(), config: self.config.as_ref().map(|c| RedactedConfig { auth: c.auth.fingerprint(), + destination: c.destination.clone(), project: c.project.clone(), parent_span_id: c.parent_span_id.clone(), root_span_id: c.root_span_id.clone(), @@ -147,6 +207,8 @@ pub struct RedactedEnvelope { pub struct RedactedConfig { pub auth: AuthFingerprint, #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_span_id: Option, @@ -178,6 +240,7 @@ mod tests { org_name: Some("acme".into()), org_id: None, }, + destination: None, project: Some("codex".into()), parent_span_id: None, root_span_id: None, @@ -228,4 +291,22 @@ mod tests { let cfg: SessionConfig = serde_json::from_value(json).unwrap(); assert_eq!(cfg.flush_mode, FlushMode::FireAndForget); } + + #[test] + fn import_destination_references_are_typed() { + let project: TraceDestination = "project_logs:proj-123".parse().unwrap(); + assert!(matches!( + project, + TraceDestination::ProjectLogs { + project_id: Some(ref id), + project_name: None + } if id == "proj-123" + )); + let experiment: TraceDestination = "experiment:exp-456".parse().unwrap(); + assert!(matches!( + experiment, + TraceDestination::Experiment { ref experiment_id } if experiment_id == "exp-456" + )); + assert!("project:ambiguous".parse::().is_err()); + } } diff --git a/bt-daemon/src/wire/mod.rs b/bt-daemon/src/wire/mod.rs index ff3e7c4..7e93121 100644 --- a/bt-daemon/src/wire/mod.rs +++ b/bt-daemon/src/wire/mod.rs @@ -11,7 +11,7 @@ mod rpc; pub use envelope::{ AuthFingerprint, BackendAuth, Envelope, FlushMode, RedactedConfig, RedactedEnvelope, - SessionConfig, + SessionConfig, TraceDestination, }; pub use methods::{ method, Capabilities, ClientInfo, EventLogResult, FlushParams, FlushResult, InitializeParams, diff --git a/bt-daemon/tests/braintrust_sink.rs b/bt-daemon/tests/braintrust_sink.rs index d36dbf8..3423b0a 100644 --- a/bt-daemon/tests/braintrust_sink.rs +++ b/bt-daemon/tests/braintrust_sink.rs @@ -2,7 +2,8 @@ //! wiremock stand-in for the Braintrust backend (the endpoints the SDK hits //! with `skip_login`: GET /version, POST /api/project/register, POST /logs3). -use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use braintrust_sdk_rust::{SpanComponents, SpanObjectType}; +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig, TraceDestination}; use bt_daemon::{ BraintrustSinkConfig, BraintrustSinkFactory, SinkFactory, SpanOp, SpanRow, SpanType, }; @@ -19,6 +20,7 @@ fn session_config(base: &str) -> SessionConfig { org_name: Some("acme".into()), org_id: None, }, + destination: None, project: Some("my-project".into()), parent_span_id: None, root_span_id: None, @@ -208,6 +210,52 @@ async fn attached_trace_children_keep_the_external_root() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exported_parent_preserves_object_root_and_propagated_event() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-parent", "codex").unwrap(); + let mut config = session_config(&base); + let mut components = SpanComponents::new(SpanObjectType::Experiment); + components.object_id = Some("exp-parent".into()); + components.span_id = Some("external-parent".into()); + components.root_span_id = Some("external-root".into()); + components.propagated_event = Some(serde_json::Map::from_iter([( + "tenant".into(), + json!("acme"), + )])); + config.destination = Some(TraceDestination::ParentSpan { components }); + sink.configure(&config); + sink.emit(&[SpanOp::Insert(row( + "session-root", + "daemon-internal-root", + &["external-parent"], + "codex", + SpanType::Task, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + for expected in [ + "exp-parent", + "external-root", + "external-parent", + "tenant", + "acme", + ] { + assert!(bodies.contains(expected), "{expected} absent: {bodies}"); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn braintrust_sink_delivers_spans_to_collector() { let server = MockServer::start().await; @@ -318,7 +366,10 @@ async fn experiment_sessions_use_experiment_object_type_and_id() { }); let mut sink = factory.create("sess-exp", "claude-code").unwrap(); let mut config = session_config(&base); - config.additional_metadata = Some(json!({"_bt_experiment_id":"exp-42"})); + config.destination = Some(TraceDestination::Experiment { + experiment_id: "exp-42".into(), + }); + config.additional_metadata = Some(json!({"_bt_experiment_id":"legacy-exp"})); sink.configure(&config); sink.emit(&[ SpanOp::Insert(row( @@ -346,6 +397,10 @@ async fn experiment_sessions_use_experiment_object_type_and_id() { let bodies = logs3_bodies(&server).await; assert!(bodies.contains("exp-42"), "experiment id absent: {bodies}"); + assert!( + !bodies.contains("legacy-exp"), + "legacy routing overrode typed destination: {bodies}" + ); assert!( !bodies.contains("\"project_id\""), "experiment spans were routed as project logs: {bodies}" diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index 95ebc2d..2b718ec 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -388,6 +388,7 @@ fn configured_ctx(session_id: &str, additional_metadata: Value) -> SessionCtx { org_name: None, org_id: None, }, + destination: None, project: Some("team-project".into()), parent_span_id: None, root_span_id: None, diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 84177aa..f7d1fa4 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -29,6 +29,7 @@ fn config_with_secret() -> SessionConfig { org_name: Some("acme".into()), org_id: None, }, + destination: None, project: Some("codex".into()), parent_span_id: None, root_span_id: None,