Open Kortyx on GitHub

Export To Langfuse

Updated 14 hours ago • June 2, 2026

Langfuse ingests Kortyx traces over OpenTelemetry. Kortyx remains backend-neutral: there is no Langfuse-specific Kortyx package and your app owns the exporter setup. Wire @kortyx/otel as usual, then map a small number of Kortyx attributes to Langfuse-native names so the UI renders trace input, output, tags, and optional managed-prompt linkage correctly.

This guide assumes you have already read OpenTelemetry. Everything here is additive on top of that setup.

Install

pnpm add @kortyx/otel @langfuse/otel @opentelemetry/api @opentelemetry/sdk-node

@kortyx/otel is required regardless of backend — it produces the spans. @langfuse/otel provides the LangfuseSpanProcessor that exports them to Langfuse.

Good to know: Add @langfuse/tracing only when your app also needs Langfuse-specific manual observations or context propagation outside Kortyx. It is not required for the Kortyx adapter path below.

Configure the span processor

Replace the OTLP exporter from the OpenTelemetry guide with Langfuse's span processor, or run both side-by-side.

src/lib/otel.ts
import { LangfuseSpanProcessor } from "@langfuse/otel"; import { NodeSDK } from "@opentelemetry/sdk-node"; export const langfuseSpanProcessor = new LangfuseSpanProcessor({ publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, baseUrl: process.env.LANGFUSE_BASE_URL, // optional self-hosted URL }); export const otelSdk = new NodeSDK({ spanProcessors: [langfuseSpanProcessor], }); otelSdk.start();

The Langfuse span processor batches spans by default. In short-lived runtimes, flush it before the process exits or the runtime freezes:

await langfuseSpanProcessor.forceFlush();

Next.js serverless routes

Register your OTel bootstrap from instrumentation.ts so tracing starts before the agent:

instrumentation.ts
export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { await import("./src/lib/otel"); } }

In a Vercel or Next.js serverless route, schedule a flush after the response completes:

import { after } from "next/server"; import { langfuseSpanProcessor } from "@/lib/otel"; export async function POST(request: Request) { const response = await handleChat(request); after(async () => { await langfuseSpanProcessor.forceFlush(); }); return response; }

Good to know: If your Next.js build creates separate instrumentation and route bundles, keep the processor in a shared globalThis-backed singleton module. That ensures the route flushes the same processor instance registered with your tracer provider.

Translate Kortyx attributes to Langfuse attributes

Kortyx emits attributes under the kortyx.* namespace. Langfuse reads its own langfuse.* keys. Use the mapAttributes hook on the OTel adapter to translate.

src/lib/kortyx-agent.ts
import "./otel"; import { createOpenTelemetryTraceAdapter } from "@kortyx/otel"; import { createAgent } from "kortyx"; import { getProvider } from "./providers"; import { workflows } from "./workflows"; const ROOT_SPAN_NAME = "kortyx.run"; export const agent = createAgent({ workflows, getProvider, telemetry: { trace: createOpenTelemetryTraceAdapter({ captureContent: true, mapAttributes: ({ name, attributes }) => { const additions: Record<string, unknown> = {}; // Tags: kortyx.trace.tags → langfuse.trace.tags const tags = attributes["kortyx.trace.tags"]; if (Array.isArray(tags) && tags.length > 0) { additions["langfuse.trace.tags"] = tags; } // Metadata: kortyx.trace.metadata.<k> → langfuse.trace.metadata.<k> for (const [key, value] of Object.entries(attributes)) { if (!key.startsWith("kortyx.trace.metadata.")) continue; const rest = key.slice("kortyx.trace.metadata.".length); additions[`langfuse.trace.metadata.${rest}`] = value; } // Content: kortyx.trace.input/output → langfuse.observation.input/output const input = attributes["kortyx.trace.input"]; const output = attributes["kortyx.trace.output"]; if (input !== undefined) { additions["langfuse.observation.input"] = input; } if (output !== undefined) { additions["langfuse.observation.output"] = output; } // Root span also drives the top-level trace card in Langfuse if (name === ROOT_SPAN_NAME) { const workflowId = attributes["kortyx.workflow.id"]; additions["langfuse.trace.name"] = typeof workflowId === "string" ? `agent · ${workflowId}` : "agent"; if (input !== undefined) { additions["langfuse.trace.input"] = input; } if (output !== undefined) { additions["langfuse.trace.output"] = output; } } return Object.keys(additions).length > 0 ? additions : undefined; }, mapPromptMetadata: (prompt) => ({ ...(prompt.name ? { "langfuse.observation.prompt.name": prompt.name } : {}), ...(Number.isInteger(prompt.version) ? { "langfuse.observation.prompt.version": prompt.version } : {}), }), }), }, });

The mapping reference:

Kortyx attributeLangfuse attribute
session.id (from streamChat.sessionId)already native — no-op
user.id (from context.userId)already native — no-op
kortyx.trace.tagslangfuse.trace.tags
kortyx.trace.metadata.<k> (each)langfuse.trace.metadata.<k>
kortyx.trace.input / .output (any span)langfuse.observation.input / .output
kortyx.trace.input / .output (run span)langfuse.trace.input / .output
telemetry.prompt.name / integer .versionlangfuse.observation.prompt.*
kortyx.workflow.id (run span)drives langfuse.trace.name

After the first request, Langfuse should show a trace named agent · <workflow-id> with the Kortyx run span as its root. If captureContent is enabled, the trace card and observations should include their input and output.

Tracing works without a prompt store. If a node uses an external managed prompt, pass its identity through useReason({ telemetry: { prompt } }) so Langfuse links that generation to the corresponding managed prompt.

Langfuse expects langfuse.observation.prompt.version to be an integer. If your prompt store uses string versions such as "v1", keep that value in telemetry.prompt.metadata for filtering instead of mapping it as the Langfuse prompt version.

const prompt = await promptStore.get("assessment-chat"); await useReason({ id: "assessment-chat", model, input: prompt.compile(vars), telemetry: { prompt: { name: prompt.name, version: prompt.version, // use an integer for Langfuse prompt linking metadata: prompt.toJSON(), }, tags: [`prompt:${prompt.name}:${prompt.version}`], }, });

Map tags

Tags work the same way as in the generic OpenTelemetry guide — pass them through telemetry.tags and the mapAttributes hook above translates kortyx.trace.tags to langfuse.trace.tags. Once translated, tags become filter facets on the Langfuse trace list.

createAgent({ workflows, telemetry: { trace, tags: ["env:production", "service:assistant"], }, }); await useReason({ id: "intent-classifier", model, input, telemetry: { tags: ["classifier"], }, });

Langfuse treats langfuse.trace.tags as trace-level context, even when it sees the attribute on a child observation. Put stable tags such as environment and service on createAgent(...); use per-useReason tags only when a generation should add searchable context to the whole trace.

Score traces from the client

Langfuse scores (thumbs up/down, hallucination flags, deflection rates) are written against a traceId. Kortyx surfaces the active trace id to the client via the trace stream chunk and ChatMsg.traceId — no header capture or wrapper spans needed.

A minimal feedback flow:

"use client"; import type { ChatMsg } from "@kortyx/react"; export function Thumbs({ msg }: { msg: ChatMsg }) { if (!msg.traceId) return null; return ( <button onClick={() => { void fetch("/api/feedback", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ traceId: msg.traceId, kind: "good" }), }); }} > 👍 </button> ); }

Then write the score with the Langfuse client in a server route:

pnpm add @langfuse/client
app/api/feedback/route.ts
import { LangfuseClient } from "@langfuse/client"; const langfuse = new LangfuseClient(); export async function POST(request: Request) { const { traceId, kind } = await request.json(); langfuse.score.create({ traceId, name: "user-feedback", value: kind, dataType: "CATEGORICAL", }); await langfuse.flush(); return Response.json({ ok: true }); }

Good to know: Accepting a raw traceId from the browser lets any authenticated user write scores against any trace id they can guess. If that matters for your app, mint a signed token server-side keyed to { traceId, userId } while the stream is open (you can wrap the kortyx stream and inject the token as an extra structured-data chunk after the native trace chunk), then verify it in the feedback route.

  • Read OpenTelemetry for the base adapter setup, identity propagation, and prompt metadata.
  • Read useChat(...) for how ChatMsg.traceId flows through the React client.