> For the complete documentation index, see [llms.txt](https://docs.zenml.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.zenml.io/kitaru/import-your-traces/import-braintrust-traces.md).

# Braintrust

Import Braintrust project logs into Kitaru: accepted export shapes, how spans become nodes, multi-turn grouping, and what the importer marks as lossy.

If your agent already logs to Braintrust, you do not need to instrument anything to start using Kitaru. Export the logs, run one import, and each trace lands as a [session](/kitaru/core-concepts/agents-and-sessions.md): the same object a live-recorded run produces, ready to evaluate and replay.

**Braintrust stays your system of record.** Kitaru takes a runnable copy of the runs you care about so that last Tuesday's incident becomes a test case and last month's traffic becomes a regression population.

Like every import, this one executes on a [worker](/kitaru/core-concepts/workers.md) in your environment: the server stores the export blob, your worker parses it. See [Import Langfuse traces](/kitaru/import-your-traces/import-langfuse-traces.md) for the generic importer contract; this page is the Braintrust specifics.

## 1. Export your Braintrust logs

The importer is permissive about the container because Braintrust logs reach you in more than one shape. It accepts a UTF-8 file that is any of:

* **JSONL**, one Braintrust event object per line.
* **A JSON array** of event objects.
* **A JSON object with an `events` array**, the shape the Braintrust API returns for a log fetch.
* **A single JSON object**, treated as a one-event export.

Uploads are capped by the server's configurable blob limit. Import in slices as often as you like; [dedup](#re-runs-skip-what-is-already-there) makes overlapping slices safe.

What matters is the fields on each record, not how you got the file. A full project-log export carries span identity, and that is what you want:

```json
{
  "id": "event-llm",
  "project_id": "project-1",
  "span_id": "llm",
  "root_span_id": "root",
  "span_parents": ["root"],
  "span_attributes": {"name": "weather-model", "type": "llm"},
  "input": {"messages": [{"role": "user", "content": "Weather?"}]},
  "output": {"role": "assistant", "content": "Sunny."},
  "metadata": {"session_id": "conversation-1", "model": "gpt-4o"},
  "metrics": {"start": 1785000000.1, "end": 1785000000.4,
              "prompt_tokens": 5, "completion_tokens": 2,
              "estimated_cost": 0.00125},
  "created": "2026-07-24T10:00:00Z"
}
```

Rows that carry `span_id`, `root_span_id`, or `span_attributes` are treated as a **full export**. Rows without them (a flat export copied out of the Braintrust UI, for example) still import, at lower fidelity; see [Lower-fidelity exports](#lower-fidelity-exports).

## 2. Import it

Register the agent the traces belong to, if you have not, and start a worker:

```bash
kitaru agent register support-agent --command "python support.py"
kitaru worker start
```

Then import:

```bash
kitaru session import braintrust-logs.jsonl \
  --importer kitaru/braintrust@latest \
  --agent support-agent@latest \
  --media-type application/x-ndjson \
  --tag imported-baseline --wait
```

`kitaru/braintrust` is one of the built-in importers registered at server startup, so `@latest` always resolves and there is no importer code to write. Use `--media-type application/json` when you upload a JSON array or an `events` object instead of JSONL.

`--tag` labels every session the import creates, so later commands can select them as a group (`kitaru session evaluate --tag imported-baseline ...`). Tagging happens once the import finishes, which is why it requires `--wait`. The receipt reports sessions `created`, `skipped`, and `failed`, with samples of the failures.

List what landed:

```bash
kitaru session list --agent support-agent --origin imported --imported-from braintrust
```

### Importer params

| Param             | Meaning                                                                                                                                                                                                          |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_instance` | Explicit project identity, preferred over the `project_id` parameter and embedded project IDs. Keep it stable across imports from the same project.                                                              |
| `project_id`      | Provider-native alias for `source_instance`, used when `source_instance` is absent or blank. Either parameter takes precedence over embedded project IDs.                                                        |
| `join_on`         | Dotted path or RFC 6901 JSON Pointer selecting the value that groups traces into one session. Defaults to the session id found in metadata. See [Grouping traces into sessions](#grouping-traces-into-sessions). |

Pass them with `--params '{"source_instance": "my-braintrust-project"}'`, or use the dedicated `--join-on` flag, which accepts a JSON Pointer only (it must start with `/`) and cannot be combined with `join_on` inside `--params`.

If the export contains no project ID, supply one of the identity parameters; filenames do not determine identity. Values are trimmed strings, and conflicting embedded project IDs fail the affected trace or session even with an override. See [Import your traces](/kitaru/import-your-traces/import-your-traces.md) for the shared identity rules and guidance for existing imports.

## 3. Or fetch from the Braintrust API

Skip the export and upload, and let the import task fetch spans from Braintrust directly:

```bash
kitaru session import \
  --importer kitaru/braintrust@latest \
  --agent support-agent@latest \
  --since 7d \
  --query '{"project_id": "my-braintrust-project"}' \
  --tag imported-baseline --wait
```

Omitting FILE and setting `--since` selects an API import: the worker calls the Braintrust API instead of parsing an uploaded payload. `--since` and `--until` accept an ISO 8601 timestamp or a relative duration (`7d`, `12h`, `30m`). `--trace-id` (repeatable) fetches exactly those root span ids instead of a time window. The same selection is a query object on the SDK and REST request:

| Query key     | Meaning                                                                                                           |
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `project_id`  | Braintrust project to fetch from. Required.                                                                       |
| `trace_ids`   | Braintrust root span ids to fetch. When present, exactly those traces are fetched and the time window is ignored. |
| `since`       | Timezone-aware ISO 8601 datetime, lower bound of root span start time. Required when `trace_ids` is absent.       |
| `until`       | Timezone-aware ISO 8601 datetime, upper bound of root span start time. Defaults to now.                           |
| `concurrency` | Traces fetched at once. Defaults to 4.                                                                            |

The worker installs the package's `api` extra for an API import, which carries the provider client. A [connection](/kitaru/import-your-traces/provider-connections.md) you name with `--connection`, or the provider's default connection, supplies `BRAINTRUST_API_KEY` and `BRAINTRUST_API_URL` for a self-hosted instance. Without either, the worker's own environment does, and only a worker started with `--selector kitaru/requires-credentials=braintrust` claims the task. Each fetched trace is parsed the same way an uploaded export would be, so the node mapping, grouping, and limitations below apply the same way.

## What a trace becomes

Every Braintrust event in a trace becomes one node, and the `span_parents` links are rebuilt as the node tree, so a tool span nested under a model span stays nested. Node type is mapped conservatively:

| Braintrust record                                                                            | Kitaru node                                                                                |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `span_attributes.type == "tool"`, or `metadata["tool.name"]` present                         | `tool_call`, with `tool_name` from `metadata["tool.name"]` (falling back to the span name) |
| `span_attributes.type == "llm"`, and `metadata["openinference.span.kind"]` is empty or `LLM` | `llm_call`                                                                                 |
| Everything else, including OpenInference `CHAIN` wrappers                                    | `span`                                                                                     |

An `llm` span whose OpenInference kind says it is really a chain stays a plain span rather than being mislabeled as a model call.

Per node, the importer preserves:

* **Inputs and outputs** from `input` / `output`, falling back to OpenInference's `metadata["input.value"]` and `metadata["output.value"]`, JSON-decoded when those hold encoded JSON strings.
* **Model identity**: requested model from `gen_ai.request.model` or `model`, resolved model from `gen_ai.response.model` or `model`, provider from `gen_ai.provider.name` or `provider`.
* **Token usage** from `metrics.prompt_tokens`, `metrics.completion_tokens`, and `metrics.prompt_cached_tokens`. A non-integer value there fails that session and is reported as an import failure; other sessions in the file still import.
* **Cost** from `metrics.estimated_cost`.
* **Timings** from `metrics.start` / `metrics.end`, with `created` as a start fallback. Both ISO 8601 strings and Unix timestamps parse.
* **Status**: a record with a non-empty `error` becomes a failed node carrying that error.
* **Metadata**, allowlisted. Only session, conversation, model, and provider keys come across (`session_id`, `sessionId`, `thread_id`, `conversation_id`, `gen_ai.conversation.id`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.provider.name`, `model`, `provider`, `turn_index`). Everything else in `metadata` is dropped rather than copied wholesale into Kitaru.

The importer also normalizes each node for the UI and for evaluators: it locates the user input text, the visible assistant output text, the system prompt on model calls, and any visible reasoning, recording selectors into the payload rather than copying the text. Reasoning and tool-call parts are excluded from what counts as visible output. When the export's metadata names a known framework (PydanticAI, LangGraph, OpenAI Agents, Google ADK, or the Claude Agent SDK), the session records it, provided the evidence points at exactly one.

### Grouping traces into sessions

Braintrust's unit is a trace; a multi-turn conversation is usually several root traces. Kitaru groups them:

* By default, traces are grouped by the first session-like key present in the root record's metadata: `session_id`, `sessionId`, `thread_id`, `conversation_id`, or `gen_ai.conversation.id`. A trace with none of these becomes its own single-turn session.
* With `join_on`, traces are grouped by the scalar at that path in each trace's root record instead, which is how you group by your own correlation key: `--join-on '/metadata/case~1id'`. A trace missing that value, or holding an object or list there, is reported as a failure rather than silently grouped elsewhere.

Grouped traces become **turns**, ordered by start time. The session's `inputs` is a versioned turn list (`{"schema_version": 1, "turns": [{"source_trace_id", "inputs", "outputs"}, ...]}`), and the session's outputs come from the last turn. Session status follows the last turn's root record: a tool that failed and was retried successfully leaves the session completed.

Session metadata records the provenance you'll want when reading the import back: `braintrust.project_ids`, `braintrust.session_id`, `braintrust.trace_ids`, `source_trace_count`, `source_completeness`, `braintrust.join_on` when you set one, and `normalization_warnings`.

## Re-runs skip what is already there

Every imported session records its source identity: `imported_from` (`braintrust`) and an `external_id` of `<source_instance>:<session>`. That pair is unique per destination agent, so re-importing an overlapping export with the same identity **skips** what is already stored and reports it as `skipped`, not as an error. Skipped sessions are not refreshed with new nodes.

## Limitations

The importer is explicit about fidelity it cannot recover, and writes what it noticed into `normalization_warnings` on the session:

* `"Braintrust UI export omits span identity and hierarchy"` on a flat export.
* `"One or more spans reference a missing parent"` when a `span_parents` entry is not in the file, usually a partial export. Those nodes are kept as roots.
* `"Model output contains tool activity but no explicit tool spans"` when a model output references `tool_calls` that the export never recorded as their own spans. Kitaru does not invent nodes for them.
* `"One or more LLM spans lack recorded input or output"` when a model call came across without its payload.

Two more things worth knowing before you rely on an import:

* Non-allowlisted `metadata` keys and Braintrust's own evaluations do not come across. Evaluate imported sessions with Kitaru [evaluators](/kitaru/core-concepts/evaluators.md) instead; backfilling your history is a single batch call.
* Replay re-runs your agent's real code, which no trace export contains. Register the agent version whose code produced these traces, with its run command, and imported sessions replay exactly like recorded ones.

### Lower-fidelity exports

A flat export (rows with `input`, `output`, `metadata`, and `metrics`, but no `span_id` or `span_parents`) still imports. The importer marks it `source_completeness: "flat"`, gives each row a synthetic identity, and relaxes one rule: without span types to read, a row that carries `metadata.model` or token metrics is treated as a model call. There is no hierarchy to rebuild, so the nodes land flat. Prefer a full project-log export whenever you can get one.

{% hint style="warning" %}
An import stores the parsed trace content, including prompts, tool arguments, and tool results, on your Kitaru server. The server is self-hosted, but check your own access and retention rules before importing exports that contain customer data.
{% endhint %}

## Next

Evaluate your imported history with [Write an evaluator](/kitaru/guides/write-an-evaluator.md), then freeze the sessions that matter into a cohort and put a change to the test with [Build a regression suite from production](/kitaru/guides/regression-suite.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.zenml.io/kitaru/import-your-traces/import-braintrust-traces.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
