From 6fd991811f5cf743861fb7e0d8af665442f1bc48 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Wed, 8 Apr 2026 12:20:06 +0300 Subject: [PATCH] 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 --- src/app.module.ts | 4 ++-- src/auth/auth.service.ts | 4 ++-- src/browser/browser.service.ts | 4 ++-- src/code-executor/code-executor.service.ts | 5 +++-- src/common/trace-context.ts | 11 +++++++++++ src/common/trace-logger.ts | 10 ++++++++++ src/filters/http-exception.filter.ts | 4 ++-- src/health/health.module.ts | 7 +++++++ src/interceptors/logging.interceptor.ts | 10 ++++++---- src/interceptors/trace.interceptor.ts | 19 +++++++++++++++++++ src/main.ts | 8 +++++--- src/scenario/scenario-scheduler.service.ts | 5 +++-- 12 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 src/common/trace-context.ts create mode 100644 src/common/trace-logger.ts create mode 100644 src/health/health.module.ts create mode 100644 src/interceptors/trace.interceptor.ts diff --git a/src/app.module.ts b/src/app.module.ts index 752749f..7d64940 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bff41cc..7f215d8 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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( diff --git a/src/browser/browser.service.ts b/src/browser/browser.service.ts index 2293d7b..9de2441 100644 --- a/src/browser/browser.service.ts +++ b/src/browser/browser.service.ts @@ -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, diff --git a/src/code-executor/code-executor.service.ts b/src/code-executor/code-executor.service.ts index d55850f..c0f42a2 100644 --- a/src/code-executor/code-executor.service.ts +++ b/src/code-executor/code-executor.service.ts @@ -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 diff --git a/src/common/trace-context.ts b/src/common/trace-context.ts new file mode 100644 index 0000000..25ec747 --- /dev/null +++ b/src/common/trace-context.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export interface TraceStore { + traceId: string; +} + +export const traceStorage = new AsyncLocalStorage(); + +export function getTraceId(): string | undefined { + return traceStorage.getStore()?.traceId; +} diff --git a/src/common/trace-logger.ts b/src/common/trace-logger.ts new file mode 100644 index 0000000..594badf --- /dev/null +++ b/src/common/trace-logger.ts @@ -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); + } +} diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts index 0df2303..ad2a865 100644 --- a/src/filters/http-exception.filter.ts +++ b/src/filters/http-exception.filter.ts @@ -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(); diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/interceptors/logging.interceptor.ts b/src/interceptors/logging.interceptor.ts index e894272..bba2bc8 100644 --- a/src/interceptors/logging.interceptor.ts +++ b/src/interceptors/logging.interceptor.ts @@ -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 { const http = context.switchToHttp(); @@ -18,13 +18,15 @@ export class LoggingInterceptor implements NestInterceptor { const res = http.getResponse(); 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}`); }), ); } diff --git a/src/interceptors/trace.interceptor.ts b/src/interceptors/trace.interceptor.ts new file mode 100644 index 0000000..7c4f3f3 --- /dev/null +++ b/src/interceptors/trace.interceptor.ts @@ -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 { + const req = context.switchToHttp().getRequest(); + const traceId = (req.headers['x-trace-id'] as string | undefined) ?? crypto.randomUUID(); + + return new Observable((subscriber) => { + traceStorage.run({ traceId }, () => { + next.handle().subscribe(subscriber); + }); + }); + } +} diff --git a/src/main.ts b/src/main.ts index 33795ba..33f7dab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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('PORT', 3000); diff --git a/src/scenario/scenario-scheduler.service.ts b/src/scenario/scenario-scheduler.service.ts index 22c118f..ff282fe 100644 --- a/src/scenario/scenario-scheduler.service.ts +++ b/src/scenario/scenario-scheduler.service.ts @@ -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(