Langfuse
Langfuse ingests plain OTLP and reads the canonical gen_ai.* semantic
conventions. An Autotel span tree therefore arrives as generations, embeddings,
agents and tools, with model names, token usage, input and output messages, cost
and the right parent/child shape, from nothing but a destination:
import { init } from 'autotel';import { createLangfuseConfig } from 'autotel-backends/langfuse';
init( createLangfuseConfig({ publicKey: process.env.LANGFUSE_PUBLIC_KEY!, secretKey: process.env.LANGFUSE_SECRET_KEY!, service: 'support-agent', }),);No @langfuse/otel, no @opentelemetry/exporter-*, no span processor written by
hand. Langfuse is a destination, not an integration.
Two packages, two jobs
Section titled “Two packages, two jobs”| Package | Answers |
|---|---|
autotel-backends/langfuse |
How to reach Langfuse: endpoint, region, auth header |
autotel-langfuse |
What Langfuse stores once you get there: trace name, tags, prompt links, time to first token, scores |
You can use the first on its own. Reach for the second when you want the fields Langfuse keeps in dedicated columns and reads from its own attributes, because no OpenTelemetry convention covers them.
What already works
Section titled “What already works”These need no mapping, and it is worth knowing so you do not go looking for one:
| Langfuse field | Arrives from |
|---|---|
Observation type (GENERATION, EMBEDDING, AGENT, TOOL) |
gen_ai.operation.name |
| Model, token usage, model parameters | gen_ai.request.* / gen_ai.usage.* |
| Input / output | gen_ai.input.messages / gen_ai.output.messages |
| Cost | gen_ai.usage.cost, emitted by autotel-genai |
| User, session | setUser() / setSession(), which write user.id / session.id |
| Environment | deployment.environment.name |
| Level, status message | Span status |
Anything that emits canonical gen_ai.* through autotel arrives the same way,
including framework spans captured with
autotel-genai/observer — LangChain/LangGraph, the
Vercel AI SDK, and Mastra. Because those spans go through autotel’s own
pipeline, the enrichers below apply to them too; a Mastra agent run picks up its
trace name and prompt link without the application knowing Langfuse exists.
Filling the rest
Section titled “Filling the rest”npm install autotel-langfuseimport { init } from 'autotel';import { createLangfuseConfig } from 'autotel-backends/langfuse';import { langfuseCompatibility } from 'autotel-langfuse';
init({ ...createLangfuseConfig({ publicKey: process.env.LANGFUSE_PUBLIC_KEY!, secretKey: process.env.LANGFUSE_SECRET_KEY!, service: 'support-agent', }), spanEnrichers: [ langfuseCompatibility({ tags: ['production'], release: process.env.GIT_SHA, }), ],});| Langfuse field | Where it comes from |
|---|---|
trace_name |
The root span’s name, or the traceName option |
tags, release, version, public |
Options |
completion_start_time |
gen_ai.response.time_to_first_chunk plus the span’s start time |
prompt_name, prompt_version |
gen_ai.prompt.name / gen_ai.prompt.version |
The processor never overwrites an attribute the application set itself, so a span
that already carries langfuse.trace.name keeps it.
Masking and filtering
Section titled “Masking and filtering”Neither needs a Langfuse-specific answer, so autotel-langfuse does not ship
one. Both are Autotel configuration and apply to every destination:
init({ ...createLangfuseConfig({ publicKey, secretKey, service: 'support-agent' }), spanEnrichers: [langfuseCompatibility()],
// Masks values before anything is exported. attributeRedactor: 'default',
// Send only the AI spans. spanFilter: (span) => Object.keys(span.attributes).some((key) => key.startsWith('gen_ai.')),});attributeRedactor takes a preset ('default', 'strict', 'pci-dss'), a
pattern config, or your own function. spanFilter runs before redaction, so it
can match on values that never leave the process. With several destinations
configured, remember that filtering to gen_ai.* for Langfuse’s benefit also
stops your HTTP and database spans reaching everything else.
Images and audio as media
Section titled “Images and audio as media”An image in a prompt arrives as a data: URI inside gen_ai.input.messages,
which puts megabytes of base64 through the OTLP pipeline on every request.
Langfuse can process it server-side as a fallback, but recommends extracting
and uploading media in the client. Upload it once and leave a reference:
import { langfuseMedia } from 'autotel-langfuse';
const media = langfuseMedia({ baseUrl, publicKey, secretKey });
const messages = await media.replaceDataUris(JSON.stringify(input), { traceId, field: 'input',});span.setAttribute('gen_ai.input.messages', messages);It works on the serialised messages directly, because a data: URI survives
JSON.stringify unchanged, and it uploads each distinct payload once.
This is an async call in your code rather than a span processor because it has
to be: onEnd is synchronous and the span exports straight after it, and
Langfuse assigns the media id, so there is nowhere in a processor to await the
upload the attribute depends on.
Evaluation results as scores
Section titled “Evaluation results as scores”Scores are the one thing OTLP cannot carry. Traces go to /api/public/otel,
scores go to /api/public/scores, and both take the same auth, so the bridge
speaks that wire API rather than depending on a Langfuse SDK:
import { langfuseScores } from 'autotel-langfuse';
init({ ...createLangfuseConfig({ publicKey, secretKey, service: 'support-agent' }), subscribers: [langfuseScores({ baseUrl, publicKey, secretKey })],});Anything that records an evaluation then lands as a score against the run that produced it, because Autotel already stamps every event with its trace:
import { recordEvaluationResult } from 'autotel-genai/events';
recordEvaluationResult(ctx, { name: 'faithfulness', scoreValue: 0.92 });A numeric value becomes a NUMERIC score and a label becomes CATEGORICAL.
Pass scoreObservation: true to attach the score to the span the evaluation ran
in rather than to the whole trace. A score that fails to post calls onError
and never throws into the operation that produced it.
Staying in step
Section titled “Staying in step”Every line of autotel-langfuse is a bet about names: which attributes Langfuse
reads going in, which fields it stores coming out. Unit tests cannot check that
bet, so the package ships a contract test that asks a real Langfuse:
docker compose -f docker/langfuse.yml up -dpnpm --filter autotel-langfuse test:contractIt reads back through public API surfaces only. GET /api/public/v2/metrics
names every mapped field as a queryable dimension and rejects a query naming a
dimension it does not have, so a rename upstream fails the suite by name
rather than quietly emptying a column. It also posts a score, uploads a media
payload and reads both back. The default test run skips it and needs no Docker.
Nightly CI runs it against the self-hosted stack, and on pull requests that touch the mapping. It is not part of the main CI gate: it needs six containers and a couple of minutes to boot them, and a young suite that blocks every pull request teaches people to re-run a red build rather than read it.
Self-hosted and Cloud
Section titled “Self-hosted and Cloud”Everything autotel-langfuse writes behaves the same on both, and the contract
suite is run against both. What differs is what you can read back:
| v4 self-hosted | Langfuse Cloud | |
|---|---|---|
/api/public/traces, /api/public/observations |
404 (events_only mode) |
Served, 15 requests/minute |
GET /api/public/v2/metrics |
The only read surface | Served, 100 requests a day |
/api/public/v3/scores |
Served | Served |
Three more things worth knowing, all found by running against the real thing:
- High-cardinality dimensions such as
userIdrequireconfig.row_limitand anorderByon a measure, or the metrics query 400s. - Prompt linking resolves by name and version. Naming a version the project
does not have leaves
promptNamenull, which looks exactly like a broken mapping and is not one. - Cloud’s limits are low enough that a polling loop exhausts the daily metrics quota in a single run, then reports the 429 as a missing row.
To point the suite at Cloud, set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and
LANGFUSE_SECRET_KEY.
Local stack
Section titled “Local stack”docker compose -f docker/langfuse.yml up -d starts Langfuse v4 with keys
already provisioned, so an example carries working credentials and you never
open the UI to copy one. See Local stacks.
Worked example
Section titled “Worked example”apps/example-langfuse
runs four demos against a local Ollama and fans the same spans to Langfuse, the
console, and autotel-devtools. It installs no
Langfuse package.