Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions backend/src/common/filters/sentry-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -32,6 +33,7 @@ describe('SentryExceptionFilter', () => {

beforeEach(() => {
jest.clearAllMocks();
enableRequestContextLogging();
sentryService = {
captureException: jest.fn().mockReturnValue('evt-id'),
isInitialized: jest.fn().mockReturnValue(true),
Expand Down Expand Up @@ -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();
});
});
6 changes: 5 additions & 1 deletion backend/src/common/filters/sentry-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -22,6 +23,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = getRequestId(request);

let status: number;
let message: string;
Expand All @@ -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),
);
}
Expand Down
92 changes: 92 additions & 0 deletions backend/src/common/logging/request-context.ts
Original file line number Diff line number Diff line change
@@ -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<RequestContextValue>();

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<Request>): 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<T>(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;
}
7 changes: 7 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down