End-to-end correlation ID propagation has been successfully implemented across HTTP middleware, WebSocket hub, and webhook dispatcher. All correlation IDs are:
- Generated as UUID v4 (or reused from request headers)
- Extracted and validated on request entry
- Stored in async-local storage for propagation across async boundaries
- Echoed in response headers
- Attached to WebSocket event metadata
- Included in outgoing webhook request headers
1. Middleware Layer: src/middleware/correlationId.ts
Exports:
CORRELATION_ID_HEADER = 'x-correlation-id'— canonical header nameisValidCorrelationId(value: string): boolean— UUID v4 regex validationcorrelationIdMiddleware()— Express middleware
Behavior:
// If incoming request has x-correlation-id header with valid UUID v4, reuse it
// Otherwise, generate new UUID v4 via crypto.randomUUID()
// Attach to req.correlationId
// Echo in x-correlation-id response header
// Propagate through correlationStore for async accessRegistration: Registered first in middleware stack (src/app.ts)
2. Async Context Propagation: src/tracing/middleware.ts
Exports:
correlationStore: AsyncLocalStorage<string>— Node.js built-ingetCorrelationId(): string— retrieve current context value or 'unknown'
Pattern:
// In correlationIdMiddleware:
correlationStore.run(correlationId, () => {
req.correlationId = correlationId;
res.setHeader(CORRELATION_ID_HEADER, correlationId);
next(); // All downstream callbacks inherit this context
});
// Anywhere in the async call chain:
const cid = getCorrelationId(); // Returns the active valueIsolation: Each request maintains its own correlation ID via AsyncLocalStorage; concurrent requests never cross-contaminate.
3. WebSocket Transport: src/ws/hub.ts
Connection Lifecycle:
// onConnect: extract correlationId from upgrade request headers
private extractCorrelationId(headers: IncomingHttpHeaders): string | undefined {
const incoming = headers[CORRELATION_ID_HEADER];
if (typeof incoming === 'string') {
const trimmed = incoming.trim();
if (trimmed.length > 0 && isValidCorrelationId(trimmed)) {
return trimmed; // Return client's correlation ID
}
}
return undefined; // No valid incoming ID
}
// Store in ClientState for the duration of the connection
this.clients.set(ws, {
id: connectionId,
correlationId, // Stored here
// ...
});
logger.info('WebSocket connected', correlationId, {
event: 'ws_connect',
// ...
});Event Broadcast:
// broadcast() — called when a stream update arrives
async broadcast(event: StreamUpdateEvent): Promise<void> {
// Get the current request's correlation ID
const correlationId = getCorrelationId();
// Include it in the outbound stream_update message
const message = JSON.stringify({
type: 'stream_update',
streamId,
eventId,
payload,
correlationId // <<< Propagated to client
});
// Send to all subscribed clients
// ...
}Client State Tracking:
interface ClientState {
id: string;
correlationId?: string; // From upgrade request
metrics: ConnectionMetrics;
subscriptions: Set<string>;
// ...
}4. Webhook Dispatcher: src/webhooks/dispatcher.ts
Dispatch Method:
async dispatch(options: WebhookDispatchOptions): Promise<WebhookDispatchResult> {
const { url, secret, payload, deliveryId, eventType, attemptNumber = 1, correlationId } = options;
// Use provided correlationId or fetch from async context
const effectiveCorrelationId = correlationId ?? getCorrelationId();
// Log with correlation ID
logger.info('Dispatching webhook',
effectiveCorrelationId !== 'unknown' ? effectiveCorrelationId : undefined,
{ deliveryId, eventType, attemptNumber, url }
);
// Send request with correlation header
const response = await this.sendRequest(
url, payload, deliveryId, eventType, timestamp, signature,
effectiveCorrelationId
);
// ...
}
private async sendRequest(
url: string,
payload: string,
deliveryId: string,
eventType: string,
timestamp: string,
signature: string,
correlationId?: string, // <<< Receives correlation ID
): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-fluxora-delivery-id': deliveryId,
'x-fluxora-timestamp': timestamp,
'x-fluxora-signature': signature,
'x-fluxora-event': eventType,
'User-Agent': 'Fluxora-Webhook-Dispatcher/2.0',
};
// Attach correlation ID header
if (correlationId && correlationId !== 'unknown') {
headers[CORRELATION_ID_HEADER] = correlationId; // x-correlation-id
}
return fetch(url, {
method: 'POST',
headers,
body: payload,
signal: controller.signal,
});
}5. Webhook Service: src/webhooks/service.ts
Delivery Attempt:
async attemptDelivery(
delivery: WebhookDelivery,
secret: string,
timestamp?: string,
): Promise<void> {
// Fetch correlation ID from async context
const correlationId = getCorrelationId();
logger.info('Attempting webhook delivery',
correlationId !== 'unknown' ? correlationId : undefined,
{ deliveryId: delivery.deliveryId, attempt: attemptNumber, ... }
);
// Send webhook with correlation ID
const response = await this.sendWebhook(
delivery.endpointUrl,
delivery.payload,
delivery.deliveryId,
ts,
signature,
correlationId // <<< Passed to sendWebhook
);
// ...
}
private async sendWebhook(
url: string,
payload: string,
deliveryId: string,
timestamp: string,
signature: string,
correlationId?: string, // <<< Receives correlation ID
): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-fluxora-delivery-id': deliveryId,
'x-fluxora-timestamp': timestamp,
'x-fluxora-signature': signature,
'x-fluxora-event': 'webhook.event',
};
// Attach correlation ID header
if (correlationId && correlationId !== 'unknown') {
headers[CORRELATION_ID_HEADER] = correlationId; // x-correlation-id
}
return fetch(url, {
method: 'POST',
headers,
body: payload,
signal: controller.signal,
});
}Test Coverage: tests/correlationId.test.ts
- ✓ Generates UUID v4 when no header provided
- ✓ Generated IDs match UUID v4 regex pattern
- ✓ Each request gets a unique ID
- ✓ Reuses valid
x-correlation-idheader - ✓ Trims whitespace from header
- ✓ Generates new ID for empty header
- ✓ Generates new ID for whitespace-only header
- ✓ Concurrent requests maintain separate correlation IDs
- ✓ setImmediate callbacks preserve correct context
- ✓ Promise chains inherit parent context
- ✓ No cross-contamination between concurrent tasks
- ✓ WebSocket events carry initiating request's correlation ID
- ✓ WebSocket connection state stores client's correlation ID
- ✓ Webhook dispatcher includes correlation header
- ✓ Webhook service propagates correlation through retries
GET /api/streams HTTP/1.1
x-correlation-id: 550e8400-e29b-41d4-a716-446655440000
HTTP/1.1 200 OK
x-correlation-id: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
GET /ws/streams HTTP/1.1
Upgrade: websocket
Connection: Upgrade
x-correlation-id: 550e8400-e29b-41d4-a716-446655440000
POST /webhook/endpoint HTTP/1.1
Content-Type: application/json
x-correlation-id: 550e8400-e29b-41d4-a716-446655440000
x-fluxora-delivery-id: deliv_12345
x-fluxora-timestamp: 1700000000
x-fluxora-signature: sha256=...
src/middleware/cors.ts includes X-Correlation-ID in allowed headers:
const DEFAULT_ALLOWED_HEADERS = 'Content-Type,Authorization,X-Correlation-ID';This allows CORS requests to include the correlation header.
All structured logs include correlationId field:
{
"timestamp": "2024-01-15T12:00:00Z",
"level": "info",
"message": "Dispatching webhook",
"correlationId": "550e8400-e29b-41d4-a716-446655440000",
"deliveryId": "deliv_abc123",
"eventType": "stream.created"
}Logs can be easily grouped by correlation ID for distributed tracing.
✓ No breaking changes
- Correlation ID generation is automatic; clients don't need to provide one
- Existing requests without a correlation ID header will receive a generated one
- Response header is always present (helpful for troubleshooting)
- WebSocket events now include
correlationIdfield (additive, not breaking) - Webhook headers include
x-correlation-id(additive, not breaking)
Regex pattern validates RFC 4122 UUID v4:
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i- Requires version nibble 1-5 (typically 4 for random UUIDs)
- Requires variant nibble 8, 9, a, or b
// Initialization (in tracing/middleware.ts)
export const correlationStore = new AsyncLocalStorage<string>();
// Propagation (in correlationId middleware)
correlationStore.run(correlationId, () => {
// All code here + any async callbacks inherit correlationId
next();
});
// Retrieval (anywhere in async chain)
getCorrelationId(); // Returns the active value or 'unknown'This is the standard Node.js pattern for async context; no additional libraries needed.
When getCorrelationId() is called outside a request context, it returns the string 'unknown'. This is checked before including in headers:
if (correlationId && correlationId !== 'unknown') {
headers[CORRELATION_ID_HEADER] = correlationId;
}This prevents sending x-correlation-id: unknown headers and allows out-of-band operations to proceed gracefully.
- Middleware registered first in stack
- AsyncLocalStorage properly initialized
- All transport layers (HTTP, WebSocket, Webhooks) include correlation ID
- Tests cover generation, propagation, and concurrency
- Logging includes correlation ID field
- CORS headers allow x-correlation-id
- No breaking changes to existing APIs
- Backward compatible with clients that don't provide header
Ready for merge and deployment.