Langfuse v4: up to 165× faster · Read more

Migrate from Braintrust to Langfuse

This guide walks through migrating LLM observability and evaluation from Braintrust to Langfuse: instrumentation first, then datasets, experiments, prompts, and online scoring.

TL;DR: Braintrust and Langfuse share the same core concepts (projects, logs/traces, datasets, experiments, scorers), so this is a re-pointing exercise rather than a redesign. Instrumentation is a wrapper swap: wrap_openai becomes Langfuse's OpenAI drop-in and @traced becomes @observe. Datasets export through Braintrust's REST API or SDK and import via the Langfuse SDK. Your autoevals scorers run in Langfuse experiments without a rewrite, because Langfuse integrates the autoevals library directly. Historical logs usually stay behind; experiments re-run against the migrated datasets.

Why teams migrate

Teams tend to evaluate a Braintrust-to-Langfuse move for a few recurring reasons:

  • Open source and full self-hosting. Langfuse's core is MIT-licensed open source, and self-hosting runs the entire stack (UI, API, and data) in your own infrastructure. Braintrust's self-hosted option is a hybrid model: you deploy the data plane into your own AWS, GCP, or Azure account (Terraform for AWS, Helm for GCP/Azure) while Braintrust hosts the control plane (web app, auth, and metadata), as described in their architecture docs (as of September 2026; a BYOC variant, where Braintrust operates the data plane in your cloud, exists too). The hybrid model keeps your event data in your environment, but a fully self-contained or air-gapped deployment is only possible with software you can run end-to-end.
  • Billing that follows request counts. Langfuse Cloud bills on counted units (traces, observations, scores) with public pricing starting free at 50k units per month. Braintrust bills on processed data, measured in GB ingested across logs, experiments, and datasets, plus separately metered scores and model credits, with paid plans starting at $249 per month (Braintrust pricing, as of September 2026). Byte-based billing scales with payload size rather than request count, which matters if your traces carry large contexts or attachments, and every autoevals or LLM-judge score adds to the score meter.
  • Ingestion-level sampling. Langfuse SDKs include a built-in sample rate (LANGFUSE_SAMPLE_RATE) that keeps a configurable fraction of traces at the source, a direct dial for controlling volume and cost on high-traffic applications.

Braintrust remains a polished platform with a tight evaluation loop, a capable playground, and a managed hybrid deployment, and if it serves your team well there is no urgency to move. This guide is for teams that have decided to consolidate on Langfuse. For a feature-by-feature comparison, see Langfuse vs. Braintrust.

Concept mapping

Braintrust and Langfuse map almost one-to-one, which keeps the mental migration small:

BraintrustLangfuseNotes
ProjectProjectLangfuse adds organizations above projects
Logs (production traces and spans)Traces / observationsHierarchical span trees in both
ExperimentsExperiments / dataset runsRuns linked to dataset items and scores
DatasetsDatasetsinput / expected / metadata in both
Scorers (autoevals, LLM-as-a-judge, code)LLM-as-a-judge + code evaluatorsautoevals scorers work directly in Langfuse experiments
Human reviewAnnotation queuesStructured human scoring on traces
PlaygroundsPlaygroundIterate on prompts against real traces
PromptsPrompt managementVersions and deployment labels via SDK
Gateway (formerly AI proxy)Not needed; pair with a gateway like LiteLLM if you want oneLangfuse observes asynchronously, outside the request path
BTQLMetrics API and custom dashboardsAggregated metrics via REST API and in-UI dashboards

One architectural difference is worth knowing before you start: Braintrust's optional gateway (which replaced its deprecated AI proxy) sits in the request path between your app and the model providers. Langfuse never proxies your LLM calls. The SDKs batch and send telemetry asynchronously, so a Langfuse outage cannot add latency to or drop your production traffic.

Supported data types

DataMove?Path
Live traces (wrapper or OTel)YesSwap the wrappers, or repoint the OTLP exporter; instrumentation stays
DatasetsYesBraintrust SDK/API export → Langfuse dataset items
Experiment code + autoevals scorersYesExperiment runner SDK + the autoevals converter
PromptsYesGET /v1/prompt export → Langfuse prompt management
Online scorers, human-review rubricsRecreateLangfuse evaluators on production traces; score configs on annotation queues
Historical logsUsually noSelective OTLP reingest for traces you actively reference
Experiment scores, BTQL saved queriesNoRe-run experiments; rebuild queries as dashboards or Metrics API calls

Step 1: Switch instrumentation

Both platforms instrument the same way: wrap your model client, decorate your functions. The migration is a package swap.

# Before (Braintrust)
from braintrust import init_logger, traced, wrap_openai
from openai import OpenAI

logger = init_logger(project="my-app")
client = wrap_openai(OpenAI())

@traced
def answer(question: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o", messages=[{"role": "user", "content": question}]
    )
    return response.choices[0].message.content
# After (Langfuse)
from langfuse import observe
from langfuse.openai import OpenAI  # drop-in replacement, traces every call

client = OpenAI()

@observe()
def answer(question: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o", messages=[{"role": "user", "content": question}]
    )
    return response.choices[0].message.content


# Required in short-lived scripts (batched spans are lost at exit otherwise):
from langfuse import get_client

get_client().flush()

Configuration moves from BRAINTRUST_API_KEY to Langfuse's key pair (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL). There is no init_logger equivalent to call: the project is determined by the API keys. Nesting works the same way as in Braintrust: @observe on outer functions produces the trace hierarchy, and the wrapped OpenAI client attaches generations to the active span.

The Langfuse JS/TS SDK is built on OpenTelemetry, so setup registers a span processor once, then wraps clients and functions like Braintrust does:

// Before (Braintrust)
import { initLogger, wrapOpenAI, wrapTraced } from "braintrust";
import OpenAI from "openai";

const logger = initLogger({ projectName: "my-app" });
const client = wrapOpenAI(new OpenAI());

const answer = wrapTraced(async function answer(question: string) {
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: question }],
  });
  return response.choices[0].message.content;
});
// After (Langfuse)
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { observe } from "@langfuse/tracing";
import { observeOpenAI } from "@langfuse/openai";
import OpenAI from "openai";

const sdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
sdk.start();

const client = observeOpenAI(new OpenAI());

const answer = observe(
  async function answer(question: string) {
    const response = await client.chat.completions.create({
      model: "gpt-4o",
      messages: [{ role: "user", content: question }],
    });
    return response.choices[0].message.content;
  },
  { name: "answer" },
);

// Required in short-lived scripts (batched spans are lost at exit otherwise):
await sdk.shutdown();

Both SDKs cover the same ground beyond OpenAI: Langfuse has native integrations for Anthropic, LangChain, the Vercel AI SDK, LiteLLM, Pydantic AI, and 100+ other frameworks and providers, so wrapper-based instrumentation (wrap_anthropic, wrap_litellm, and friends) has a direct counterpart.

The wrapper swap is reversible, and both stacks can run in parallel during the migration window, even in the same process: Braintrust's wrappers and Langfuse's instrumentation use independent capture paths, so each backend records the request exactly once (verified with @observe stacked on @traced and Langfuse's OTel-based Anthropic instrumentation alongside wrap_anthropic). If you prefer not to stack them, run the window per process instead: keep production on Braintrust and point a canary deployment at Langfuse. If you instrumented via OpenTelemetry, you can dual-export per request with two exporters on one tracer provider (see below).

After the swap, confirm in Langfuse that each request produces one trace: a root span named after your decorated function, and a generation carrying the model name, token usage, and cost.

Users, sessions, and tags

Braintrust has no first-class user or session fields; teams keep them in log metadata (and tags). Langfuse promotes them to first-class trace attributes: user_id powers the user view, session_id groups traces into sessions, and tags filter tables. Re-map them at instrumentation time instead of leaving them in metadata:

from langfuse import observe, propagate_attributes

@observe()
def answer(question: str, user_id: str, thread_id: str) -> str:
    with propagate_attributes(user_id=user_id, session_id=thread_id):
        ...  # wrapped client calls inherit both attributes

The JS/TS equivalent is propagateAttributes({ userId, sessionId }, async () => ...) from @langfuse/tracing.

If you instrumented via OpenTelemetry

If you used Braintrust's OTel integration (braintrust[otel] in Python or @braintrust/otel in JS) or exported spans from an OTel collector, you do not need to touch instrumentation at all. Point the OTLP exporter at Langfuse's endpoint instead:

OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"  # EU region
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4"

AUTH_STRING is your base64-encoded project keys (echo -n "pk-lf-...:sk-lf-..." | base64). Langfuse accepts OTLP over HTTP in protobuf and JSON encodings (no gRPC). Running both exporters in parallel during a validation window is a common way to de-risk the cutover.

While you are configuring the SDK, decide on a sample rate. LANGFUSE_SAMPLE_RATE=0.1 keeps 10% of traces, which is often enough for monitoring high-volume endpoints while cutting ingestion cost proportionally. The default is 1.0 (keep everything).

Step 2: Export and recreate datasets

Braintrust datasets export cleanly through its API or SDK, and the record shapes map directly onto Langfuse dataset items:

Braintrust fieldLangfuse field
inputinput
expectedexpected_output
metadatametadata
tagsmetadata (no dedicated column; keep them as a metadata key)

A common migration path is a short script that iterates each Braintrust dataset and inserts the rows into Langfuse:

from braintrust import init_dataset
from langfuse import get_client

langfuse = get_client()
langfuse.create_dataset(name="golden-set")

# init_dataset fetches an existing dataset; iterating yields all records
for row in init_dataset(project="my-app", name="golden-set"):
    langfuse.create_dataset_item(
        dataset_name="golden-set",
        id=row["id"],  # reuse the Braintrust record ID as a stable retry key
        input=row["input"],
        expected_output=row.get("expected"),
        metadata={**(row.get("metadata") or {}), "tags": row.get("tags")},
    )
import { initDataset } from "braintrust";
import { LangfuseClient } from "@langfuse/client";

const langfuse = new LangfuseClient();
await langfuse.dataset.create("golden-set");

const dataset = initDataset({ project: "my-app", dataset: "golden-set" });
for await (const row of dataset.fetch()) {
  await langfuse.dataset.createItem({
    datasetName: "golden-set",
    id: row.id, // reuse the Braintrust record ID as a stable retry key
    input: row.input,
    expectedOutput: row.expected,
    metadata: { ...(row.metadata ?? {}), tags: row.tags },
  });
}

Reusing the source record ID makes the import idempotent: a rerun after a partial failure upserts the same items instead of duplicating them. When the import finishes, confirm the Langfuse dataset's item count matches the Braintrust dataset before moving on.

If you prefer working against the REST API (for example, from a language without a Braintrust SDK), the export endpoints are GET /v1/dataset to list datasets and /v1/dataset/{dataset_id}/fetch to page through records (GET paginates via max_xact_id/max_root_span_id; the POST variant accepts a cursor in the body), authenticated via Authorization: Bearer <api key> against https://api.braintrust.dev (per Braintrust's OpenAPI spec, as of September 2026). The fetch endpoint also accepts a version parameter if you need a snapshot of a dataset as of a past point in time.

Langfuse datasets support folders, JSON schema validation on input and expectedOutput, and media attachments, so structure that lived in Braintrust conventions can usually become explicit during the import.

Step 3: Re-run experiments and keep your autoevals scorers

Braintrust experiments are defined with Eval(name, data, task, scores) and run via the braintrust eval (now bt eval) CLI. The Langfuse equivalent is the experiment runner SDK: a plain script that loops your task over a dataset and applies evaluators, with concurrency, tracing, and error isolation handled for you.

The scorers you wrote with autoevals carry over as-is. Langfuse ships a converter that makes any autoevals scorer a Langfuse evaluator:

# Before (Braintrust)
from braintrust import Eval
from autoevals.llm import Factuality

Eval(
    "my-app",
    data=load_examples,
    task=answer,
    scores=[Factuality],
)
# After (Langfuse): same scorer, no rewrite
from langfuse import get_client
from langfuse.experiment import create_evaluator_from_autoevals
from autoevals.llm import Factuality

langfuse = get_client()
dataset = langfuse.get_dataset("golden-set")

def task(*, item, **kwargs):
    return answer(item.input)  # your existing application logic

result = dataset.run_experiment(
    name="post-migration-baseline",
    task=task,
    evaluators=[create_evaluator_from_autoevals(Factuality())],
)
print(result.format())

In JS/TS, the experiment runner exports task traces via OpenTelemetry: without a registered span processor and a flush at the end, the run is created but its traces never arrive.

// After (Langfuse): same scorer, no rewrite
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { LangfuseClient, createEvaluatorFromAutoevals } from "@langfuse/client";
import { Factuality } from "autoevals";

const otelSdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
otelSdk.start();

const langfuse = new LangfuseClient();
const dataset = await langfuse.dataset.get("golden-set");

const result = await dataset.runExperiment({
  name: "post-migration-baseline",
  task: async (item) => answer(item.input), // your existing application logic
  evaluators: [createEvaluatorFromAutoevals(Factuality)],
});
console.log(await result.format());
await otelSdk.shutdown(); // flush pending spans

Note the converter argument: the TS bridge takes the scorer itself (Factuality), while the Python bridge takes an instance (Factuality()).

Two differences to note when porting:

  • Task signature. Braintrust tasks receive the raw input; Langfuse tasks receive the full dataset item (with item.input, item.expected_output, item.metadata).
  • Runner. There is no dedicated CLI; experiments are ordinary Python/TS scripts, which makes them straightforward to run in CI/CD as regression gates.

Historical Braintrust experiment results are best kept as an archived reference rather than imported: scores are cheap to regenerate against the migrated datasets, and score comparability across tools is shaky anyway. Re-run your latest baseline experiment in Langfuse right after the dataset import so future runs have an anchor.

Step 4: Recreate prompts, online scorers, and review workflows

Prompts

Export prompt definitions via Braintrust's GET /v1/prompt endpoint and recreate them in Langfuse prompt management, using labels (e.g. production, staging) for deployment targeting. Both platforms use mustache-style {{variable}} syntax, so prompt templates port without conversion:

import os

import requests
from langfuse import Langfuse

langfuse = Langfuse()
headers = {"Authorization": f"Bearer {os.environ['BRAINTRUST_API_KEY']}"}

prompts = requests.get(
    "https://api.braintrust.dev/v1/prompt",
    headers=headers,
    params={"project_name": "my-app"},
).json()["objects"]

for p in prompts:
    prompt = p["prompt_data"]["prompt"]
    if prompt["type"] != "chat":
        continue  # completion prompts port the same way with type="text"
    langfuse.create_prompt(
        name=p["slug"],
        type="chat",
        prompt=[
            {"role": m["role"], "content": m["content"]}
            for m in prompt["messages"]
        ],
        config=p["prompt_data"].get("options", {}),  # model name + parameters
        labels=["production"],
        commit_message="Migrated from Braintrust",
    )

Unlike the dataset import, prompt creation is not idempotent: every create_prompt call adds a new version, so run the import once. Application code then fetches prompts by name and label and compiles variables at runtime; once that is live, link prompts to traces so generation metrics break down by prompt version.

Online scorers and human review

  • Online scorers. Braintrust's online scoring (scorers sampled over production logs) maps to Langfuse LLM-as-a-judge evaluators and code evaluators running continuously on incoming traces, with their own sampling and filter controls.
  • Human review. Rubrics from Braintrust's human review map to score configs on Langfuse annotation queues: numeric, categorical, and boolean scores plus comments, assigned to reviewers as a queue.

Step 5: Decide what to do with historical logs

Most teams cut over fresh: old logs stay queryable in Braintrust for their retention window, and Langfuse becomes the system of record from cutover day. Bulk-importing everything is rarely worth it: stale traces have low value, reingested history is billed as new Langfuse ingestion, and the effort is better spent validating the new pipeline. If you need selected history (for example, traces referenced in open incidents), reingest it over Langfuse's OTLP endpoint.

Reingest history over OTLP

Do not build a backfill on the legacy /api/public/ingestion endpoint: it is deprecated for trace and observation events. The current path is OTLP:

  1. Extract log events with Braintrust's GET /v1/project_logs/{project_id}/fetch endpoint (Bearer auth against https://api.braintrust.dev), which returns spans with IDs, timing, input/output, and metrics.
  2. Bound the window at cutover time. Events after the new instrumentation went live are already in Langfuse; reingesting them duplicates data.
  3. Transform to plain OTLP JSON and POST to {LANGFUSE_BASE_URL}/api/public/otel/v1/traces with Basic auth and the x-langfuse-ingestion-version: 4 header. Do not create the spans through an OTel tracer: tracers assign new timestamps and IDs, while raw JSON preserves the original span start and end times.
  4. Derive IDs deterministically from Braintrust span/trace IDs (for example, a hash) so every source record maps to a stable Langfuse ID. Langfuse does not deduplicate re-ingested IDs, so checkpoint accepted source IDs and resume an interrupted import by skipping them.
  5. Map fields: Braintrust span type → langfuse.observation.type, input/output → observation input/output, token counts from metricslangfuse.observation.usage_details, errors → langfuse.observation.level: ERROR. Keep the source ID in langfuse.trace.metadata.braintrust_span_id.
  6. Reconcile counts per trace (source events vs destination observations) and spot-check timestamps in the Langfuse UI.

Migrate custom ingestion to v4 documents the span format, and the project-to-project migration cookbook contains adaptable pagination, retry, and ID-mapping code.

Validation checklist

  • Traces arrive in Langfuse with the expected hierarchy, timing, and token/cost data
  • User and session attribution works (user_id, session_id set via the attribute mapping where Braintrust used metadata)
  • Sample rate configured deliberately (default keeps 100% of traces)
  • Datasets migrated with item counts matching the source
  • Baseline experiment re-run in Langfuse with autoevals scorers producing scores
  • Prompts resolve by name and label from application code
  • Online evaluators scoring a sample of production traces
  • Team access set up (org/project roles, SSO if applicable)
  • Braintrust exporter removed, or the parallel-running window has an end date

Limitations and gaps

  • Experiment scores do not transfer. Braintrust experiment results stay behind; re-running experiments against the migrated datasets regenerates scores, but historical score timelines are not comparable across tools.
  • Trace IDs differ across backends during a parallel window. Braintrust and Langfuse assign their own IDs; cross-reference by input and start time or put a shared request ID in metadata.
  • BTQL saved queries and playground sessions do not port. Rebuild the queries you rely on as custom dashboards or Metrics API calls.
  • Prompt version history flattens by default. The Step 4 import copies the latest version of each prompt; recreating full version history means iterating Braintrust's prompt versions oldest-first, which is rarely worth it.

FAQ

Can I keep my autoevals scorers?

Yes. autoevals is an MIT-licensed open-source library, and Langfuse's experiment SDKs include converters (create_evaluator_from_autoevals in Python, createEvaluatorFromAutoevals in TypeScript) that run autoevals scorers unchanged inside Langfuse experiments. Custom scorers written as plain functions port directly as Langfuse evaluators.

Do I have to migrate historical logs?

No, and most teams do not. Old logs remain in Braintrust for their retention window while Langfuse records everything from cutover day. Selective backfill is possible via Braintrust's project-logs fetch endpoint and reingestion over Langfuse's OTLP endpoint (see Step 5), but it is usually only worth doing for traces you actively reference.

Does Langfuse have an LLM gateway or proxy like Braintrust?

No, by design. Langfuse SDKs send telemetry asynchronously outside the request path, so observability cannot add latency or become a point of failure for model calls. If you want gateway features such as routing, caching, or failover, Langfuse integrates with dedicated gateways like LiteLLM and receives traces from them.

How do I control ingestion volume and cost in Langfuse?

Set a sample rate in the SDK (LANGFUSE_SAMPLE_RATE=0.1 keeps 10% of traces) to cap volume at the source. Because Langfuse bills on counted units rather than bytes of processed data, cost scales with how many traces you keep, not with payload size.

I use Braintrust's EU (or another regional) endpoint. Anything different?

Only the API host: set BRAINTRUST_API_URL (for example https://api-eu.braintrust.dev) in your environment (the Braintrust SDK picks it up automatically) and use the same host for the REST export calls in Steps 2, 4, and 5. The Langfuse region is chosen independently via LANGFUSE_BASE_URL.

Is Langfuse open source where Braintrust is not?

Langfuse's core platform is MIT-licensed and fully self-hostable, including the UI and storage layer. Braintrust's SDKs and the autoevals library are MIT-licensed open source, but the platform itself is proprietary; its self-hosted option is a hybrid deployment where the data plane runs in your cloud account and Braintrust hosts the control plane (as of September 2026). Check both models against your compliance requirements, particularly if you need air-gapped operation.

Get help with the migration

Start on Langfuse Cloud or self-host. If you want a migration plan, talk to us.


Was this page helpful?