feat(logging): add trace ID propagation via AsyncLocalStorage

- add TraceInterceptor that reads x-trace-id header or generates a UUID
- add AsyncLocalStorage store in common/trace-context for request-scoped trace ID
- replace NestJS Logger with TraceLogger (extends ConsoleLogger) that injects trace ID into log context
- register TraceInterceptor globally before LoggingInterceptor
- move HealthController into HealthModule so global interceptors apply to /healthz
- omit body/response from log lines when empty or null
This commit is contained in:
2026-04-08 12:20:06 +03:00
parent 7f5a2bfe18
commit 6fd991811f
12 changed files with 72 additions and 19 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthController } from './health/health.controller';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { BrowserModule } from './browser/browser.module';
import { SessionEntity } from './session/session.entity';
@@ -36,7 +36,7 @@ import { ScenarioModule } from './scenario/scenario.module';
EnvironmentModule,
ScenarioModule,
McpModule,
HealthModule,
],
controllers: [HealthController],
})
export class AppModule {}
+2 -2
View File
@@ -2,9 +2,9 @@ import {
Injectable,
BadRequestException,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import { ConfigService } from '@nestjs/config';
import { chromium } from 'playwright';
import * as fs from 'fs';
@@ -21,7 +21,7 @@ interface KeyDescriptor {
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly logger = new TraceLogger(AuthService.name);
private readonly keysDir: string;
constructor(
+2 -2
View File
@@ -2,9 +2,9 @@ import {
Injectable,
HttpException,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import { chromium } from 'playwright';
import type { BrowserContext } from 'playwright';
import { Readability } from '@mozilla/readability';
@@ -23,7 +23,7 @@ export interface OpenResult {
@Injectable()
export class BrowserService {
private readonly logger = new Logger(BrowserService.name);
private readonly logger = new TraceLogger(BrowserService.name);
constructor(
private readonly sessionService: SessionService,
+3 -2
View File
@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import { parse } from 'acorn';
import type { Page, BrowserContext } from 'playwright';
import { dumpDom } from './dom-helpers';
@@ -9,7 +10,7 @@ export interface ExecResult {
@Injectable()
export class CodeExecutorService {
private readonly logger = new Logger(CodeExecutorService.name);
private readonly logger = new TraceLogger(CodeExecutorService.name);
/**
* Validates `code` by wrapping it in an async function body and attempting
+11
View File
@@ -0,0 +1,11 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface TraceStore {
traceId: string;
}
export const traceStorage = new AsyncLocalStorage<TraceStore>();
export function getTraceId(): string | undefined {
return traceStorage.getStore()?.traceId;
}
+10
View File
@@ -0,0 +1,10 @@
import { ConsoleLogger } from '@nestjs/common';
import { getTraceId } from './trace-context';
export class TraceLogger extends ConsoleLogger {
protected override formatContext(context: string): string {
const traceId = getTraceId();
const traced = traceId ? `${context}:${traceId}` : context;
return super.formatContext(traced);
}
}
+2 -2
View File
@@ -5,13 +5,13 @@ import {
ExceptionFilter,
HttpException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import type { Request, Response } from 'express';
@Catch(BadRequestException, InternalServerErrorException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
private readonly logger = new TraceLogger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+6 -4
View File
@@ -2,15 +2,15 @@ import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import type { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
private readonly logger = new TraceLogger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
@@ -18,13 +18,15 @@ export class LoggingInterceptor implements NestInterceptor {
const res = http.getResponse<Response>();
const { method, url, body } = req;
const start = Date.now();
const bodyStr = body && Object.keys(body).length ? ` ${JSON.stringify(body)}` : '';
this.logger.debug(`${method} ${url} ${JSON.stringify(body)}`);
this.logger.debug(`${method} ${url}${bodyStr}`);
return next.handle().pipe(
tap((responseBody) => {
const ms = Date.now() - start;
this.logger.debug(`${method} ${url} ${res.statusCode} (${ms}ms) ${JSON.stringify(responseBody)}`);
const resStr = responseBody != null ? ` ${JSON.stringify(responseBody)}` : '';
this.logger.debug(`${method} ${url} ${res.statusCode} (${ms}ms)${resStr}`);
}),
);
}
+19
View File
@@ -0,0 +1,19 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request } from 'express';
import * as crypto from 'crypto';
import { Observable } from 'rxjs';
import { traceStorage } from '../common/trace-context';
@Injectable()
export class TraceInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request>();
const traceId = (req.headers['x-trace-id'] as string | undefined) ?? crypto.randomUUID();
return new Observable((subscriber) => {
traceStorage.run({ traceId }, () => {
next.handle().subscribe(subscriber);
});
});
}
}
+5 -3
View File
@@ -1,19 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { TraceLogger } from './common/trace-logger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { TraceInterceptor } from './interceptors/trace.interceptor';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { name as pkgName, version as pkgVersion } from '../package.json';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const logger = new TraceLogger('Bootstrap');
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor());
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000);
+3 -2
View File
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -17,7 +18,7 @@ interface ValidateResult {
@Injectable()
export class ScenarioSchedulerService {
private readonly logger = new Logger(ScenarioSchedulerService.name);
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
private isProcessingStep = false;
constructor(