Skip to content
Open
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
67 changes: 67 additions & 0 deletions bt-daemon/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions bt-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ chrono = "0.4"
clap = { version = "4", features = ["derive", "env"] }
fs2 = "0.4"
regex = "1"
rquickjs = "0.12.2"
rquickjs-serde = "0.6.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
110 changes: 110 additions & 0 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,116 @@ 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.

### JavaScript span plugins

`--plugin PATH` registers a synchronous ES module that transforms each
sink-neutral span row after translation and immediately before delivery. Repeat
the flag to compose plugins from left to right. `enable` persists its ordered list
for ordinary agent sessions. Managed runs and imports are isolated from that
list and use only the `--plugin` flags passed to their command. Each path is
canonicalized to an absolute path before it is validated or stored.

Each module must default-export a synchronous function. It receives a span and
`{ operation, source, session_id, env }`, and must return a JSON-compatible span
object. Span, root, and parent identities cannot be changed:

```js
// redact.mjs
function redact(value) {
if (typeof value === "string") {
return value.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]");
}
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, redact(child)]),
);
}
return value;
}

export default function redactSpan(span) {
const next = { ...span };
for (const field of ["input", "output", "error"]) {
if (field in next) next[field] = redact(next[field]);
}
return next;
}
```

The context can drive a second transform without changing the first one:

```js
// tag-ci.mjs
export default function tagCi(span, context) {
if (!context.env.CI) return span;

return {
...span,
tags: [...new Set([...(span.tags ?? []), "ci"])],
metadata: {
...(span.metadata ?? {}),
deployment: context.env.DEPLOYMENT_ENV ?? "unknown",
trace_source: context.source,
},
};
}
```

Register both transforms persistently for ordinary Codex sessions. The
redactor runs first and its returned span becomes the tagger's input:

```bash
bt trace enable codex --plugin ./redact.mjs --plugin ./tag-ci.mjs
```

`run` and `import` plugins apply only to that command. They replace, rather than
merge with, plugins saved by `enable`:

```bash
# Only local.mjs runs; redact.mjs and tag-ci.mjs remain global enable behavior.
bt trace run --plugin ./local.mjs codex -- "summarize this change"

# Only sanitize-history.mjs transforms spans produced by this import.
bt trace import codex SESSION_ID --plugin ./sanitize-history.mjs
```

The journal stores raw input events, not transformed spans. After daemon
recovery, replayed events therefore pass through the resumed session's current
route: ordinary sessions use the current globally configured plugins, while a
managed session continues using only that run's isolated plugins.

`context.operation` is `"insert"` or `"merge"`; `context.source` and
`context.session_id` identify the translated event stream; and `context.env`
contains the daemon process environment. Environment variable names are
uppercased on Windows so common lookups such as `context.env.PATH` remain
portable.

The environment map is captured from the daemon process when each worker-local
span processor is constructed. Plugins execute in bounded, thread-local
QuickJS runtimes with no filesystem or network host APIs. Modules must be
self-contained and transforms must be stateless: module globals belong to a
worker thread, not a session. Every configured plugin is mandatory. If any
plugin fails, the daemon discards that span operation instead of delivering
untransformed data, and that worker continues discarding operations that would
use the failed plugin. Modifying the plugin file causes workers to retry it. The
raw event remains journaled, so restarting the daemon after fixing the plugin
replays the withheld data through the current chain.

Plugin failures are deduplicated in the daemon's private local state, including
the raw QuickJS exception and stack. Inspect them with:

```bash
bt trace doctor codex
```

The doctor output reports the plugin path, exception, occurrence count, and
timestamps. Managed-run diagnostics are copied out of their temporary daemon
directory before it is removed.
Plugins are trusted local code: although they have no host APIs, they can copy
environment values into spans that are delivered to Braintrust. Read only the
specific variables needed by the transform; never attach `context.env` itself.

### Additional root metadata

`additional_metadata` is a JSON object merged into each traced session's root
Expand Down
5 changes: 4 additions & 1 deletion bt-daemon/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"additional_metadata": {
"team": "platform",
"environment": "development"
}
},
"span_plugins": [
"/absolute/path/to/redact.mjs"
]
}
}
13 changes: 9 additions & 4 deletions bt-daemon/docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ Used for version handover and by tests.
"project_name": "codex"
},
"flush_mode": "fire_and_forget",
"additional_metadata": { "…": "…" }
"additional_metadata": { "…": "…" },
"span_plugins": ["/absolute/path/redact.mjs"]
}
}
```
Expand Down Expand Up @@ -270,7 +271,9 @@ Field notes:

Live credentials returned by the host provider are **never** written to the
journal, logs, status, or RPC response. Envelopes journal only their non-secret
`route`, allowing restart recovery to resolve a fresh lease.
`route`, allowing restart recovery to resolve a fresh lease. Span plugins read
an environment snapshot captured inside their daemon worker process; it is not
part of the envelope or journal schema.

## Daemon lifecycle

Expand Down Expand Up @@ -337,8 +340,10 @@ profiles, organizations, and destinations while sharing one daemon.
`$HOME/.braintrust/state/bt-daemon` on Unix, and
`%LOCALAPPDATA%\Braintrust\bt-daemon` on Windows. On restart the daemon
rebuilds each route's unfinished correlation state independently, replaying
only the journal entries whose `route` matches that pipeline into a fresh
translator. The resulting rows may be resubmitted to repair delivery
only the journal entries whose delivery route matches that pipeline into a
fresh translator. Span plugin paths are ignored for this comparison so raw
events can be replayed through the current plugin chain. The resulting rows
may be resubmitted to repair delivery
interrupted by a crash, but their deterministic ids target the same backend
rows and must never create duplicate spans, and a route never receives
another route's rows. Replay streams the journal and is bounded to the
Expand Down
31 changes: 31 additions & 0 deletions bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub struct DoctorCommandOutput {
pub route: Option<SessionRoute>,
pub auth: AuthDiagnostic,
pub warnings: Vec<String>,
pub plugin_diagnostics: Vec<crate::PluginDiagnostic>,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -207,6 +208,17 @@ impl TraceCommandOutput {
for warning in &doctor.warnings {
rendered.push_str(&format!("\nWarning: {warning}"));
}
for diagnostic in &doctor.plugin_diagnostics {
rendered.push_str(&format!(
"\nPlugin error: {} ({} occurrence{})\nFirst seen: {}\nLast seen: {}\n{}",
diagnostic.plugin_path.display(),
diagnostic.occurrences,
if diagnostic.occurrences == 1 { "" } else { "s" },
render_timestamp(diagnostic.first_seen_ms),
render_timestamp(diagnostic.last_seen_ms),
diagnostic.exception
));
}
Ok(rendered)
}
Self::Enable(setup) => Ok(format!(
Expand Down Expand Up @@ -245,6 +257,12 @@ impl TraceCommandOutput {
}
}

fn render_timestamp(timestamp_ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(timestamp_ms)
.map(|timestamp| timestamp.to_rfc3339())
.unwrap_or_else(|| timestamp_ms.to_string())
}

fn render_destination(destination: &TraceDestination) -> String {
match destination {
TraceDestination::ProjectLogs {
Expand Down Expand Up @@ -406,12 +424,25 @@ mod tests {
error: None,
},
warnings: Vec::new(),
plugin_diagnostics: vec![crate::PluginDiagnostic {
source: "codex".into(),
plugin_path: PathBuf::from("/tmp/redact.mjs"),
plugin_digest: Some("abc".into()),
exception: "Error: raw secret\n at redact (redact.mjs:1)".into(),
first_seen_ms: 1,
last_seen_ms: 2,
occurrences: 3,
}],
});
let rendered = output.render(OutputFormat::Json).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["command"], "doctor");
assert_eq!(value["auth"]["source"], "saved_profile");
assert!(!rendered.contains("token"));
assert!(!rendered.contains("api_key"));
assert_eq!(
value["plugin_diagnostics"][0]["exception"],
"Error: raw secret\n at redact (redact.mjs:1)"
);
}
}
1 change: 1 addition & 0 deletions bt-daemon/src/delivery_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ mod tests {
}),
flush_mode: FlushMode::FireAndForget,
additional_metadata: None,
span_plugins: Vec::new(),
}
}

Expand Down
Loading
Loading