# Langfuse

> Send agent runs, tool calls and LLM generations with their input, output and token usage to Langfuse with LangfuseLayer.

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](https://langfuse.com/), the model calls with their input, output and token usage.

## Installation

```sh
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:

| 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

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

```sh
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:

```sh
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.

## Layer setup

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

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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](https://github.com/bosun-ai/swiftide/tree/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/docs-qa) lists the steps.

## 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](/observability/tracing/#filters-per-layer). Add `--trace` to any of its run commands:

```sh
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.

## Final flush

```rust
// 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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

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

`agent.query` and `agent.query_once` get the rendered query as input. The [stop reason](/agents/stopping-and-structured-results/), 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

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

### 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

Indexing pipeline step spans are TRACE, so a DEBUG filter drops them; transformer and query pipeline spans are INFO and appear. [Tracing](/observability/tracing/#spans) lists them.

## Minimal example

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/langfuse.rs#L28-L41)

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/langfuse.rs#L54-L63)

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

```sh
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.

## Custom configuration

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

- **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.

## Reference

- [`LangfuseLayer`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.LangfuseLayer.html)
- [`Configuration`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.Configuration.html)
- [`LangfuseBatchManager`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.LangfuseBatchManager.html)
- [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html)
- [`AwsBedrock`](https://docs.rs/swiftide/0.34/swiftide/integrations/aws_bedrock_v2/struct.AwsBedrock.html)
- [`LanguageModelWithBackOff`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.LanguageModelWithBackOff.html)
- [`Targets`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/targets/struct.Targets.html) and [`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html)
- [Feature flags](/reference/feature-flags/)

## Next

[Metrics and pipeline statistics](/observability/metrics-and-statistics/)