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 @@ -24,6 +24,8 @@ async-trait = "0.1"
chrono = "0.4"
clap = { version = "4", features = ["derive", "env"] }
regex = "1"
rquickjs = "0.12.2"
rquickjs-serde = "0.6.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
95 changes: 95 additions & 0 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,101 @@ 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. If a plugin fails, that worker reports and skips
only that plugin on subsequent spans; the remaining plugins continue to run.
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 @@ -199,7 +199,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 @@ -248,7 +249,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 @@ -315,8 +318,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
33 changes: 31 additions & 2 deletions bt-daemon/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,36 @@ impl SessionActor {
};
while let Some(ops) = next {
if !ops.is_empty() {
match sink.emit(&ops).await {
let plugin_paths = ctx
.config
.as_ref()
.map(|config| config.span_plugins.as_slice())
.unwrap_or_default();
let mut processed = Vec::with_capacity(ops.len());
for op in &ops {
match crate::span_processor::process(
plugin_paths,
op,
&self.source,
&self.session_id,
) {
Ok(result) => {
for failure in result.failures {
self.set_error(format!(
"span plugin {} failed; disabled on this worker: {}",
failure.path.display(),
failure.message
));
}
processed.push(result.op);
}
Err(error) => {
self.set_error(format!("span plugin processor failed: {error}"));
processed.push(op.clone());
}
}
}
match sink.emit(&processed).await {
Ok(n) => {
self.counters.spans_emitted.fetch_add(n, Ordering::Relaxed);
}
Expand Down Expand Up @@ -370,7 +399,7 @@ impl SessionActor {
if !entry
.route
.as_ref()
.is_some_and(|candidate| candidate.same_route(&plan.route))
.is_some_and(|candidate| candidate.same_replay_route(&plan.route))
{
continue;
}
Expand Down
Loading
Loading