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_callspan for each call of a method on a provider or controller whose class name matchesinclude, by default names ending inService,Controller,Gateway,Resolver,Repository,UseCaseorHandler. Each records its arguments and result unless you passcaptureIO: false. - An
llm_callspan for each OpenAI call and atool_callspan for each tool your code runs throughtraceTool, under the method that made them.
The tutorial below, a CommonJS app, records one trace of 6 spans:
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 answerInstall
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/nodeSet 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 understrictagainst Nest 12. When it finds nothing to read, it logs that and returns 0; it never fails the app's startup. enableNestjsAutoTracewraps the methods defined on each matching class, once, afterNestFactory.create, and returns how many it wrapped. It skips lifecycle hooks; thecanActivate,intercept,transform,catchandusemethods of guards, interceptors, pipes, filters and middleware; Passport'svalidate,authenticate,serializeUseranddeserializeUser; getters and setters; and methods inherited from a base class. Route metadata such as@Postand@Bodyis kept.- Narrow it with
{ include: /(Service|Controller)$/ }, or keep arguments and results out with{ captureIO: false }. See Configuration and privacy. createNestjsInterceptor()returns an interceptor forapp.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()insrc/tracing.tsand import it on the first line ofsrc/main.ts. Compiled to CommonJS, the imports becomerequirecalls in the same order, soinit()runs before Nest, OpenAI and your modules load. - Create clients and tools in providers, as the tutorial does with
SupportService: Nest builds them afterbootstrap()starts, long afterinit(). - Compile with
tsc. Nest's constructor injection reads the decorator metadata thatemitDecoratorMetadatamakestscemit. - 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, Nest and the OpenAI client, as in Install.
- 2
Add the files
Copy the files below into
src/, andtsconfig.jsonbeside it.SupportServiceoffers 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
Build and start
Run
npx tsc -p .and thennode dist/main.js. 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: "nestjs-support",
environment: "development",
});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;
}
}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 };
}
}import { Module } from "@nestjs/common";
import { ChatController } from "./chat.controller";
import { SupportService } from "./support.service";
@Module({
controllers: [ChatController],
providers: [SupportService],
})
export class AppModule {}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();{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}export EVALKIT_SUBSCRIPTION_KEY=tk_live_... # Settings → This project → Environments → Copy key
export OPENAI_API_KEY=sk-...
npx tsc -p . && node dist/main.jscurl -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, 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
- Open Traces in the Environment whose key you used. The newest row is
POST /chatfrom the servicenestjs-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.
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 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.
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 withNest could not find DiscoveryService element; the same call failstsc --strictagainst Nest 12 withArgument of type 'INestApplication<any>' is not assignable. ImportDiscoveryModulefrom@nestjs/corein your root module and passapp.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 - /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. - 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; narrowincludethere 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'slookupOrderis a property, which is why it shows only as itstool_callspan. - Arguments you would rather not record. Every wrapped method records its arguments and result. Pass
{ captureIO: false }, narrowinclude, 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. 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.