Skip to content

Langfuse

Turn on the langfuse feature flag and add one tracing layer, and your agent runs, tool calls and OpenAI-compatible or Bedrock model calls show up in Langfuse, the model calls with their input, output and token usage.

Terminal window
cargo add swiftide@0.34 --features langfuse,openai
cargo add tracing anyhow
cargo add tracing-subscriber --features env-filter
cargo add tokio --features macros,rt-multi-thread

The langfuse feature flag adds swiftide::langfuse. LangfuseLayer::default() reads its settings from the environment:

VariableAlso readDefault
LANGFUSE_PUBLIC_KEYLANGFUSE_INIT_PROJECT_PUBLIC_KEYnone, required
LANGFUSE_SECRET_KEYLANGFUSE_INIT_PROJECT_SECRET_KEYnone, required
LANGFUSE_URLhttp://localhost:3000

Langfuse’s own Docker setup uses the second names.

Pick the keys first; the container creates its project with them:

Terminal window
export LANGFUSE_PUBLIC_KEY=pk-lf-local LANGFUSE_SECRET_KEY=sk-lf-local OPENAI_API_KEY=sk-...

The release-notes-agent README starts Langfuse v2 like this; the last -e line adds a login user:

Terminal window
docker network create langfuse
docker run -d --name langfuse-db --network langfuse \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres postgres:16
docker run -d --name langfuse --network langfuse -p 3000:3000 \
-e DATABASE_URL=postgresql://postgres:postgres@langfuse-db:5432/postgres \
-e NEXTAUTH_SECRET=secret -e SALT=salt -e NEXTAUTH_URL=http://localhost:3000 \
-e LANGFUSE_INIT_ORG_ID=local -e LANGFUSE_INIT_PROJECT_ID=release-notes \
-e LANGFUSE_INIT_PROJECT_PUBLIC_KEY="$LANGFUSE_PUBLIC_KEY" \
-e LANGFUSE_INIT_PROJECT_SECRET_KEY="$LANGFUSE_SECRET_KEY" \
-e LANGFUSE_INIT_USER_EMAIL=me@example.com -e LANGFUSE_INIT_USER_PASSWORD=password \
langfuse/langfuse:2

Open http://localhost:3000, sign in, and pick the release-notes project to see its traces.

The docs-qa showcase adds the layer when you pass --langfuse:

examples/docs-qa/src/telemetry.rs
let terminal = tracing_subscriber::fmt::layer()
.compact()
.with_writer(std::io::stderr)
.with_filter(log_filter());
// Reads LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_URL
let langfuse = send_to_langfuse.then(LangfuseLayer::default);
tracing_subscriber::registry()
.with(terminal)
// Langfuse data is emitted at `debug`, so its layer gets its own, wider filter
.with(
langfuse
.clone()
.map(|layer| layer.with_filter(LevelFilter::DEBUG)),
)
.try_init()?;

Source: examples/docs-qa/src/telemetry.rs#L30-L46

LangfuseLayer::default() panics on a missing or empty key, so docs-qa checks the keys first. The layer also starts a Tokio task that sends a batch every 5 seconds.

Swiftide records all Langfuse data at the debug level, so the Langfuse layer gets its own LevelFilter::DEBUG while the terminal layer stays quieter. Running docs-qa also needs Qdrant and an index; its README lists the steps.

The release-notes-agent showcase adds the layer behind --trace, with a Targets filter that passes only Swiftide’s spans, shown in Filters per layer. Add --trace to any of its run commands:

Terminal window
cargo run -p release-notes-agent -- --version 0.34.0 --trace

Its key check ignores the LANGFUSE_INIT_PROJECT_* names and lets an empty key through to the panic. Its README states that --trace was never run against a live Langfuse.

// Keep a clone: the one inside the registry lives as long as the process.
let langfuse = LangfuseLayer::default();
tracing_subscriber::registry()
.with(langfuse.clone().with_filter(LevelFilter::DEBUG))
.try_init()?;
let result = agent
.query("Which crates changed since the last release?")
.await;
// Batches go out every 5 seconds; send the rest before the process exits.
// A failed flush must not hide why the run failed.
if let Err(error) = langfuse.flush().await {
eprintln!("Could not send traces to Langfuse: {error:#}");
}
result?;

Source: snippets/src/observability/langfuse.rs#L14-L30

A layer installed as the global subscriber is never dropped, so nothing sends the last partial batch. Events still buffered when main returns are lost. Clones of LangfuseLayer share one buffer, so keep a clone and call flush on it.

flush sends once. The layer queues events from spawned Tokio tasks, so spans that closed just before flush may miss it, and events requeued for retry are lost.

Each span that passes the layer’s filter becomes an observation. Its name is the otel.name field when the span sets it at creation, such as tool.git_diff or hook.BeforeTool, and the span name otherwise. Agent steps record otel.name later, so they appear as step, with step-N in the metadata. Fields that start with langfuse. fill Langfuse’s type, model, input, output, usage and status message; the rest go into metadata. Events add their fields to their span’s observation; events outside a span are ignored.

agent.query and agent.query_once get the rendered query as input. The stop reason, as JavaScript Object Notation (JSON), becomes the output of the span current at the stop: agent.query or agent.run when the model has nothing more to say, a step observation when a tool such as stop ends the run.

Each tool call is a tool.<name> observation with the arguments as input. The output is recorded after the AfterTool hooks ran, so a hook that shortens it also shortens what Langfuse stores.

The OpenAI-compatible clients (OpenAI, Groq, Gemini, Ollama, OpenRouter, Dashscope) and AwsBedrock mark prompts, completions, structured prompts and streams as GENERATION observations with model, request, response and token usage. Each needs its own feature flag: openai, groq, gemini, ollama, open-router, dashscope or aws-bedrock. A failed AwsBedrock call records the error as the status message.

OpenAI-compatible embedding calls open no span. They set model, usage, and an empty input and output on the span they run in, such as transformers.embed. These replace any input and output the span had.

Anthropic records no Langfuse fields, so its calls appear as plain spans.

Indexing pipeline step spans are TRACE, so a DEBUG filter drops them; transformer and query pipeline spans are INFO and appear. Tracing lists them.

examples/langfuse.rs
let fmt_layer = tracing_subscriber::fmt::layer()
.compact()
.with_target(false)
.boxed();
let langfuse_layer = swiftide::langfuse::LangfuseLayer::default()
.with_filter(LevelFilter::DEBUG)
.boxed();
let registry = tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(vec![fmt_layer, langfuse_layer]);
registry.init();

Source: examples/langfuse.rs#L28-L41

examples/langfuse.rs
let openai = swiftide::integrations::openai::OpenAI::builder()
.default_prompt_model("gpt-5")
.build()
.unwrap();
let paris = openai
.prompt("What is the capital of France?".into())
.await?;
println!("The capital of France is {paris}");

Source: examples/langfuse.rs#L54-L63

Here one EnvFilter covers both layers, and without RUST_LOG nothing reaches Langfuse:

Terminal window
RUST_LOG=swiftide=debug,langfuse=debug cargo run --example langfuse

RUST_LOG=debug would also send the HTTP client’s spans. The GENERATION observation sits under prompt_openai. The example never calls flush; add it when events do not arrive.

let langfuse = LangfuseLayer::from_config(Configuration {
base_path: "http://langfuse.internal:3000".to_string(),
user_agent: Some("my-app".to_string()),
basic_auth: Some((public_key, Some(secret_key))),
..Default::default()
});

Source: snippets/src/observability/langfuse.rs#L38-L43

LangfuseLayer::from_config takes a Configuration instead of the environment. It starts its own batch sender. Omitted fields come from Configuration::default(), whose base path is http://localhost.

LangfuseLayer::from_batch_manager builds a layer on a LangfuseBatchManager you started with spawn. Layers on one manager share its sender; each has its own trace.

  • One trace per layer. The first span creates a trace named with the current Unix timestamp, and every later span joins it. A long-running server puts all requests in one trace; wrap each request in a span of your own to group its observations; they still share the one trace.
  • Retries. LanguageModelWithBackOff calls the inner client again per attempt, so each attempt is its own GENERATION observation.
  • Rejected events. When Langfuse rejects single events in a batch with 429 or 5xx, they are sent at most three times. Other rejected events are dropped with the WARN event “Dropping Langfuse events that will not be retried”. When the whole request fails (wrong keys, network error, Langfuse down, a batch over 3.5 MB), the whole batch is lost: the sender logs an ERROR, or flush returns the error.
  • Tokio. default, from_config and every new span spawn Tokio tasks, and panic outside a runtime.

Metrics and pipeline statistics