Open Kortyx on GitHub

OpenTelemetry

Updated 14 hours ago • June 2, 2026

Kortyx can emit server-side OpenTelemetry spans for chat runs, workflow nodes, useReason(...), and provider calls.

Use this when you want to inspect:

  • which session and user triggered a run
  • which workflow and node handled the request
  • which provider/model was called
  • token usage and finish reasons
  • prompt identity and version metadata
  • interrupts, retries, and failures

Kortyx does not configure your collector or tracing backend. Your app owns the OpenTelemetry SDK/exporter setup.

Install

pnpm add @kortyx/otel @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http

Configure OpenTelemetry on the server

Initialize OpenTelemetry before creating the agent.

src/lib/otel.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { NodeSDK } from "@opentelemetry/sdk-node"; export const otelSdk = new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, }), }); otelSdk.start();

Good to know: Keep OpenTelemetry setup in server bootstrap code. React clients should pass stable identifiers such as sessionId, while provider calls and token usage stay on the server. If your tracing endpoint requires headers, set OTEL_EXPORTER_OTLP_TRACES_HEADERS in the environment instead of adding header parsing to the example.

Wire Kortyx tracing

Pass the OpenTelemetry adapter when creating the agent.

src/lib/kortyx-agent.ts
import "./otel"; import { createOpenTelemetryTraceAdapter } from "@kortyx/otel"; import { createAgent } from "kortyx"; import { getProvider } from "./providers"; import { workflows } from "./workflows"; export const agent = createAgent({ workflows, getProvider, telemetry: { trace: createOpenTelemetryTraceAdapter({ captureContent: false, }), }, });

See Capture inputs and outputs before enabling raw prompt or completion capture.

Call the agent normally and pass request context from trusted server code.

const stream = await agent.streamChat(messages, { sessionId: threadId, context: { userId: user.id, tenantId: tenant.id, }, });

The trace contains a run span, node spans, useReason spans, and generation spans. Generation spans include standard gen_ai.* attributes for provider/model metadata and token usage. MCP tool loops add useReason.tool-step.* and useReason.tool-call.* events plus kortyx.tool.* count attributes on the useReason span.

Pass identity from React

React clients can pass request context through the route transport. For authenticated apps, treat frontend context as request hints and set the trusted userId and tenantId on the server from your auth/session layer.

"use client"; import { createRouteChatTransport, useChat } from "@kortyx/react"; export function Chat({ accountId }: { accountId: string }) { const chat = useChat({ transport: createRouteChatTransport({ endpoint: "/api/chat" }), context: { accountId, }, }); return ( <button onClick={() => chat.send("Summarize this account")}>Send</button> ); }

On the route, parse the chat body, read the authenticated user, and forward the merged context to the agent.

app/api/chat/route.ts
import { parseChatRequestBody } from "@kortyx/agent"; import { toSSE } from "@kortyx/stream"; import { agent } from "@/lib/kortyx-agent"; import { getSession } from "@/lib/session"; export async function POST(request: Request) { const body = parseChatRequestBody(await request.json()); const session = await getSession(request); if (!session) { return new Response("Unauthorized", { status: 401 }); } const stream = await agent.streamChat(body.messages, { sessionId: body.sessionId, workflowId: body.workflowId, context: { ...body.context, userId: session.user.id, tenantId: session.tenant.id, }, }); return toSSE(stream); }

The accountId from the React client is sent in body.context, merged into the server context, and recorded as kortyx.trace.metadata.accountId. The resulting spans also include session.id, gen_ai.conversation.id, user.id, and kortyx.tenant.id when those values are present.

If you want accountId as a first-class attribute in your tracing backend, map it in the adapter:

createOpenTelemetryTraceAdapter({ mapAttributes: ({ attributes }) => ({ ...(attributes["kortyx.trace.metadata.accountId"] ? { "app.account.id": attributes["kortyx.trace.metadata.accountId"] } : {}), }), });

Good to know: If your app only has an anonymous browser identifier, pass it as app context such as anonymousId and map it explicitly in createOpenTelemetryTraceAdapter({ mapAttributes }). Use userId for authenticated server-derived identities.

Attach prompt metadata

If your node loads prompts from an external prompt store, pass prompt identity through useReason({ telemetry }).

import { useReason } from "kortyx"; import { google } from "../lib/providers"; import { promptStore } from "../lib/prompts"; export async function chatNode() { const prompt = await promptStore.get("assessment-chat"); const input = prompt.compile({ question: "Summarize this candidate" }); const result = await useReason({ id: "assessment-chat", model: google("gemini-2.5-flash"), input, telemetry: { operation: "assessmentChat", prompt: { name: prompt.name, version: prompt.version, type: prompt.type, metadata: prompt.toJSON(), }, tags: [`prompt:${prompt.name}:${prompt.version}`], }, }); return { ui: { message: result.text } }; }

Kortyx records generic prompt attributes such as gen_ai.prompt.name, gen_ai.prompt.version, and kortyx.prompt.*.

Customize span attributes

Use mapper hooks when your OpenTelemetry pipeline expects additional attributes.

const trace = createOpenTelemetryTraceAdapter({ mapPromptMetadata: (prompt) => ({ "app.prompt.metadata": prompt.metadata, }), mapAttributes: ({ attributes }) => ({ ...(attributes["kortyx.trace.metadata.tenantId"] ? { "app.tenant.id": attributes["kortyx.trace.metadata.tenantId"] } : {}), }), });

Good to know: Prompt text alone is not enough to link a generation to a managed prompt. Pass prompt name, version, type, and any prompt-store metadata in telemetry.prompt.

Capture inputs and outputs

Raw prompts and model outputs can contain sensitive data. Kortyx leaves content capture off by default.

Enable it only when your app has an approved retention and redaction policy.

createOpenTelemetryTraceAdapter({ captureContent: { input: true, output: true, }, });

Attach tags to traces

Pass tags in telemetry to label runs with searchable strings. They are emitted as the OpenTelemetry attribute kortyx.trace.tags on the relevant span and most backends expose them as filter facets.

App-wide tags applied to every kortyx.run span:

createAgent({ workflows, getProvider, telemetry: { trace: createOpenTelemetryTraceAdapter({ captureContent: false }), tags: ["env:production", "service:assistant"], }, });

Per-call tags applied to the matching useReason span:

await useReason({ id: "classify-intent", model, input, telemetry: { tags: ["intent-classifier", `prompt:${prompt.name}:${prompt.version}`], }, });

If your tracing backend expects a different attribute name (for example, Langfuse looks for langfuse.trace.tags), translate kortyx.trace.tags in mapAttributes. See Export To Langfuse for the exact wiring.

Read trace ids from the client

When @kortyx/otel is wired on the agent, the orchestrator emits a trace stream chunk as soon as the kortyx.run span opens:

{ type: "trace", traceId: "…", spanId: "…", runId: "…", rootSpanName: "kortyx.run" }

@kortyx/react reads it off the wire and stamps every assistant ChatMsg produced during that turn with traceId, spanId, and runId. You do not need to capture response headers or open your own wrapper spans — the values are first-class fields on the message.

"use client"; import type { ChatMsg } from "@kortyx/react"; export function Thumbs({ msg }: { msg: ChatMsg }) { if (!msg.traceId) return null; return ( <a href={`https://your-trace-ui/trace/${msg.traceId}`} target="_blank" rel="noreferrer" > View trace </a> ); }

The trace fields are undefined until a trace chunk arrives, which happens when an OTel trace adapter is configured on the agent. Without @kortyx/otel (or another adapter that supports getActiveContext) wired into createAgent({ telemetry: { trace } }), no trace chunk is emitted and msg.traceId stays undefined.

Restored-from-storage messages keep their trace ids — createBrowserChatStorage serializes them with the rest of the ChatMsg.