Frameworks
Express
Trace an Express app with syntropylabs-evalkit: one trace per request, with its model and tool calls inside, sessions and agents, and a tutorial you can run in five minutes.
init() patches the Node.js HTTP and HTTPS servers, so every request an Express app answers becomes one trace, named by its route, with the model calls, tool calls, HTTP and database calls it made inside. There is no middleware to add. This page sets that up, walks through a small support agent with one tool, and lists what to watch for.
What you get
- One root span per request,
POST /chat, named by the Express route (/orders/:id, not/orders/1001). It records the method, URL, route, status, request and response headers, and request and response bodies up to 10 MiB. - An
llm_callspan for each OpenAI call: model, prompt, completion, tokens, the tools offered and the tool calls the model asked for. - A
tool_callspan for each tool your code runs throughtraceTool, with its arguments and result. - Spans for outgoing HTTP and database calls made inside the request. See What is traced automatically.
The tutorial below records one trace of 4 spans:
POST /chat http_call the request
chat.completions.create llm_call the model asks for lookup_order
lookup_order tool_call its arguments and result
chat.completions.create llm_call the answerInstall
mkdir support-bot && cd support-bot
npm init -y && npm pkg set type=module # ES modules, as the imports below are written
npm install syntropylabs-evalkit openai express
npm install -D tsx typescript @types/node @types/expressSet up
import evalkit from "syntropylabs-evalkit";
evalkit.init({
subscriptionKey: process.env.EVALKIT_SUBSCRIPTION_KEY!,
serviceName: "my-api",
environment: "development",
});evalkit.expressMiddleware() is optional. init() has already opened the request span on every Node.js HTTP and HTTPS server, so the middleware steps aside; pass it { name }, a string or a function of the request, to give that span a name of your own.
Where init() goes
- Put
init()in a module of its own,src/tracing.ts, and import it on the first line of your entry file. ES modules run their imports in the order they are written, soinit()runs before Express, OpenAI and your own modules load. - Writing
evalkit.init()below the other imports of the entry file is too late: everyimportruns before any statement of the file, so a module that callstraceToolat its top level, such assrc/agent.ts, would run beforeinit()and throw. - Create clients, tools and wrappers after that import.
traceTool,traceFunctionand the framework adapters needinit()to have run, and throw[evalkit] call evalkit.init() before using the SDKwhen it has not. - Call
init()once per process, at startup. A second call applies its options, such as a new service name, to the spans that start after it, and patches nothing twice.
Tutorial: a support agent with one tool
- 1
Create the project
Install the SDK, the framework and the OpenAI client, as in Install.
- 2
Add the files
Copy
src/tracing.ts,src/agent.ts,src/server.tsandtsconfig.jsonbelow. The agent offers the model one tool,lookup_order, runs it when the model asks, and sends the result back for the answer. - 3
Set the keys
Export your environment key and your OpenAI key in the shell. The code reads both from the environment and never writes them anywhere.
- 4
Start the server
Run
npx tsx src/server.ts. The server listens on port 3000. - 5
Send one request
Ask about an order. The model calls
lookup_order, reads its result and answers. - 6
Open the trace
The trace appears in Traces within about 5 seconds, the export interval. See See the trace.
import evalkit from "syntropylabs-evalkit";
evalkit.init({
subscriptionKey: process.env.EVALKIT_SUBSCRIPTION_KEY!,
serviceName: "express-support",
environment: "development",
});import OpenAI from "openai";
import evalkit from "syntropylabs-evalkit";
const client = new OpenAI(); // reads OPENAI_API_KEY from the environment
const SYSTEM = "You are a support agent for an online shop. Use lookup_order for questions about an order.";
const TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [{
type: "function",
function: {
name: "lookup_order",
description: "Find an order from the customer's words.",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
},
}];
// A tool_call span with the arguments and the result. traceTool needs init() to have run.
const lookupOrder = evalkit.traceTool("lookup_order", async (args: { query: string }) => {
return { order_id: "1001", status: "shipped", arrives: "Thursday" };
});
export async function answer(message: string): Promise<string | null> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM },
{ role: "user", content: message },
];
let reply = (await client.chat.completions.create({ model: "gpt-4o-mini", messages, tools: TOOLS })).choices[0].message;
if (reply.tool_calls?.length) {
messages.push(reply);
for (const call of reply.tool_calls) {
if (call.type !== "function") continue;
const result = await lookupOrder(JSON.parse(call.function.arguments));
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
}
reply = (await client.chat.completions.create({ model: "gpt-4o-mini", messages, tools: TOOLS })).choices[0].message;
}
return reply.content;
}import "./tracing.js"; // first import: init() runs before Express, OpenAI and the agent load
import express from "express";
import evalkit from "syntropylabs-evalkit";
import { answer } from "./agent.js";
const app = express(); // init() already makes each request a trace, named by its route
app.use(express.json());
app.post("/chat", async (req, res) => {
const { message, session_id, user_id } = req.body;
evalkit.setAgent("support-bot"); // inside a request: names the agent of this trace only
// Groups the turns of one conversation; the scope follows the async calls inside it.
const reply = await evalkit.withSession({ sessionId: session_id, userId: user_id }, () => answer(message));
res.json({ reply });
});
app.listen(Number(process.env.PORT ?? 3000));{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}export EVALKIT_SUBSCRIPTION_KEY=tk_live_... # Settings → This project → Environments → Copy key
export OPENAI_API_KEY=sk-...
npx tsx src/server.tscurl -s -X POST localhost:3000/chat \
-H 'content-type: application/json' \
-d '{"message": "Where is my order 1001?", "session_id": "conv_1", "user_id": "u_42"}'{"reply":"Order 1001 shipped yesterday and arrives on Thursday."}Tested with syntropylabs-evalkit 0.3.5, Node.js 26, Express 5.3, openai 7.33 and tsx 4.23: one trace of 4 spans, with the session conv_1 and the agent support-bot. npx tsc -p . type-checks the project; tsx runs it without a build step.
See the trace
- Open Traces in the Environment whose key you used. The newest row is
POST /chatfrom the serviceexpress-support. - Open it. The waterfall shows the request span with the two model calls and the tool call inside it.
- Click the first
llm_callspan: the request lists thelookup_ordertool, and the reply asks for it. Thetool_callspan shows the arguments the model chose and the order your code returned. The second model call shows the answer, with tokens and cost. - Open Traces → Sessions and find
conv_1: this request is its first turn. Traces → Agents listssupport-bot.
Every response carries the trace id in an x-trace-id header, and a traceparent header for the request span. Paste the id into the search on Traces to open the trace of one request, for example from a support ticket.
Sessions, users and agents
evalkit.setAgent("support-bot")inside a request names the agent of that request's trace, root span included. Outside a request it names the agent of everything the current async context does next.evalkit.withSession({ sessionId, userId }, fn)puts every span started insidefnin the session, following the async calls inside it, and restores the previous scope whenfnsettles. Concurrent requests never see each other's session. The request span itself started beforewithSessionand carries none, but the trace still belongs to the session, because a trace belongs to every session any of its spans carries.evalkit.setUser({ sessionId, userId })inside a request names the session and user of that request's whole trace, root span included. It binds to the trace, so the next request starts with no identity.
When the client sends the conversation in headers, one middleware names every request's trace. Register it before your routes:
// Before your routes: names the session and user of each request's whole trace from its headers.
app.use((req, _res, next) => {
const sessionId = req.header("x-session-id");
if (sessionId) evalkit.setUser({ sessionId, userId: req.header("x-user-id") });
next();
});In a run of this middleware, the request span and the model call both carried the session and user, and a following request without the headers carried none.
Pitfalls
- An empty request body after an async middleware, on 0.3.4 and earlier. There an authentication or rate-limit middleware that awaits something before
express.json()leavesreq.bodyundefined, because the SDK had already read the body; registerexpress.json()first. From 0.3.5 the SDK records the body as Express reads it: in a run with an async middleware beforeexpress.json(), the handler got the body and the request span recorded it. - HTTPS servers on 0.3.4 and earlier. There
init()patches Node's HTTP server only, so a server created withhttps.createServergets its request span fromexpressMiddleware(): keep it in. From 0.3.5init()patches both. - Spans outside the session. Only spans started inside the
withSessioncallback carry the session. Await the whole turn inside it, as the tutorial does, not only the first call. - Streamed replies. A reply you stream to the client with
res.writestays in the request's trace, and its model-call span ends when the stream ends. Passstream_options: { include_usage: true }so OpenAI sends token counts in the stream; see Streamed calls. - Express internals as spans in CommonJS. On 0.3.4 and earlier, a CommonJS app also gets small spans for framework internals, such as
middleware - jsonParser,request handler - /chatandtcp.connect, from OpenTelemetry instrumentations that hookrequire(). SetOTEL_NODE_DISABLED_INSTRUMENTATIONStoexpress,router,netthere to leave them out. From 0.3.5 those instrumentations stay off, so a CommonJS app gives the same spans as an ES module. - A serverless function or a script loses its last spans. Node does not send queued spans when the process ends. Call
await evalkit.flush()before a script ends or a serverless handler returns; it never rejects. Do not call it on every request of a long-running server: the batch is sent every 5 seconds anyway. - Running without tracing. To run without tracing, for example in tests, guard
init()on the key variable. GuardtraceToolthe same way: they needinit()and fail with[evalkit] call evalkit.init() before using the SDKwithout it.evalkit.flush()needs no guard: beforeinit()it resolves tofalse(on 0.3.4 and earlier it rejects).
Safe by design
- Spans are batched in memory and exported in the background, so a request never waits for the trace service.
- If the trace service is down or unreachable, your app answers as before. The SDK writes
[evalkit] export network errorto the console and drops those spans. - If the key is wrong, the trace service refuses the spans and they are dropped; your app keeps answering.
- A streamed model reply reaches your code chunk by chunk, unchanged, as the provider SDK's own stream object:
tee(),withResponse(),finalMessage()and the other helpers work as without the SDK. The SDK reads the same chunks on the side and ends the span when the stream ends. - Tracing never breaks the call it records. An error inside the SDK's hooks is swallowed and the call goes on as if untraced, and the provider's own errors reach your code unchanged.
await evalkit.flush()never rejects: it resolves totruewhen the spans were sent andfalsewhen the export failed orinit()has not run, so a trace service outage never fails a job or a serverless invocation. On 0.3.4 and earlier it rejects when the export fails; writeawait evalkit.flush().catch(() => {})there.
Next steps
- Choose what is recorded, from prompts to function arguments, and mask what must not leave the process: Configuration and privacy.
- Every library the SDK traces without code, and how streamed calls are counted: What is traced automatically.
- Follow one request across services, or run beside an OpenTelemetry setup you already have: OpenTelemetry and distributed tracing.
- Read the trace you just sent: Your first trace, and group turns into conversations: Sessions and users.