Skip to content

Commit b98ea09

Browse files
Merge pull request #250 from memplethee-lab/feat/Correlation
feat(security+observability): domain-safe error handling + request correlation
2 parents fb0f716 + 7e100db commit b98ea09

9 files changed

Lines changed: 226 additions & 4 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { GlobalExceptionFilter } from './global-exception.filter';
2+
import { HttpStatus } from '@nestjs/common';
3+
import { runWithCorrelationId } from '../utils/correlation.utils';
4+
5+
describe('GlobalExceptionFilter', () => {
6+
it('adds correlation ID to error response and header', () => {
7+
const filter = new GlobalExceptionFilter();
8+
9+
const req: any = { method: 'GET', url: '/test' };
10+
const responseHeaders: Record<string, string> = {};
11+
const res: any = {
12+
status: (code: number) => {
13+
res.statusCode = code;
14+
return res;
15+
},
16+
json: (body: any) => {
17+
res.body = body;
18+
return res;
19+
},
20+
setHeader: (name: string, value: string) => {
21+
responseHeaders[name.toLowerCase()] = value;
22+
},
23+
getHeader: (name: string) => responseHeaders[name.toLowerCase()],
24+
};
25+
26+
runWithCorrelationId(() => {
27+
filter.catch(new Error('Test error'), {
28+
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
29+
} as any);
30+
}, 'cid-123');
31+
32+
const body = res.body;
33+
34+
expect(res.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
35+
expect(body.correlationId).toBe('cid-123');
36+
expect(body.message).toBe('Test error');
37+
});
38+
});

src/common/interceptors/global-exception.filter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Request, Response } from 'express';
1010
import { QueryFailedError, EntityNotFoundError } from 'typeorm';
1111
import { ApiError, ValidationErrorDetail } from '../../interfaces/api-error.interface';
12+
import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils';
1213

1314
@Catch()
1415
export class GlobalExceptionFilter implements ExceptionFilter {
@@ -22,16 +23,23 @@ export class GlobalExceptionFilter implements ExceptionFilter {
2223

2324
const { statusCode, message, error, details, stack } = this.resolveException(exception);
2425

26+
const correlationId = getCorrelationId();
27+
2528
const errorResponse: ApiError = {
2629
statusCode,
2730
message,
2831
error,
2932
timestamp: new Date().toISOString(),
3033
path: request.url,
34+
correlationId,
3135
...(details?.length && { details }),
3236
...(!this.isProduction && stack && { stack }),
3337
};
3438

39+
if (correlationId) {
40+
response.setHeader(CORRELATION_ID_HEADER, correlationId);
41+
}
42+
3543
this.logger.error(
3644
`[${request.method}] ${request.url}${statusCode} ${error}: ${
3745
Array.isArray(message) ? message.join(', ') : message
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { LoggingInterceptor } from './logging.interceptor';
2+
import { of, firstValueFrom } from 'rxjs';
3+
4+
describe('LoggingInterceptor', () => {
5+
it('attaches and propagates correlation ID header', async () => {
6+
const interceptor = new LoggingInterceptor();
7+
8+
const req: any = { method: 'GET', url: '/spam', headers: {} };
9+
const headers: Record<string, string> = {};
10+
const res: any = {
11+
statusCode: 200,
12+
setHeader: (name: string, value: string) => {
13+
headers[name.toLowerCase()] = value;
14+
},
15+
getHeader: (name: string) => headers[name.toLowerCase()],
16+
};
17+
18+
const context: any = {
19+
getType: () => 'http',
20+
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
21+
};
22+
23+
const next: any = {
24+
handle: () => of({ success: true }),
25+
};
26+
27+
await firstValueFrom(interceptor.intercept(context, next));
28+
29+
const correlationId = res.getHeader('x-request-id');
30+
expect(typeof correlationId).toBe('string');
31+
expect(correlationId).toMatch(/^cid-/);
32+
});
33+
});

src/common/interceptors/logging.interceptor.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } fr
22
import { Observable, throwError } from 'rxjs';
33
import { tap, catchError } from 'rxjs/operators';
44
import { Request, Response } from 'express';
5+
import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils';
56

67
export interface RequestLog {
78
requestId: string;
@@ -49,7 +50,10 @@ export class LoggingInterceptor implements NestInterceptor {
4950
}
5051

5152
const startTime = Date.now();
52-
const requestId = this.generateRequestId();
53+
const requestId = getCorrelationId() || this.generateRequestId();
54+
55+
const response = httpCtx.getResponse<Response>();
56+
response?.setHeader(CORRELATION_ID_HEADER, requestId);
5357

5458
const baseLog: RequestLog = {
5559
requestId,
@@ -67,12 +71,12 @@ export class LoggingInterceptor implements NestInterceptor {
6771

6872
return next.handle().pipe(
6973
tap(() => {
70-
const response = httpCtx.getResponse<Response>();
74+
const res = httpCtx.getResponse<Response>();
7175
this.logOutgoing({
7276
...baseLog,
73-
statusCode: response.statusCode,
77+
statusCode: res.statusCode,
7478
responseTimeMs: Date.now() - startTime,
75-
contentLength: this.getContentLength(response),
79+
contentLength: this.getContentLength(res),
7680
});
7781
}),
7882
catchError((error: unknown) => {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
correlationMiddleware,
3+
getCorrelationId,
4+
injectCorrelationIdToHeaders,
5+
CORRELATION_ID_HEADER,
6+
} from './correlation.utils';
7+
8+
describe('correlation.utils', () => {
9+
it('generates and propagates correlation ID through middleware', (done) => {
10+
const req: any = { method: 'GET', url: '/test', headers: {} };
11+
const headers: Record<string, string> = {};
12+
const res: any = {
13+
setHeader: (name: string, value: string) => {
14+
headers[name.toLowerCase()] = value;
15+
},
16+
getHeader: (name: string) => headers[name.toLowerCase()],
17+
};
18+
19+
correlationMiddleware(req, res, () => {
20+
const id = getCorrelationId();
21+
expect(typeof id).toBe('string');
22+
expect(res.getHeader(CORRELATION_ID_HEADER)).toBe(id);
23+
done();
24+
});
25+
});
26+
27+
it('respects incoming x-request-id header', (done) => {
28+
const incomingId = 'test-correlation-id';
29+
const req: any = { method: 'GET', url: '/test', headers: { 'x-request-id': incomingId } };
30+
const headers: Record<string, string> = {};
31+
const res: any = {
32+
setHeader: (name: string, value: string) => {
33+
headers[name.toLowerCase()] = value;
34+
},
35+
getHeader: (name: string) => headers[name.toLowerCase()],
36+
};
37+
38+
correlationMiddleware(req, res, () => {
39+
expect(getCorrelationId()).toBe(incomingId);
40+
expect(res.getHeader(CORRELATION_ID_HEADER)).toBe(incomingId);
41+
done();
42+
});
43+
});
44+
45+
it('injects correlation header into outgoing request headers', () => {
46+
const custom = injectCorrelationIdToHeaders({ Authorization: 'Bearer token' }, 'cid-1');
47+
expect(custom[CORRELATION_ID_HEADER]).toBe('cid-1');
48+
expect(custom.Authorization).toBe('Bearer token');
49+
});
50+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { AsyncLocalStorage } from 'async_hooks';
2+
import { Request, Response, NextFunction } from 'express';
3+
4+
export const CORRELATION_ID_HEADER = 'x-request-id';
5+
6+
export interface CorrelationContext {
7+
correlationId: string;
8+
}
9+
10+
const correlationStorage = new AsyncLocalStorage<CorrelationContext>();
11+
12+
export function generateCorrelationId(): string {
13+
return `cid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
14+
}
15+
16+
export function getCorrelationId(): string | undefined {
17+
const store = correlationStorage.getStore();
18+
return store?.correlationId;
19+
}
20+
21+
export function setCorrelationId(req: Request, res: Response, correlationId: string): void {
22+
(req as Request & { correlationId?: string }).correlationId = correlationId;
23+
res.setHeader(CORRELATION_ID_HEADER, correlationId);
24+
}
25+
26+
export function correlationMiddleware(req: Request, res: Response, next: NextFunction): void {
27+
const incoming =
28+
(req.headers[CORRELATION_ID_HEADER] as string) || (req.headers['x-correlation-id'] as string);
29+
const correlationId = incoming || generateCorrelationId();
30+
31+
correlationStorage.run({ correlationId }, () => {
32+
setCorrelationId(req, res, correlationId);
33+
next();
34+
});
35+
}
36+
37+
export function runWithCorrelationId<T>(callback: () => T, correlationId?: string): T {
38+
const id = correlationId || generateCorrelationId();
39+
return correlationStorage.run({ correlationId: id }, callback);
40+
}
41+
42+
export function injectCorrelationIdToHeaders(
43+
headers: Record<string, any> = {},
44+
correlationId?: string,
45+
): Record<string, any> {
46+
const id = correlationId || getCorrelationId() || generateCorrelationId();
47+
return {
48+
...headers,
49+
[CORRELATION_ID_HEADER]: id,
50+
};
51+
}

src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import Redis from 'ioredis';
99
import { AppModule } from './app.module';
1010
import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter';
1111
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
12+
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
13+
import { correlationMiddleware } from './common/utils/correlation.utils';
1214
import { sessionConfig } from './config/cache.config';
1315
import { SESSION_REDIS_CLIENT } from './session/session.constants';
1416

@@ -26,6 +28,8 @@ async function bootstrapWorker() {
2628
expressApp.set('trust proxy', 1);
2729
}
2830

31+
app.use(correlationMiddleware);
32+
2933
app.use(
3034
session({
3135
store: new RedisStore({
@@ -50,6 +54,9 @@ async function bootstrapWorker() {
5054
// ─── Global Exception Filter ──────────────────────────────────────────────
5155
app.useGlobalFilters(new GlobalExceptionFilter());
5256

57+
// ─── Global Logging Interceptor ───────────────────────────────────────────
58+
app.useGlobalInterceptors(new LoggingInterceptor());
59+
5360
// ─── Global Response Transform Interceptor ───────────────────────────────
5461
app.useGlobalInterceptors(new ResponseTransformInterceptor());
5562

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ServiceMeshService } from './service-mesh.service';
2+
import { of } from 'rxjs';
3+
4+
describe('ServiceMeshService', () => {
5+
it('propagates correlation ID to external API call headers', async () => {
6+
const serviceDiscovery: any = {
7+
getService: jest.fn().mockResolvedValue({ baseUrl: 'http://localhost' }),
8+
markUnhealthy: jest.fn(),
9+
};
10+
const httpService: any = {
11+
request: jest.fn().mockReturnValue(of({ data: { ok: true } })),
12+
};
13+
14+
const service = new ServiceMeshService(serviceDiscovery, httpService);
15+
16+
await expect(service.request('dummy', '/ping', 'GET')).resolves.toEqual({ ok: true });
17+
18+
expect(httpService.request).toHaveBeenCalledWith(
19+
expect.objectContaining({
20+
headers: expect.objectContaining({ 'x-request-id': expect.any(String) }),
21+
}),
22+
);
23+
});
24+
});

src/orchestration/service-mesh/service-mesh.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { HttpService } from '@nestjs/axios';
33
import { firstValueFrom } from 'rxjs';
44
import { AxiosResponse } from 'axios';
55
import { ServiceDiscoveryService } from '../discovery/service-discovery.service';
6+
import {
7+
injectCorrelationIdToHeaders,
8+
getCorrelationId,
9+
} from '../../common/utils/correlation.utils';
610

711
@Injectable()
812
export class ServiceMeshService {
@@ -20,13 +24,16 @@ export class ServiceMeshService {
2024
const service = await this.discovery.getService(serviceName);
2125
const url = `${service.baseUrl}${path}`;
2226

27+
const correlationId = getCorrelationId();
28+
2329
try {
2430
const response: AxiosResponse<T> = await firstValueFrom(
2531
this.httpService.request<T>({
2632
url,
2733
method,
2834
data,
2935
timeout: 5000,
36+
headers: injectCorrelationIdToHeaders(undefined, correlationId),
3037
}),
3138
);
3239

0 commit comments

Comments
 (0)