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
19 changes: 16 additions & 3 deletions bt-daemon/docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <codex|claude> <session-id>` 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 <codex|claude> <session-id> [project_logs:<project-id> |
experiment:<experiment-id>]` 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
<SpanComponents>` attaches it below an exported span and is mutually exclusive
with an object destination.

- **Journal (WAL).** Every accepted event is appended (auth-redacted) to
`<data_dir>/journal/<session_id>.ndjson` before/at enqueue. `data_dir`
Expand Down
1 change: 1 addition & 0 deletions bt-daemon/src/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 45 additions & 1 deletion bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:<project-id>` or
/// `experiment:<experiment-id>`.
#[arg(value_name = "DESTINATION", conflicts_with = "parent")]
pub destination: Option<wire::TraceDestination>,
/// Attach the imported session below an exported Braintrust span.
#[arg(long, value_name = "SPAN_COMPONENTS", conflicts_with = "destination")]
pub parent: Option<SpanComponents>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
Expand Down Expand Up @@ -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<SessionConfig>,
mut config: Option<SessionConfig>,
) -> 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<SessionConfig>,
destination: Option<wire::TraceDestination>,
) -> 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.
Expand Down Expand Up @@ -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"));
}
}
1 change: 1 addition & 0 deletions bt-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 116 additions & 11 deletions bt-daemon/src/sink/braintrust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,6 +111,7 @@ struct Creds {
token: String,
org_id: String,
org_name: Option<String>,
destination: Option<TraceDestination>,
project: Option<String>,
experiment_id: Option<String>,
parent_span_id: Option<String>,
Expand All @@ -135,32 +136,44 @@ 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<ParentSpanInfo> {
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(),
},
_ => 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()),
))
}
}

Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -324,6 +350,85 @@ fn full_span(
}
}

fn root_destination(
destination: &TraceDestination,
project: &str,
) -> anyhow::Result<ParentSpanInfo> {
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<String> {
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<String>,
compute_object_metadata_args: Option<Map<String, Value>>,
propagated_event: Option<Map<String, Value>>,
}

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,
Expand Down
14 changes: 7 additions & 7 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions bt-daemon/src/translate/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,6 +108,7 @@ struct Scope {
struct CodexTranslator {
session_id: String,
root_span_id: String,
external_parent_span_id: Option<String>,
root_opened: bool,
root_ended: bool,
source: Option<String>,
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading