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.
Installation
Section titled “Installation”cargo add swiftide@0.34 --features langfuse,openaicargo add tracing anyhowcargo add tracing-subscriber --features env-filtercargo add tokio --features macros,rt-multi-threadThe langfuse feature flag adds swiftide::langfuse. LangfuseLayer::default() reads its settings from the environment:
| Variable | Also read | Default |
|---|---|---|
LANGFUSE_PUBLIC_KEY | LANGFUSE_INIT_PROJECT_PUBLIC_KEY | none, required |
LANGFUSE_SECRET_KEY | LANGFUSE_INIT_PROJECT_SECRET_KEY | none, required |
LANGFUSE_URL | http://localhost:3000 |
Langfuse’s own Docker setup uses the second names.
Local Langfuse
Section titled “Local Langfuse”Pick the keys first; the container creates its project with them:
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:
docker network create langfusedocker run -d --name langfuse-db --network langfuse \ -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres postgres:16docker 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:2Open http://localhost:3000, sign in, and pick the release-notes project to see its traces.
Layer setup
Section titled “Layer setup”The docs-qa showcase adds the layer when you pass --langfuse:
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_URLlet 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.
Target filter
Section titled “Target filter”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:
cargo run -p release-notes-agent -- --version 0.34.0 --traceIts 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.
Final flush
Section titled “Final flush”// 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.
Reported data
Section titled “Reported data”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.
Agents
Section titled “Agents”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.
Tool calls
Section titled “Tool calls”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.
Model calls
Section titled “Model calls”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.
Pipelines
Section titled “Pipelines”Indexing pipeline step spans are TRACE, so a DEBUG filter drops them; transformer and query pipeline spans are INFO and appear. Tracing lists them.
Minimal example
Section titled “Minimal example”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
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:
RUST_LOG=swiftide=debug,langfuse=debug cargo run --example langfuseRUST_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.
Custom configuration
Section titled “Custom configuration”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.
Gotchas
Section titled “Gotchas”- 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.
LanguageModelWithBackOffcalls 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
flushreturns the error. - Tokio.
default,from_configand every new span spawn Tokio tasks, and panic outside a runtime.