Tracking cost and latency for LLM calls gets messy the moment you have more than one route calling a model. A nestjs interceptor token usage logging approach gives you a single, declarative place to capture usage metadata from OpenAI-compatible responses and push it to your metrics pipeline. This guide builds that interceptor from scratch, wires it to a real HTTP client, and shows how to verify the emitted logs in local development.
Step 1: Scaffold the interceptor class
Create a new file src/llm/token-usage.interceptor.ts. The interceptor needs to implement NestInterceptor and return a modified Observable from next.handle(). At this point we are not yet touching the response—just confirming the skeleton compiles.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class TokenUsageInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle();
}
}
The ExecutionContext gives you the incoming request; next.handle() returns the response stream from your route handler. We will tap into that stream in the next step.
Step 2: Extract token usage from the response body
OpenAI-compatible APIs return a usage object on non-streaming chat completion responses:
{
"id": "chatcmpl-123",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 34,
"total_tokens": 46
}
}
Define a minimal type and use RxJS map to pull the field. Update the interceptor:
import { map } from 'rxjs';
interface OpenAIUsage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
interface OpenAIResponse {
usage?: OpenAIUsage;
model?: string;
}
@Injectable()
export class TokenUsageInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((response: OpenAIResponse) => {
if (response?.usage) {
// store or log here; for now attach to request for later steps
const req = context.switchToHttp().getRequest();
req.tokenUsage = response.usage;
req.responseModel = response.model;
}
return response;
}),
);
}
}
This nestjs interceptor token usage logging pattern keeps the response intact while side‑collecting the metadata.
Step 3: Inject a logger and emit structured logs
NestJS ships with a Logger service. For production you will likely forward to Datadog, OTel, or a log sink; the interceptor should not care. Use a scoped logger and emit one line per request.
import { Logger } from '@nestjs/common';
@Injectable()
export class TokenUsageInterceptor implements NestInterceptor {
private readonly logger = new Logger('TokenUsage');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const route = `${req.method} ${req.route?.path ?? req.url}`;
return next.handle().pipe(
map((response: OpenAIResponse) => {
if (response?.usage) {
this.logger.log({
event: 'llm_token_usage',
route,
model: response.model,
prompt_tokens: response.usage.prompt_tokens,
completion_tokens: response.usage.completion_tokens,
total_tokens: response.usage.total_tokens,
userId: req.user?.id ?? 'anonymous',
});
}
return response;
}),
);
}
}
Structured logging makes the nestjs interceptor token usage logging output queryable. Avoid string concatenation; emit objects so your log shipper can index fields.
Step 4: Bind the interceptor globally or per‑route
For a single LLM controller, use @UseInterceptors on the controller class:
@Controller('llm')
@UseInterceptors(TokenUsageInterceptor)
export class LlmController {}
To cover every outbound LLM call regardless of entry point, register it globally in app.module.ts:
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: TokenUsageInterceptor },
],
})
export class AppModule {}
Global binding is simplest when all your model traffic flows through HTTP services inside the app. If you also call models from cron jobs or queues, instantiate the interceptor manually there or move the logic into a shared service.
Step 5: Call an OpenAI‑compatible LLM endpoint
Assume you use @nestjs/axios. Create a service that posts to a chat completion endpoint and returns the parsed body.
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class LlmService {
constructor(private readonly http: HttpService) {}
async complete(prompt: string) {
const { data } = await firstValueFrom(
this.http.post('https://api.openai.com/v1/chat/completions', {
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
}, {
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
}),
);
return data;
}
}
When you point that request at an OpenAI-compatible gateway such as n4n.ai, the usage object reflects per-token metering across 240+ models and stays accurate even if the gateway performs automatic fallback to a secondary provider. The interceptor code does not change—it only reads the standard field.
If your gateway honors client routing directives or forwards provider cache-control hints, those details appear in response headers; you can extend the interceptor to log x-cache or similar by reading context.switchToHttp().getResponse().headers inside a tap before the response maps.
Step 6: Verify the logs in local development
Start the app and hit a route that triggers LlmService.complete.
npm run start:dev
curl -X POST http://localhost:3000/llm/ask -d '{"prompt":"What is 2+2?"}' -H 'Content-Type: application/json'
You should see a line in the console similar to:
{"event":"llm_token_usage","route":"POST /llm/ask","model":"gpt-4o-mini","prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"userId":"anonymous"}
If the line is missing, set a breakpoint in the map operator or temporarily console.log(response) before the if. Common failures: the upstream returned a stream (no usage in the body), the response was wrapped by another interceptor, or the route handler returned a Promise that resolves to a DTO stripping usage. Ensure your controller returns the raw LLM response or re‑attaches usage before sending.
Step 7: Correlate usage with request IDs and cost centers
The basic interceptor logs per request. In a multi‑tenant system you need cost attribution. Extend the logger call to include a correlation ID from a header and a static tag from route metadata.
map((response: OpenAIResponse) => {
if (response?.usage) {
this.logger.log({
event: 'llm_token_usage',
route,
correlationId: req.headers['x-correlation-id'] ?? req.id,
costCenter: req.headers['x-cost-center'] ?? 'default',
model: response.model,
...response.usage,
});
}
return response;
})
You can also estimate spend by maintaining a local price table keyed by model. Do not hardcode prices in the interceptor; inject a PricingService that fetches from your finance system or a config file. The interceptor stays thin:
const costUsd = this.pricing.estimate(response.model, response.usage);
this.logger.log({ ...baseLog, costUsd });
Handling streaming responses
The code above assumes a buffered JSON response. If you use stream: true, the usage object arrives in the final SSE event or in a trailing HTTP header (x-openai-usage). In that case, do not use map on the response body. Instead, buffer the stream in your service, parse the last chunk, and attach usage to the resolved value before the controller returns. The interceptor then works unchanged. Alternatively, write a dedicated streaming interceptor that taps the response Observable of the Axios stream and emits a log when the stream closes.
Step 8: Ship it with confidence
A nestjs interceptor token usage logging implementation is now covering every LLM exit point. You have a single file that:
- Extracts
prompt_tokens,completion_tokens,total_tokens. - Tags the call with route, user, and correlation ID.
- Emits structured logs without mutating business logic.
From here, pipe the log lines into your metrics backend. Create a dashboard for total_tokens by model and alert when a single costCenter exceeds a daily budget. Because the interceptor is decoupled from the HTTP client, you can later swap Axios for fetch or add retries without touching observability.
Keep the interceptor free of async side effects—logging should never block the response. If you need to write to a slow sink, use tap with queueMicrotask or an async appender configured in your logger transport. That preserves latency while keeping your token accounting complete.