diff --git a/backend/src/common/filters/sentry-exception.filter.spec.ts b/backend/src/common/filters/sentry-exception.filter.spec.ts index d3de5b3..4acf76e 100644 --- a/backend/src/common/filters/sentry-exception.filter.spec.ts +++ b/backend/src/common/filters/sentry-exception.filter.spec.ts @@ -1,21 +1,22 @@ -import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; import { SentryExceptionFilter } from './sentry-exception.filter'; import { SentryService } from '../../sentry/sentry.service'; - -jest.mock('@sentry/node', () => ({ - captureException: jest.fn(), - withScope: jest.fn((cb: (scope: unknown) => unknown) => { - const scope = { setTag: jest.fn(), setExtra: jest.fn(), setUser: jest.fn() }; - return cb(scope); - }), -})); - -function buildHost(url = '/test', method = 'GET', ip = '127.0.0.1') { +import { enableRequestContextLogging, runWithRequestContext } from '../logging/request-context'; + +function buildHost( + url = '/test', + method = 'GET', + ip = '127.0.0.1', + requestId?: string, +) { const response = { status: jest.fn().mockReturnThis(), json: jest.fn(), + setHeader: jest.fn(), }; - const request = { url, method, ip }; + const headers = requestId ? { 'x-request-id': requestId } : {}; + const request = { url, method, ip, headers, get: (name: string) => headers[name.toLowerCase()] }; return { switchToHttp: () => ({ getResponse: () => response, @@ -32,6 +33,7 @@ describe('SentryExceptionFilter', () => { beforeEach(() => { jest.clearAllMocks(); + enableRequestContextLogging(); sentryService = { captureException: jest.fn().mockReturnValue('evt-id'), isInitialized: jest.fn().mockReturnValue(true), @@ -117,4 +119,30 @@ describe('SentryExceptionFilter', () => { expect(body).toHaveProperty('statusCode', 500); }); }); + + it('should propagate the request id to the logger and Sentry tags for the same request', () => { + const requestId = 'req-123'; + const setTag = jest.fn(); + const setExtra = jest.fn(); + const setUser = jest.fn(); + const withScopeSpy = jest.spyOn(Sentry, 'withScope' as never).mockImplementation( + ((scopeOrCallback: unknown, callback?: (scope: any) => unknown) => { + const scope = { setTag, setExtra, setUser }; + if (typeof scopeOrCallback === 'function') { + return (scopeOrCallback as (scope: any) => unknown)(scope); + } + return callback ? callback(scope) : undefined; + }) as never, + ); + const logSpy = jest.spyOn(Logger.prototype, 'error'); + + runWithRequestContext(requestId, () => { + filter.catch(new Error('crash'), buildHost('/api/auth/login', 'POST', '127.0.0.1', requestId)); + }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`requestId=${requestId}`), expect.any(String)); + expect(setTag).toHaveBeenCalledWith('request_id', requestId); + expect(setTag).toHaveBeenCalledWith('correlation_id', requestId); + expect(withScopeSpy).toHaveBeenCalled(); + }); }); diff --git a/backend/src/common/filters/sentry-exception.filter.ts b/backend/src/common/filters/sentry-exception.filter.ts index c3d02ff..a977052 100644 --- a/backend/src/common/filters/sentry-exception.filter.ts +++ b/backend/src/common/filters/sentry-exception.filter.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { getRequestId } from '../logging/request-context'; import { SentryService } from '../../sentry/sentry.service'; @Injectable() @@ -22,6 +23,7 @@ export class SentryExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); + const requestId = getRequestId(request); let status: number; let message: string; @@ -44,12 +46,14 @@ export class SentryExceptionFilter implements ExceptionFilter { Sentry.withScope(scope => { scope.setTag('url', request.url); scope.setTag('method', request.method); + scope.setTag('request_id', requestId); + scope.setTag('correlation_id', requestId); scope.setExtra('statusCode', status); scope.setUser({ ip_address: request.ip }); this.sentryService.captureException(exception, 'SentryExceptionFilter'); }); this.logger.error( - `[${request.method}] ${request.url} — ${status}`, + `[${request.method}] ${request.url} — ${status} — requestId=${requestId}`, exception instanceof Error ? exception.stack : String(exception), ); } diff --git a/backend/src/common/logging/request-context.ts b/backend/src/common/logging/request-context.ts new file mode 100644 index 0000000..72d6222 --- /dev/null +++ b/backend/src/common/logging/request-context.ts @@ -0,0 +1,92 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; +import { Logger } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + id?: string; + } + } +} + +interface RequestContextValue { + requestId: string; + request?: Request; +} + +export const requestContextStorage = new AsyncLocalStorage(); + +const REQUEST_ID_HEADER = 'x-request-id'; +const REQUEST_ID_LOG_PATTERN = /requestId=/i; + +function getHeaderValue(header: string | string[] | undefined): string | undefined { + if (Array.isArray(header)) { + return header[0]; + } + + return header; +} + +export function getRequestId(request?: Partial): string { + if (!request) { + return requestContextStorage.getStore()?.requestId ?? `req_${randomUUID()}`; + } + + const headerValue = getHeaderValue( + typeof request.get === 'function' ? request.get(REQUEST_ID_HEADER) : request.headers?.[REQUEST_ID_HEADER], + ); + + if (headerValue) { + return headerValue; + } + + return requestContextStorage.getStore()?.requestId ?? `req_${randomUUID()}`; +} + +export function runWithRequestContext(requestId: string, callback: () => T): T { + return requestContextStorage.run({ requestId }, callback); +} + +export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { + const incomingRequestId = getHeaderValue( + typeof req.get === 'function' ? req.get(REQUEST_ID_HEADER) : req.headers?.[REQUEST_ID_HEADER], + ); + const requestId = incomingRequestId ?? `req_${randomUUID()}`; + + req.headers[REQUEST_ID_HEADER] = requestId; + req.id = requestId; + res.setHeader('X-Request-Id', requestId); + + requestContextStorage.run({ requestId, request: req }, () => next()); +} + +export function enableRequestContextLogging(): void { + const loggerPrototype = Logger.prototype as Logger & { + __requestContextLoggingEnabled?: boolean; + }; + + if (loggerPrototype.__requestContextLoggingEnabled) { + return; + } + + const methods = ['log', 'error', 'warn', 'debug', 'verbose'] as const; + + for (const methodName of methods) { + const originalMethod = Logger.prototype[methodName] as (...args: unknown[]) => void; + + Logger.prototype[methodName] = function (...args: unknown[]) { + const requestId = requestContextStorage.getStore()?.requestId; + const [firstArg] = args; + + if (typeof firstArg === 'string' && requestId && !REQUEST_ID_LOG_PATTERN.test(firstArg)) { + args[0] = `[requestId=${requestId}] ${firstArg}`; + } + + return originalMethod.apply(this, args); + }; + } + + loggerPrototype.__requestContextLoggingEnabled = true; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 337bdec..138c3bb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -5,6 +5,10 @@ import * as Sentry from '@sentry/node'; import { AppModule } from './app.module'; import { SentryService } from './sentry/sentry.service'; import { SentryExceptionFilter } from './common/filters/sentry-exception.filter'; +import { + enableRequestContextLogging, + requestIdMiddleware, +} from './common/logging/request-context'; import { SorobanEventIndexerService } from './soroban-event-indexer/soroban-event-indexer.service'; import { MetricsHttpInterceptor } from './monitoring/metrics-http.interceptor'; @@ -27,7 +31,10 @@ process.on('uncaughtException', (error: Error) => { }); async function bootstrap() { + enableRequestContextLogging(); + const app = await NestFactory.create(AppModule); + app.use(requestIdMiddleware); // Initialize Sentry via the injectable service so it shares the same instance const sentryService = app.get(SentryService);