Docs

    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_call span for each OpenAI call: model, prompt, completion, tokens, the tools offered and the tool calls the model asked for.
    • A tool_call span for each tool your code runs through traceTool, 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:

    text
    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 answer

    Install

    bash
    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/express

    Set 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, so init() runs before Express, OpenAI and your own modules load.
    • Writing evalkit.init() below the other imports of the entry file is too late: every import runs before any statement of the file, so a module that calls traceTool at its top level, such as src/agent.ts, would run before init() and throw.
    • Create clients, tools and wrappers after that import. traceTool, traceFunction and the framework adapters need init() to have run, and throw [evalkit] call evalkit.init() before using the SDK when 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. 1

      Create the project

      Install the SDK, the framework and the OpenAI client, as in Install.

    2. 2

      Add the files

      Copy src/tracing.ts, src/agent.ts, src/server.ts and tsconfig.json below. The agent offers the model one tool, lookup_order, runs it when the model asks, and sends the result back for the answer.

    3. 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. 4

      Start the server

      Run npx tsx src/server.ts. The server listens on port 3000.

    5. 5

      Send one request

      Ask about an order. The model calls lookup_order, reads its result and answers.

    6. 6

      Open the trace

      The trace appears in Traces within about 5 seconds, the export interval. See See the trace.

    src/tracing.ts
    import evalkit from "syntropylabs-evalkit";
    
    evalkit.init({
      subscriptionKey: process.env.EVALKIT_SUBSCRIPTION_KEY!,
      serviceName: "express-support",
      environment: "development",
    });
    src/agent.ts
    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;
    }
    src/server.ts
    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));
    tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "NodeNext",
        "moduleResolution": "NodeNext",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "noEmit": true
      },
      "include": ["src"]
    }
    Set the keys and start the server
    export EVALKIT_SUBSCRIPTION_KEY=tk_live_...   # Settings → This project → Environments → Copy key
    export OPENAI_API_KEY=sk-...
    npx tsx src/server.ts
    Send one request
    curl -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"}'
    Response
    {"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

    1. Open Traces in the Environment whose key you used. The newest row is POST /chat from the service express-support.
    2. Open it. The waterfall shows the request span with the two model calls and the tool call inside it.
    3. Click the first llm_call span: the request lists the lookup_order tool, and the reply asks for it. The tool_call span shows the arguments the model chose and the order your code returned. The second model call shows the answer, with tokens and cost.
    4. Open Traces → Sessions and find conv_1: this request is its first turn. Traces → Agents lists support-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 inside fn in the session, following the async calls inside it, and restores the previous scope when fn settles. Concurrent requests never see each other's session. The request span itself started before withSession and 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:

    typescript
    // 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() leaves req.body undefined, because the SDK had already read the body; register express.json() first. From 0.3.5 the SDK records the body as Express reads it: in a run with an async middleware before express.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 with https.createServer gets its request span from expressMiddleware(): keep it in. From 0.3.5 init() patches both.
    • Spans outside the session. Only spans started inside the withSession callback 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.write stays in the request's trace, and its model-call span ends when the stream ends. Pass stream_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 - /chat and tcp.connect, from OpenTelemetry instrumentations that hook require(). Set OTEL_NODE_DISABLED_INSTRUMENTATIONS to express,router,net there 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. Guard traceTool the same way: they need init() and fail with [evalkit] call evalkit.init() before using the SDK without it. evalkit.flush() needs no guard: before init() it resolves to false (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 error to 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 to true when the spans were sent and false when the export failed or init() 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; write await evalkit.flush().catch(() => {}) there.

    Next steps

    EvalKit is built by SyntropyLabs. Published on PyPI and npm.