Docs

    Frameworks

    NestJS

    Trace a NestJS app with syntropylabs-evalkit: one trace per request, function spans for every controller and service method through enableNestjsAutoTrace, sessions and agents, and a runnable tutorial.

    init() patches the Node.js HTTP server, so every request a NestJS app answers becomes one trace, named by its route, with the model, tool, HTTP and database calls inside. enableNestjsAutoTrace adds a function span for each method of your controllers and services, with its arguments and result. This page sets that up on the Express platform, walks through a small support agent with one tool, and lists what to watch for.

    What you get

    • One root span per request, named by the route, such as POST /chat, with method, URL, status, headers and bodies.
    • A function_call span for each call of a method on a provider or controller whose class name matches include, by default names ending in Service, Controller, Gateway, Resolver, Repository, UseCase or Handler. Each records its arguments and result unless you pass captureIO: false.
    • An llm_call span for each OpenAI call and a tool_call span for each tool your code runs through traceTool, under the method that made them.

    The tutorial below, a CommonJS app, records one trace of 6 spans:

    text
    POST /chat                          http_call      the request
      ChatController.chat               function_call  from enableNestjsAutoTrace
        SupportService.answer           function_call
          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=commonjs   # Nest's usual module format
    npm install syntropylabs-evalkit openai @nestjs/core @nestjs/common @nestjs/platform-express reflect-metadata rxjs
    npm install -D typescript @types/node

    Set up

    import evalkit from "syntropylabs-evalkit";
    
    evalkit.init({
      subscriptionKey: process.env.EVALKIT_SUBSCRIPTION_KEY!,
      serviceName: "my-api",
      environment: "development",
    });
    • Pass the application itself. enableNestjsAutoTrace(app) reads the providers and controllers from the application's own module container, so no module has to be imported for it, and the call type-checks under strict against Nest 12. When it finds nothing to read, it logs that and returns 0; it never fails the app's startup.
    • enableNestjsAutoTrace wraps the methods defined on each matching class, once, after NestFactory.create, and returns how many it wrapped. It skips lifecycle hooks; the canActivate, intercept, transform, catch and use methods of guards, interceptors, pipes, filters and middleware; Passport's validate, authenticate, serializeUser and deserializeUser; getters and setters; and methods inherited from a base class. Route metadata such as @Post and @Body is kept.
    • Narrow it with { include: /(Service|Controller)$/ }, or keep arguments and results out with { captureIO: false }. See Configuration and privacy.
    • createNestjsInterceptor() returns an interceptor for app.useGlobalInterceptors(...) or @UseInterceptors(...), if you would rather not wrap methods: it names the request's server span by its route and opens no second span. The tutorial does not use it. On 0.3.4 and earlier it fails every request with a 500; do not register it there.

    Where init() goes

    • Put init() in src/tracing.ts and import it on the first line of src/main.ts. Compiled to CommonJS, the imports become require calls in the same order, so init() runs before Nest, OpenAI and your modules load.
    • Create clients and tools in providers, as the tutorial does with SupportService: Nest builds them after bootstrap() starts, long after init().
    • Compile with tsc. Nest's constructor injection reads the decorator metadata that emitDecoratorMetadata makes tsc emit.
    • 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, Nest and the OpenAI client, as in Install.

    2. 2

      Add the files

      Copy the files below into src/, and tsconfig.json beside it. SupportService 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

      Build and start

      Run npx tsc -p . and then node dist/main.js. 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: "nestjs-support",
      environment: "development",
    });
    src/support.service.ts
    import { Injectable } from "@nestjs/common";
    import OpenAI from "openai";
    import evalkit from "syntropylabs-evalkit";
    
    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"] },
      },
    }];
    
    @Injectable()
    export class SupportService {
      private readonly client = new OpenAI(); // reads OPENAI_API_KEY from the environment
    
      // A tool_call span with the arguments and the result.
      private readonly lookupOrder = evalkit.traceTool("lookup_order", async (args: { query: string }) => {
        return { order_id: "1001", status: "shipped", arrives: "Thursday" };
      });
    
      async answer(message: string): Promise<string | null> {
        const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
          { role: "system", content: SYSTEM },
          { role: "user", content: message },
        ];
        let reply = (await this.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 this.lookupOrder(JSON.parse(call.function.arguments));
            messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
          }
          reply = (await this.client.chat.completions.create({ model: "gpt-4o-mini", messages, tools: TOOLS })).choices[0].message;
        }
        return reply.content;
      }
    }
    src/chat.controller.ts
    import { Body, Controller, Post } from "@nestjs/common";
    import evalkit from "syntropylabs-evalkit";
    import { SupportService } from "./support.service";
    
    @Controller()
    export class ChatController {
      constructor(private readonly support: SupportService) {}
    
      @Post("chat")
      async chat(@Body() body: { message: string; session_id: string; user_id?: string }) {
        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: body.session_id, userId: body.user_id }, () => this.support.answer(body.message));
        return { reply };
      }
    }
    src/app.module.ts
    import { Module } from "@nestjs/common";
    import { ChatController } from "./chat.controller";
    import { SupportService } from "./support.service";
    
    @Module({
      controllers: [ChatController],
      providers: [SupportService],
    })
    export class AppModule {}
    src/main.ts
    import "./tracing"; // first import: init() runs before Nest, OpenAI and your modules load
    import { NestFactory } from "@nestjs/core";
    import evalkit from "syntropylabs-evalkit";
    import { AppModule } from "./app.module";
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      // A function span for each method of your controllers and services, under the request span.
      await evalkit.enableNestjsAutoTrace(app);
      await app.listen(Number(process.env.PORT ?? 3000));
    }
    bootstrap();
    tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "commonjs",
        "strict": true,
        "esModuleInterop": true,
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "skipLibCheck": true,
        "rootDir": "src",
        "outDir": "dist"
      },
      "include": ["src"]
    }
    Set the keys, build and start
    export EVALKIT_SUBSCRIPTION_KEY=tk_live_...   # Settings → This project → Environments → Copy key
    export OPENAI_API_KEY=sk-...
    npx tsc -p . && node dist/main.js
    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 (201 Created, Nest's default for a POST)
    {"reply":"Order 1001 shipped yesterday and arrives on Thursday."}

    Tested with syntropylabs-evalkit 0.3.5, Node.js 26, NestJS 12.1 on the Express platform, openai 7.33 and TypeScript 7.0 compiled with tsc under strict: one trace of 6 spans, with the session conv_1 and the agent support-bot, and no DiscoveryModule import.

    See the trace

    1. Open Traces in the Environment whose key you used. The newest row is POST /chat from the service nestjs-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.

    Click SupportService.answer to read the message it was called with in function.args and the reply it returned in function.result.

    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.

    To name the session from headers for every route, call evalkit.setUser({ sessionId, userId }) in a Nest middleware or interceptor, with the values from the request headers. It runs inside the request, so it names the request's whole trace.

    Pitfalls

    • The app stops at startup, on 0.3.4 and earlier. There enableNestjsAutoTrace(app) asks Nest for a provider no module offers, and Nest aborts the app with Nest could not find DiscoveryService element; the same call fails tsc --strict against Nest 12 with Argument of type 'INestApplication<any>' is not assignable. Import DiscoveryModule from @nestjs/core in your root module and pass app.get(DiscoveryService) there.
    • Express internals as spans. 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.
    • Health probe controllers stay out of the trace list. A route handler whose path is a health or readiness probe (/health, /healthz, /readyz, /livez) runs untraced, with what it calls, so a probe polled every few seconds adds no traces. Background work it starts is traced as usual. On 0.3.4 and earlier the probe controller and its services are traced on every poll; narrow include there to leave them out.
    • A class has no function spans. Its name does not match include, or the method is inherited from a base class or defined as a property. The tutorial's lookupOrder is a property, which is why it shows only as its tool_call span.
    • Arguments you would rather not record. Every wrapped method records its arguments and result. Pass { captureIO: false }, narrow include, or drop the attributes in a mask hook; see Configuration and privacy.
    • 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.