Skip to content

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.

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.

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.

Terminal window
npm install autotel-langfuse
import { 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.

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.

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.

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.

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:

Terminal window
docker compose -f docker/langfuse.yml up -d
pnpm --filter autotel-langfuse test:contract

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

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 userId require config.row_limit and an orderBy on a measure, or the metrics query 400s.
  • Prompt linking resolves by name and version. Naming a version the project does not have leaves promptName null, 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.

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.

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.