Convex Langfuse
Send LLM traces, generations, and scores from Convex to Langfuse over OpenTelemetry, with a reactive local copy of the data.
git clone https://github.com/sholajegede/convex-langfuse.git
cd convex-langfuseThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Send LLM traces, generations, and scores from Convex to Langfuse over OpenTelemetry, with a reactive local copy of the data.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: sholajegede/convex-langfuse. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Send LLM traces, generations, and scores from your Convex app to Langfuse over OpenTelemetry, with reactive local queries.
Explore the demo ↗Upstream overview
From the upstream package documentation.
convex-langfuse bridges Convex actions and Langfuse observability by exporting completed LLM calls as OpenTelemetry spans via Langfuse's OTLP/HTTP endpoint, bypassing the deprecated batch ingestion API. Because Convex actions are stateless and short-lived, the component accepts already-finished input/output/token data rather than managing open spans. Every trace, observation, and score is also written to Convex tables, enabling reactive queries over your LLM history without additional Langfuse API calls.
Use cases
From the upstream package documentation.
- Logging completed LLM generations from Convex actions, including model name, token counts, and prompt/completion text, for cost and quality monitoring in Langfuse
- Grouping a retrieval step and a generation step under a single Langfuse trace by passing the same traceId across multiple logSpan and logGeneration calls
- Attaching user thumbs-up/thumbs-down ratings or automated eval scores to a trace after the fact using recordScore
- Building a reactive in-app trace history or token usage dashboard by querying the mirrored Convex tables with listRecentTraces or getStats without hitting the Langfuse API
- Migrating LLM observability off Langfuse's deprecated batch ingestion endpoint before the November 2026 sunset by switching to the OTLP-native integration
How it works
From the upstream package documentation.
After installing the package and registering the component in convex.config.ts, you initialize a Langfuse client with your Langfuse public and secret keys. The client exposes action-callable methods: logGeneration for completed LLM calls and logSpan for non-LLM pipeline steps. Each call accepts a finished payload including input, output, token counts, and optional trace-scoping fields like userId and sessionId.
Under the hood, each logGeneration or logSpan call constructs a minimal OTLP/HTTP JSON payload using Langfuse's documented span attribute conventions and exports it to Langfuse's OTLP endpoint via fetch, with no OpenTelemetry SDK dependency. The component targets the OTLP endpoint rather than the deprecated /api/public/ingestion batch endpoint, so it remains compatible after the November 2026 cloud sunset.
Alongside the export, each call writes a record into the component's own Convex tables: traces, observations, and scores. These tables are queryable through provided query helpers such as getTrace, listObservations, listTracesByUser, and getStats, which means your frontend can subscribe to LLM activity reactively using standard Convex query patterns. The recordScore method posts a score to Langfuse's Scores REST endpoint and mirrors it locally in the same way.
Upstream package FAQ
Does convex-langfuse work with Convex's stateless action model?
Yes. convex-langfuse is designed specifically for Convex actions, which are short stateless functions. Instead of opening and closing spans around an async process, you call logGeneration() or logSpan() once with the already-finished input, output, and timing. The component constructs and exports a completed OpenTelemetry span in a single call.
Which Langfuse ingestion endpoint does convex-langfuse use?
convex-langfuse sends data to Langfuse's OTLP/HTTP endpoint using Langfuse's documented span attribute conventions. It does not use the older /api/public/ingestion batch endpoint, which is deprecated and scheduled for sunset on Langfuse Cloud on November 16, 2026. The component constructs minimal OTLP/HTTP JSON payloads over fetch with no OpenTelemetry SDK dependency.
Can I query Langfuse trace data reactively in my Convex app?
Yes. convex-langfuse mirrors every trace, observation, and score into Convex tables as they are logged. You can use getTrace(), listObservations(), listScores(), listTracesByUser(), listTracesBySession(), listRecentTraces(), and getStats() as reactive Convex queries, without making any calls to the Langfuse API.
Does convex-langfuse require installing the Langfuse SDK or an OpenTelemetry SDK?
No. convex-langfuse talks directly to Langfuse's OTLP/HTTP and Scores REST endpoints using fetch. It does not depend on the Langfuse SDK or any OpenTelemetry SDK package. This keeps the component dependency-free but means it only supports the attributes documented in its README.
How do I configure convex-langfuse for the Langfuse US, JP, or self-hosted regions?
Set the baseUrl option when initializing the Langfuse client in convex-langfuse. The default baseUrl targets the EU Langfuse Cloud region. For US, JP, HIPAA, or self-hosted deployments, pass the correct base URL for your project. Also confirm that your Convex deployment can reach the Langfuse OTLP endpoint over HTTPS, as firewall rules can silently block exports.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Convex Langfuse with Bijection.
Read: https://bijection.com/components/convex-langfuse/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from sholajegede/convex-langfuse, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
convex-langfuse
Send LLM traces, generations, and scores from your Convex app to Langfuse. Reactive local queries, OpenTelemetry-native ingestion.
const langfuse = new Langfuse(components.convexLangfuse, {
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
});
// Log a completed LLM call
const { traceId } = await langfuse.logGeneration(ctx, {
name: "chat-completion",
userId: "user_123",
model: "gpt-5",
input: prompt,
output: completion,
inputTokens: 120,
outputTokens: 340,
});
// Attach a user rating or eval score to it
await langfuse.recordScore(ctx, { traceId, name: "user-rating", value: 1 });
What this does
Most Langfuse integrations assume a long-lived Node process that can hold an OpenTelemetry span open for the duration of an LLM call. Convex actions are short, stateless, request/response functions — there's no "open a span, await a stream, close it later." This component is built for that shape: you call it once, with the finished input/output/timing/usage already in hand, and it does the rest.
Under the hood it exports a single completed OpenTelemetry span per call, using Langfuse's documented span attribute conventions (langfuse.observation.*, langfuse.trace.*) — no OpenTelemetry SDK dependency, just fetch. This matters more than it sounds: Langfuse's older /api/public/ingestion batch endpoint is deprecated and sunsets on Langfuse Cloud on November 16, 2026. Building against the OTLP endpoint from day one means this component doesn't need a rewrite in a few months.
Alongside the export, every trace, generation, and score is mirrored into Convex tables so you can query them reactively — build a live "recent generations" or "token usage" view without a separate call to Langfuse's API.
- One-shot logging —
logGeneration()/logSpan()take a finished call and export it as a proper Langfuse trace + observation - Scoring —
recordScore()attaches an evaluation or user-rating score to any trace - Reactive local queries — traces, observations, and scores are all queryable live from Convex
- No SDK dependency — talks to Langfuse's OTLP/HTTP and Scores REST endpoints directly over
fetch
Table of Contents
- Install
- Quick Start
- Usage
- Continuing a Trace
- API Reference
- Type Reference
- Database Schema
- Testing
- Example App
- Limitations
- Troubleshooting
- Contributing
- Changelog
Install
npm install convex-langfuse
Requirements: Convex v1.33.1 or later, Node.js 18+, a Langfuse project (Cloud or self-hosted, OTel-compatible)
Quick Start
Three steps to send traces from your Convex app to Langfuse.
1. Add the component
In convex/convex.config.ts:
import { defineApp } from "convex/server";
import convexLangfuse from "convex-langfuse/convex.config";
const app = defineApp();
app.use(convexLangfuse);
export default app;
2. Set environment variables
npx convex env set LANGFUSE_PUBLIC_KEY pk-lf-xxxxxxxxxxxx
npx convex env set LANGFUSE_SECRET_KEY sk-lf-xxxxxxxxxxxx
3. Initialize the client
In convex/observability.ts:
import { components } from "./_generated/api";
import { Langfuse } from "convex-langfuse";
export const langfuse = new Langfuse(components.convexLangfuse, {
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
// baseUrl defaults to the EU Langfuse Cloud region — set it for US/JP/self-hosted
});
Import langfuse from this file in any Convex action that calls an LLM.
Usage
Log a generation
export const chat = action({
args: { userId: v.string(), prompt: v.string() },
handler: async (ctx, args) => {
const completion = await callYourModel(args.prompt);
const { traceId } = await langfuse.logGeneration(ctx, {
name: "chat-completion",
userId: args.userId,
model: "gpt-5",
input: args.prompt,
output: completion,
inputTokens: completion.usage.inputTokens,
outputTokens: completion.usage.outputTokens,
});
return { completion, traceId };
},
});
// Returns: { traceId, observationId }
Log a non-LLM step
await langfuse.logSpan(ctx, {
traceId,
name: "vector-search",
input: { query },
output: { matches },
});
Record a score
export const rate = action({
args: { traceId: v.string(), value: v.number() },
handler: async (ctx, args) => {
await langfuse.recordScore(ctx, {
traceId: args.traceId,
name: "user-rating",
value: args.value, // e.g. 1 for thumbs up, 0 for thumbs down
});
return null;
},
});
Read a trace reactively
export const getTrace = query({
args: { traceId: v.string() },
handler: async (ctx, args) => langfuse.getTrace(ctx, args),
});
export const getObservations = query({
args: { traceId: v.string() },
handler: async (ctx, args) => langfuse.listObservations(ctx, args),
});
Continuing a Trace
Pass the traceId returned from the first call into subsequent logGeneration()/logSpan() calls to group multiple observations (a retrieval step, then a generation, then a tool call) under one trace in the Langfuse UI:
const { traceId } = await langfuse.logSpan(ctx, { name: "retrieve-context", ... });
await langfuse.logGeneration(ctx, { traceId, name: "generate-answer", ... });
API Reference
| Method | Kind | Description |
|---|---|---|
logGeneration(ctx, args) | action | Exports a completed LLM call as a Langfuse generation |
logSpan(ctx, args) | action | Exports a completed non-LLM step as a Langfuse span |
recordScore(ctx, args) | action | Attaches an evaluation/rating score to a trace |
getTrace(ctx, args) | query | Fetch one trace by id |
listTracesByUser(ctx, args) | query | List a user's traces, newest first |
listTracesBySession(ctx, args) | query | List a session's traces, newest first |
listObservations(ctx, args) | query | List a trace's observations, oldest first |
listScores(ctx, args) | query | List a trace's scores |
listRecentTraces(ctx, args?) | query | Every trace, newest first, regardless of user/session — for a history view or dashboard |
getStats(ctx) | query | Aggregate trace/observation/score counts for a small dashboard |
Type Reference
type LogObservationArgs = {
traceId?: string; // omit to start a new trace
traceName?: string;
userId?: string;
sessionId?: string;
tags?: string[];
release?: string;
traceMetadata?: Record<string, unknown>;
name: string;
input?: unknown;
output?: unknown;
model?: string;
inputTokens?: number;
outputTokens?: number;
level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
statusMessage?: string;
metadata?: Record<string, unknown>;
startTime?: number; // ms epoch, defaults to now
endTime?: number; // ms epoch, defaults to startTime
};
type Trace = {
traceId: string;
name: string;
userId?: string;
sessionId?: string;
tags?: string[];
metadata?: string;
release?: string;
};
type Observation = {
observationId: string;
traceId: string;
type: "span" | "generation" | "event" | "embedding" | "agent" | "tool"
| "chain" | "retriever" | "guardrail" | "evaluator";
name: string;
input?: string;
output?: string;
model?: string;
inputTokens?: number;
outputTokens?: number;
level: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
statusMessage?: string;
metadata?: string;
startedAt: number;
endedAt: number;
};
type Score = {
scoreId: string;
traceId: string;
name: string;
value: number;
comment?: string;
};
Database Schema
traces: {
traceId, name, userId?, sessionId?, tags?, metadata?, release?,
createdAt, updatedAt,
}
observations: {
observationId, traceId, type, name, input?, output?, model?,
inputTokens?, outputTokens?, level, statusMessage?, metadata?,
startedAt, endedAt, createdAt,
}
scores: {
scoreId, traceId, name, value, comment?, createdAt,
}
listRecentTraces() and getStats() read across these tables directly with a full scan — fine for a history view or small dashboard, not intended as a high-volume production query path.
Testing
npm run test
Component logic is tested with convex-test in src/component/lib.test.ts. Import convex-langfuse/test in your own app to register this component's schema against your test instance.
Example App
example/ is a full Vite + React demo that exercises the entire component end to end against your own Langfuse account:
- Log a generation — simulate a finished chat completion (model, prompt, completion, token counts, tags) and export it as a Langfuse generation.
- Continue the trace — attach a non-LLM step (a retrieval, a tool call, a guardrail check) to the same trace via
logSpan(). - Rate the response — attach a 👍/👎
recordScore()call, with an optional comment. - Trace explorer — look up any trace by id, or browse the traces recorded for a given user/session, all reactive.
- History — every trace ever recorded in the deployment, newest first, independent of the browser-local demo identity that resets on reload. Expand a trace to see its full generation/step/score detail, and hit Replay this to recreate the whole scenario as a brand-new trace live, without retyping anything — useful for anyone poking at the demo who wants to see the flow without filling out forms themselves.
- Activity console — a side-docked live log of every call this app makes into the Langfuse client. Since Langfuse ingestion is one-way (no webhooks call back into Convex), this is the closest thing to a live feed the example has.
Run it from the repo root:
npm install --legacy-peer-deps
npx convex env set LANGFUSE_PUBLIC_KEY pk-lf-xxxxxxxxxxxx
npx convex env set LANGFUSE_SECRET_KEY sk-lf-xxxxxxxxxxxx
npm run dev
npm run dev starts the Convex backend and the Vite frontend together — there's no need to cd example or run either one separately.
Limitations
- No live/streaming spans — each call exports one already-finished observation. For a call you want to see "in progress," log it once it completes rather than trying to open and later close a span.
- Trace-level fields (
userId,sessionId,tags,metadata) are attached to every span you log under that trace, per Langfuse's own OTel guidance for filterable trace attributes — there's no separate "create trace" call. - This component does not vendor the Langfuse SDK or an OpenTelemetry SDK; it constructs minimal OTLP/HTTP JSON payloads directly, which keeps it dependency-free but means it only covers the attributes this README documents.
Troubleshooting
Nothing shows up in the Langfuse UI — confirm baseUrl matches your project's region (EU, US, JP, HIPAA, or your self-hosted URL) and that the OTLP endpoint isn't blocked by a firewall between Convex and Langfuse.
401 from Langfuse — publicKey/secretKey are combined into a Basic auth header; confirm both are set and belong to the same project.
Generation shows no cost/usage — cost is computed by Langfuse from inputTokens/outputTokens and the model's pricing table; confirm model matches a model name Langfuse recognizes for your provider.
Contributing
See CONTRIBUTING.md.
Changelog
See CHANGELOG.md.