Skip to main content

Documentation Index

Fetch the complete documentation index at: https://braintrust.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

Topics classify your traces by running an LLM over a normalized text representation of each trace. That representation comes from a preprocessor, and the built-in Thread preprocessor (the default) handles most traces out of the box. This guide covers confirming your traces render correctly, enabling Topics, and adapting traces that need a custom preprocessor.

Check your traces

Before enabling Topics, confirm a representative trace renders correctly through the Thread preprocessor:
  1. Go to Logs and open a trace.
  2. Select the Thread view.
  3. Select and choose the Thread preprocessor.
  4. Confirm the conversation renders with the user turns, assistant turns, and tool calls you expect.
If the conversation looks right, your traces are ready for Topics. If the Thread view is empty or missing content, see When traces don’t work.

Enable Topics

Once your traces pass the check above, enabling Topics turns on the built-in facets (Task, Sentiment, Issues) for the current project and starts the daily pipeline:
  1. Go to Topics.
  2. Choose whether to Apply to existing traces or only to new traces.
  3. Click Enable topics.
Topics is enabled per project. Repeat these steps in each project where you want classifications. Each facet extracts a short summary from each trace and stores it in the background. Once at least 100 summaries are collected, the daily pipeline clusters them into topics that classify your logs. See Check automation status to track progress or Common issues if results don’t appear.
Sessions and multi-turn conversations: By default, Topics classifies each trace independently. If your application produces one trace per turn (typical with auto-instrumentation), classifications can fragment across a conversation. For example, an early frustrated turn might be classified as FRUSTRATED and a later resolution turn as POSITIVE, so filtering on FRUSTRATED then surfaces every conversation that started badly, even the ones that resolved well. Group traces into conversations so Topics classifies the full session as a unit.

When traces don’t work

If the check above didn’t produce a conversation, the Thread preprocessor couldn’t extract messages from your trace. The preprocessor walks every non-scorer span, reads input and output, converts recognized message data into role/content pairs, and deduplicates across spans. Each span uses OpenAI-style role/content messages: an array of messages on input, and the assistant message object on output:
{
  "input": [{"role": "user", "content": "Hi"}],
  "output": {"role": "assistant", "content": "Hello"}
}
When extraction fails, the trace typically falls into one of these patterns:
  • Conversation in metadata: Messages stored in metadata instead of input or output.
  • Custom field names without role: Objects using names like user_question and assistant_reply instead of role and content.
  • Flattened strings: A turn collapsed into a single string like "USER: ... \nASSISTANT: ...".
  • Deeply nested wrappers: Messages buried below the recognized wrapper keys (messages, prompt, input, output, choices, result, response), like data.payload.messages.
  • LangGraph state without message arrays: Graph state that doesn’t expose a recognized message array.
You have two options: update your instrumentation so spans match a recognized shape, or write a custom preprocessor that reshapes the data. Updating instrumentation is the better long-term fix because every downstream tool benefits from clean span data. Writing a custom preprocessor is often faster because you don’t ship instrumentation changes or backfill data, and the preprocessor can apply to existing traces.

Update your instrumentation

If you control how spans are logged, adjust input and output so they match a recognized shape. Common fixes for the patterns above:
  • Conversation in metadata: Move messages from metadata to input and output.
  • Custom field names without role: Rename fields to role/content, or wrap them under a recognized key like messages.
  • Flattened strings: Emit a message array instead of a single concatenated string.
  • Deeply nested wrappers: Unwrap so one of the recognized keys sits at the top level.
  • LangGraph state without message arrays: Include the underlying LangChain or OpenAI-style messages on the spans you want Topics to see.
For supported providers and frameworks (OpenAI, Anthropic, Google Gemini, Bedrock, Vercel AI SDK, Pydantic AI, LangChain), switching to Braintrust auto-instrumentation gets you a recognized shape without further work.

Write a custom preprocessor

A custom preprocessor is a function named handler that receives a span’s fields (input, output, metadata, error, span_attributes) and returns a message array Topics can use. Each example below addresses one of the patterns listed above and can be pasted directly into the preprocessor editor.
Trace shape:
{
  "input": null,
  "output": null,
  "metadata": {
    "conversation": [
      {"role": "user", "content": "Hi"},
      {"role": "assistant", "content": "Hello"}
    ]
  }
}
Preprocessor:
function handler({
  metadata,
}: {
  input: any;
  output: any;
  error: any;
  metadata: Record<string, any>;
  span_attributes: { name?: string; type?: string };
}): any {
  return metadata?.conversation ?? [];
}
Trace shape:
{
  "input": {"user_question": "How do I cancel my subscription?"},
  "output": {"assistant_reply": "From Settings > Billing > Cancel."}
}
Preprocessor:
function handler({
  input,
  output,
}: {
  input: any;
  output: any;
  error: any;
  metadata: Record<string, any>;
  span_attributes: { name?: string; type?: string };
}): any {
  const messages = [];
  if (input?.user_question) {
    messages.push({ role: "user", content: input.user_question });
  }
  if (output?.assistant_reply) {
    messages.push({ role: "assistant", content: output.assistant_reply });
  }
  return messages;
}
Trace shape:
{
  "input": "USER: Hi\nASSISTANT: Hello\nUSER: How are you?",
  "output": "I'm doing well, thanks!"
}
Preprocessor:
function handler({
  input,
  output,
}: {
  input: any;
  output: any;
  error: any;
  metadata: Record<string, any>;
  span_attributes: { name?: string; type?: string };
}): any {
  const messages = [];
  if (typeof input === "string") {
    for (const line of input.split("\n")) {
      const match = line.match(/^(USER|ASSISTANT):\s*(.*)$/i);
      if (match) {
        messages.push({ role: match[1].toLowerCase(), content: match[2] });
      }
    }
  }
  if (typeof output === "string") {
    messages.push({ role: "assistant", content: output });
  }
  return messages;
}
Trace shape:
{
  "input": {
    "data": {
      "payload": {
        "messages": [{"role": "user", "content": "Hi"}]
      }
    }
  },
  "output": {
    "data": {
      "payload": {"role": "assistant", "content": "Hello"}
    }
  }
}
Preprocessor:
function handler({
  input,
  output,
}: {
  input: any;
  output: any;
  error: any;
  metadata: Record<string, any>;
  span_attributes: { name?: string; type?: string };
}): any {
  const inputMessages = input?.data?.payload?.messages ?? [];
  const outputMessage = output?.data?.payload;
  return outputMessage ? [...inputMessages, outputMessage] : inputMessages;
}
Trace shape:
{
  "output": {
    "state": {
      "messages": [
        {"type": "human", "content": "Hi"},
        {"type": "ai", "content": "Hello"}
      ]
    }
  }
}
Preprocessor:
function handler({
  output,
}: {
  input: any;
  output: any;
  error: any;
  metadata: Record<string, any>;
  span_attributes: { name?: string; type?: string };
}): any {
  return (output?.state?.messages ?? []).map((m: { type: string; content: string }) => {
    const role = m.type === "human" ? "user" : m.type === "ai" ? "assistant" : "system";
    return { role, content: m.content };
  });
}
To save a preprocessor and apply it across your project:
  1. Go to Logs and open any trace.
  2. Select Thread to switch to the Thread view.
  3. Select to open the preprocessor picker, then choose + Custom preprocessor (advanced) at the bottom of the dropdown.
  4. Paste or write your function, enter a name, and click Save.
  5. Verify the Thread view now renders the conversation correctly using your preprocessor. Iterate on the function until it does.
  6. Go to Settings > Advanced and select your preprocessor under Default preprocessor. Topics will use it for every facet that doesn’t override the preprocessor, including the built-in Task, Sentiment, and Issues facets.
Once the Thread view renders your traces correctly, return to Enable Topics above to start the daily pipeline. If you already enabled Topics with the built-in default, rewind history after switching the default so past traces are reprocessed.

Next steps