Skip to content
Open
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
35 changes: 35 additions & 0 deletions docs/REQUEST_ID_MIDDLEWARE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 🆔 Request ID & Correlation Tracing Middleware

This document specifies the Request ID tracing middleware for NotifyChain's API services (Issue #686).

---

## 1. Overview

To make requests traceable across distributed logs and debugging sessions:
1. Every incoming HTTP request resolves a validated `X-Request-ID`.
2. Client-provided `X-Request-ID` and `X-Correlation-ID` headers are validated against strict alphanumeric/hyphen constraints (`^[a-zA-Z0-9_-]{1,64}$`).
3. If valid, the client-provided ID is preserved; if missing or invalid, a secure unique ID is minted.
4. Both IDs are attached to the request context for logging and echoed in HTTP response headers.

---

## 2. Header Contract

| Header Name | Required | Behavior |
|---|---|---|
| `X-Request-ID` | Optional on Request | Validated client ID or minted UUID, echoed on response |
| `X-Correlation-ID` | Optional on Request | Multi-service trace ID, echoed on response |

---

## 3. Usage & Integration

```typescript
import { applyRequestContext } from '../utils/request-id';

export function handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
const { requestId, correlationId } = applyRequestContext(req, res);
logger.info(`Received request`, { requestId, correlationId, url: req.url });
}
```
69 changes: 69 additions & 0 deletions listener/src/middleware/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { IncomingMessage, ServerResponse } from 'http';
import { attachRequestId, isValidRequestId, resolveRequestId } from './request-id';

describe('Request ID Middleware (Issue #686)', () => {
describe('isValidRequestId', () => {
test('accepts valid UUIDs and alphanumeric tokens', () => {
expect(isValidRequestId('c9bf9e57-1685-4c89-bafb-ff5af830be8a')).toBe(true);
expect(isValidRequestId('req-123456_abc')).toBe(true);
expect(isValidRequestId('simpleid')).toBe(true);
});

test('rejects empty, invalid characters or oversized IDs', () => {
expect(isValidRequestId('')).toBe(false);
expect(isValidRequestId(' ')).toBe(false);
expect(isValidRequestId('req<script>')).toBe(false);
expect(isValidRequestId('req with spaces')).toBe(false);
expect(isValidRequestId('a'.repeat(65))).toBe(false);
});
});

describe('resolveRequestId & attachRequestId', () => {
test('generates a new UUID when client header is missing', () => {
const req = { headers: {} } as unknown as IncomingMessage;
const res = {
headersSent: false,
setHeader: jest.fn(),
} as unknown as ServerResponse;

const reqId = attachRequestId(req, res);

expect(reqId).toBeDefined();
expect(req.headers['x-request-id']).toBe(reqId);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', reqId);
});

test('reuses valid client-supplied request ID', () => {
const clientReqId = 'client-provided-trace-id-123';
const req = {
headers: { 'x-request-id': clientReqId },
} as unknown as IncomingMessage;
const res = {
headersSent: false,
setHeader: jest.fn(),
} as unknown as ServerResponse;

const reqId = attachRequestId(req, res);

expect(reqId).toBe(clientReqId);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', clientReqId);
});

test('replaces invalid client-supplied request ID with secure random UUID', () => {
const invalidClientReqId = 'invalid id with spaces & symbols !@#$';
const req = {
headers: { 'x-request-id': invalidClientReqId },
} as unknown as IncomingMessage;
const res = {
headersSent: false,
setHeader: jest.fn(),
} as unknown as ServerResponse;

const reqId = attachRequestId(req, res);

expect(reqId).not.toBe(invalidClientReqId);
expect(isValidRequestId(reqId)).toBe(true);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', reqId);
});
});
});
60 changes: 60 additions & 0 deletions listener/src/middleware/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Request ID Middleware (Issue #686)
*
* Assigns or validates a unique request identifier (`X-Request-ID`) for every
* incoming HTTP request. Ensures consistent traceability across logs and
* includes the ID in HTTP response headers.
*/

import { IncomingMessage, ServerResponse } from 'http';
import { randomUUID } from 'crypto';

// Regex to validate client-supplied request IDs (alphanumeric, hyphens, underscores, length 1-64)
const VALID_REQUEST_ID_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;

export interface RequestIdOptions {
headerName?: string;
}

/**
* Validates whether a client-provided request ID is safe to reuse.
*/
export function isValidRequestId(id: string | undefined): boolean {
if (!id || typeof id !== 'string') return false;
return VALID_REQUEST_ID_REGEX.test(id.trim());
}

/**
* Extracts or generates a valid request ID from an incoming HTTP request.
*/
export function resolveRequestId(req: IncomingMessage, headerName = 'x-request-id'): string {
const rawHeader = req.headers[headerName.toLowerCase()];
const clientProvided = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;

if (clientProvided && isValidRequestId(clientProvided)) {
return clientProvided.trim();
}

return randomUUID();
}

/**
* Request ID Middleware function for Node.js HTTP servers.
*/
export function attachRequestId(
req: IncomingMessage,
res: ServerResponse,
headerName = 'X-Request-ID'
): string {
const reqId = resolveRequestId(req, headerName);

// Attach to request headers for downstream handler access
req.headers['x-request-id'] = reqId;

// Expose on response header
if (!res.headersSent) {
res.setHeader(headerName, reqId);
}

return reqId;
}
134 changes: 56 additions & 78 deletions listener/src/utils/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,59 @@
import { IncomingMessage, ServerResponse } from 'http';
import { generateRequestId, resolveCorrelationId, applyRequestContext } from './request-id';

function makeReq(headers: Record<string, string | string[] | undefined> = {}): IncomingMessage {
return { headers } as unknown as IncomingMessage;
}

function makeRes(): ServerResponse {
const headers: Record<string, string> = {};
return {
setHeader: (name: string, value: string) => {
headers[name] = value;
},
getHeader: (name: string) => headers[name],
} as unknown as ServerResponse;
}

describe('generateRequestId', () => {
it('generates a short, unique id for each call', () => {
const a = generateRequestId();
const b = generateRequestId();
expect(a).not.toEqual(b);
expect(a.length).toBeGreaterThan(0);
});
});

describe('resolveCorrelationId', () => {
it('reuses an incoming header value', () => {
expect(resolveCorrelationId('abc-123')).toBe('abc-123');
});

it('trims whitespace from an incoming header value', () => {
expect(resolveCorrelationId(' abc-123 ')).toBe('abc-123');
});

it('uses the first value when the header is an array', () => {
expect(resolveCorrelationId(['first', 'second'])).toBe('first');
});

it('generates a new id when no header is present', () => {
const id = resolveCorrelationId(undefined);
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
});

it('generates a new id when the header is blank', () => {
const id = resolveCorrelationId(' ');
expect(id.trim().length).toBeGreaterThan(0);
});
});

describe('applyRequestContext', () => {
it('assigns a fresh requestId to every request', () => {
const res = makeRes();
const { requestId: first } = applyRequestContext(makeReq(), res);
const { requestId: second } = applyRequestContext(makeReq(), makeRes());
expect(first).not.toEqual(second);
});

it('honours an incoming X-Correlation-Id header', () => {
const res = makeRes();
const { correlationId } = applyRequestContext(
makeReq({ 'x-correlation-id': 'caller-supplied-id' }),
res
);
expect(correlationId).toBe('caller-supplied-id');
});

it('generates a correlationId when none is supplied', () => {
const res = makeRes();
const { correlationId } = applyRequestContext(makeReq(), res);
expect(correlationId.length).toBeGreaterThan(0);
});

it('echoes requestId and correlationId back as response headers', () => {
const res = makeRes();
const { requestId, correlationId } = applyRequestContext(makeReq(), res);
expect(res.getHeader('X-Request-Id')).toBe(requestId);
expect(res.getHeader('X-Correlation-Id')).toBe(correlationId);
import {
applyRequestContext,
isValidRequestId,
resolveRequestId,
resolveCorrelationId,
} from './request-id';

describe('Request ID & Correlation ID Utilities (Issue #686)', () => {
describe('isValidRequestId', () => {
test('validates safe alphanumeric and uuid tokens', () => {
expect(isValidRequestId('c9bf9e57-1685-4c89-bafb-ff5af830be8a')).toBe(true);
expect(isValidRequestId('req-123456_abc')).toBe(true);
expect(isValidRequestId('simpleid')).toBe(true);
});

test('rejects unsafe, empty or oversized IDs', () => {
expect(isValidRequestId('')).toBe(false);
expect(isValidRequestId(' ')).toBe(false);
expect(isValidRequestId('req<script>')).toBe(false);
expect(isValidRequestId('req with spaces')).toBe(false);
expect(isValidRequestId('a'.repeat(65))).toBe(false);
});
});

describe('resolveRequestId & resolveCorrelationId', () => {
test('reuses valid client-supplied headers', () => {
expect(resolveRequestId('client-req-001')).toBe('client-req-001');
expect(resolveCorrelationId('corr-id-xyz')).toBe('corr-id-xyz');
});

test('generates fresh IDs when header is missing or invalid', () => {
const generatedReqId = resolveRequestId(undefined);
expect(generatedReqId).toBeDefined();
expect(typeof generatedReqId).toBe('string');

const sanitizedReqId = resolveRequestId('invalid id with spaces');
expect(sanitizedReqId).not.toBe('invalid id with spaces');
expect(isValidRequestId(sanitizedReqId)).toBe(true);
});
});

describe('applyRequestContext', () => {
test('attaches headers to request and response objects', () => {
const req = { headers: { 'x-request-id': 'valid-trace-id' } } as unknown as IncomingMessage;
const res = {
headersSent: false,
setHeader: jest.fn(),
} as unknown as ServerResponse;

const ctx = applyRequestContext(req, res);

expect(ctx.requestId).toBe('valid-trace-id');
expect(req.headers['x-request-id']).toBe('valid-trace-id');
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'valid-trace-id');
expect(res.setHeader).toHaveBeenCalledWith('X-Correlation-ID', expect.any(String));
});
});
});
58 changes: 43 additions & 15 deletions listener/src/utils/request-id.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { randomUUID } from 'crypto';
import type { IncomingMessage, ServerResponse } from 'http';

// Regex to validate client-supplied request/correlation IDs (alphanumeric, hyphens, underscores, length 1-64)
const VALID_ID_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;

/**
* Validates whether a client-provided request/correlation ID is safe to reuse.
*/
export function isValidRequestId(id: string | undefined): boolean {
if (!id || typeof id !== 'string') return false;
return VALID_ID_REGEX.test(id.trim());
}

/**
* Generates a short, unique request identifier for tracing a single poll cycle
* or API request through the notification pipeline.
Expand All @@ -9,38 +20,55 @@ export function generateRequestId(): string {
return randomUUID().split('-')[0];
}

/**
* Resolves a request ID for an incoming request.
* Validates and honours client-supplied X-Request-ID headers, otherwise generates a fresh ID.
*/
export function resolveRequestId(incomingHeader: string | string[] | undefined): string {
const incoming = Array.isArray(incomingHeader) ? incomingHeader[0] : incomingHeader;
if (incoming && isValidRequestId(incoming)) {
return incoming.trim();
}
return generateRequestId();
}

/**
* Resolves a correlation ID for a request.
* Honours an incoming X-Correlation-Id header if present, otherwise generates a new UUID.
* Honours and validates an incoming X-Correlation-Id header if present, otherwise generates a new UUID.
*/
export function resolveCorrelationId(incomingHeader: string | string[] | undefined): string {
const incoming = Array.isArray(incomingHeader) ? incomingHeader[0] : incomingHeader;
return incoming?.trim() || randomUUID();
if (incoming && isValidRequestId(incoming)) {
return incoming.trim();
}
return randomUUID();
}

export interface RequestContext {
/** Short id minted fresh for this single request; never inherited from a caller. */
/** Short id minted or validated for this single request. */
requestId: string;
/** Id used to trace a request across services; honours an inbound X-Correlation-Id header. */
correlationId: string;
}

/**
* Correlation-ID middleware for the events API server.
*
* Every incoming HTTP request is assigned a fresh `requestId`, and a
* `correlationId` is resolved (reusing the caller's `X-Correlation-Id`
* header when present, otherwise minting a new one). Both are echoed back
* as `X-Request-Id` / `X-Correlation-Id` response headers so a caller can
* correlate its request with the server's logs, and both should be passed
* to every `logger` call made while handling that request.
* Request ID & Correlation ID middleware for the events API server.
*
* Call this once per request, before any routing logic runs.
* Every incoming HTTP request resolves a validated `requestId` (reusing a valid
* client-supplied `X-Request-ID` or generating a new one), and a `correlationId`.
* Both are echoed back as `X-Request-ID` / `X-Correlation-ID` response headers.
*/
export function applyRequestContext(req: IncomingMessage, res: ServerResponse): RequestContext {
const requestId = generateRequestId();
const requestId = resolveRequestId(req.headers['x-request-id']);
const correlationId = resolveCorrelationId(req.headers['x-correlation-id']);
res.setHeader('X-Request-Id', requestId);
res.setHeader('X-Correlation-Id', correlationId);

req.headers['x-request-id'] = requestId;
req.headers['x-correlation-id'] = correlationId;

if (!res.headersSent) {
res.setHeader('X-Request-ID', requestId);
res.setHeader('X-Correlation-ID', correlationId);
}

return { requestId, correlationId };
}