From df3b8b85f0bd990aad255004a13e58f9d93211be Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 22:26:37 +1000 Subject: [PATCH 1/4] feat(api): add correlation IDs and structured telemetry - Add telemetry/correlation.ts: generateCorrelationId (crypto.randomUUID) and resolveCorrelationId (8-128 alphanumeric/hyphen/underscore validation) - Add telemetry/logger.ts: createLogger factory, per-request JSON logger with injectable write function; imports PostKitErrorCode from post-kit-types - Add telemetry/index.ts: re-exports both modules - Update functions/contact.ts: resolve correlationId from X-Correlation-Id header, create per-request logger, log received/completed/failed events with durationMs/outcome/errorCode, add X-Correlation-Id response header - Add @singleton-sd/post-kit-types workspace dep to apps/api - Update apps/api build/test scripts to build post-kit-types before api Closes #21 --- apps/api/package.json | 7 +- apps/api/src/functions/contact.ts | 26 ++++++- apps/api/src/telemetry/correlation.spec.ts | 64 ++++++++++++++++ apps/api/src/telemetry/correlation.ts | 28 +++++++ apps/api/src/telemetry/index.ts | 3 + apps/api/src/telemetry/logger.spec.ts | 89 ++++++++++++++++++++++ apps/api/src/telemetry/logger.ts | 69 +++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/telemetry/correlation.spec.ts create mode 100644 apps/api/src/telemetry/correlation.ts create mode 100644 apps/api/src/telemetry/index.ts create mode 100644 apps/api/src/telemetry/logger.spec.ts create mode 100644 apps/api/src/telemetry/logger.ts diff --git a/apps/api/package.json b/apps/api/package.json index b1b236b..b54a340 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,9 +4,9 @@ "private": true, "main": "dist/index.js", "scripts": { - "build": "pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", + "build": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", "lint": "echo \"lint:api — covered by root eslint on staged files\"", - "test": "pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", + "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", "start": "func start" }, "dependencies": { @@ -14,7 +14,8 @@ "@azure/functions": "^4.6.0", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.11.2", - "@singleton-sd/post-kit-email": "workspace:*" + "@singleton-sd/post-kit-email": "workspace:*", + "@singleton-sd/post-kit-types": "workspace:^" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index 84f35d9..a016e8a 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -3,11 +3,18 @@ import { EmailProviderError } from '@singleton-sd/post-kit-email'; import { ensureAppConfiguration } from '../config/app-configuration'; import { contactCorsHeaders, submitContactInquiry } from '../contact'; import { clientIpFromHeaders, getContactRateLimiter } from '../contact-rate-limit'; +import { createLogger, resolveCorrelationId } from '../telemetry'; export async function contactHandler( request: HttpRequest, context: InvocationContext, ): Promise { + const startMs = Date.now(); + const correlationId = resolveCorrelationId(request.headers.get('x-correlation-id') ?? undefined); + const logger = createLogger(correlationId); + + logger.info('contact.request.received'); + const origin = request.headers.get('origin'); try { await ensureAppConfiguration(); @@ -20,6 +27,7 @@ export async function contactHandler( headers: { ...contactCorsHeaders(origin), 'Content-Type': 'application/json', + 'X-Correlation-Id': correlationId, }, jsonBody: { error: 'Contact delivery is temporarily unavailable. Please try again later.', @@ -29,7 +37,7 @@ export async function contactHandler( const cors = contactCorsHeaders(origin); if (request.method === 'OPTIONS') { - return { status: 204, headers: cors }; + return { status: 204, headers: { ...cors, 'X-Correlation-Id': correlationId } }; } const ip = clientIpFromHeaders(request.headers); @@ -41,6 +49,7 @@ export async function contactHandler( ...cors, 'Content-Type': 'application/json', 'Retry-After': String(limit.retryAfterSec), + 'X-Correlation-Id': correlationId, }, jsonBody: { error: 'Too many messages were sent. Please wait a minute and try again.' }, }; @@ -49,12 +58,19 @@ export async function contactHandler( try { const body = await request.json().catch(() => null); const result = await submitContactInquiry(body, { requestOrigin: origin }); + const durationMs = Date.now() - startMs; + logger.info('contact.request.completed', { outcome: 'sent', durationMs }); return { status: 202, - headers: { ...cors, 'Content-Type': 'application/json' }, + headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId }, jsonBody: result, }; } catch (error) { + const durationMs = Date.now() - startMs; + + const errorCode = + error instanceof EmailProviderError ? error.kind : (error as Error | undefined)?.name; + context.error('contact failed', { name: error instanceof Error ? error.name : 'Error', kind: error instanceof EmailProviderError ? error.kind : undefined, @@ -62,6 +78,8 @@ export async function contactHandler( correlationId: error instanceof EmailProviderError ? error.correlationId : undefined, }); + logger.error('contact.request.failed', { outcome: 'failed', errorCode, durationMs }); + const statusFromValidation = error instanceof Error && 'status' in error ? Number((error as Error & { status?: number }).status) @@ -69,7 +87,7 @@ export async function contactHandler( if (statusFromValidation === 400) { return { status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, + headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId }, jsonBody: { error: error instanceof Error ? error.message : 'Invalid request' }, }; } @@ -80,7 +98,7 @@ export async function contactHandler( return { status: unavailable ? 503 : 500, - headers: { ...cors, 'Content-Type': 'application/json' }, + headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId }, jsonBody: { error: unavailable ? 'Contact delivery is temporarily unavailable. Please try again later.' diff --git a/apps/api/src/telemetry/correlation.spec.ts b/apps/api/src/telemetry/correlation.spec.ts new file mode 100644 index 0000000..8e6ca54 --- /dev/null +++ b/apps/api/src/telemetry/correlation.spec.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { generateCorrelationId, resolveCorrelationId } from './correlation'; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('generateCorrelationId', () => { + it('returns a non-empty string matching UUID v4 pattern', () => { + const id = generateCorrelationId(); + assert.ok(id.length > 0, 'should be non-empty'); + assert.match(id, UUID_V4); + }); + + it('returns a different value on each call', () => { + assert.notEqual(generateCorrelationId(), generateCorrelationId()); + }); +}); + +describe('resolveCorrelationId', () => { + it('generates a fresh ID when headerValue is undefined', () => { + const id = resolveCorrelationId(undefined); + assert.ok(id.length > 0); + assert.match(id, UUID_V4); + }); + + it('returns a valid header value as-is', () => { + assert.equal(resolveCorrelationId('valid-id-123'), 'valid-id-123'); + }); + + it('accepts UUIDs as valid header values', () => { + const uuid = generateCorrelationId(); + assert.equal(resolveCorrelationId(uuid), uuid); + }); + + it('sanitises a value containing HTML/script injection characters', () => { + const id = resolveCorrelationId(''); + assert.notEqual(id, ''); + assert.match(id, UUID_V4); + }); + + it('generates a fresh ID when headerValue exceeds 128 chars', () => { + const id = resolveCorrelationId('a'.repeat(200)); + assert.match(id, UUID_V4); + }); + + it('generates a fresh ID when headerValue is an empty string', () => { + const id = resolveCorrelationId(''); + assert.match(id, UUID_V4); + }); + + it('generates a fresh ID when headerValue is fewer than 8 chars', () => { + const id = resolveCorrelationId('short'); + assert.match(id, UUID_V4); + }); + + it('accepts an ID at the minimum boundary (8 chars)', () => { + assert.equal(resolveCorrelationId('abcd1234'), 'abcd1234'); + }); + + it('accepts an ID at the maximum boundary (128 chars)', () => { + const max = 'a'.repeat(128); + assert.equal(resolveCorrelationId(max), max); + }); +}); diff --git a/apps/api/src/telemetry/correlation.ts b/apps/api/src/telemetry/correlation.ts new file mode 100644 index 0000000..5466ba2 --- /dev/null +++ b/apps/api/src/telemetry/correlation.ts @@ -0,0 +1,28 @@ +/** + * Correlation ID utilities for per-request tracing. + * + * No external UUID library — Node 20 ships crypto.randomUUID() natively. + */ + +/** Valid correlation ID: 8–128 alphanumeric / hyphen / underscore characters. */ +const VALID_CORRELATION_ID = /^[a-zA-Z0-9_-]{8,128}$/; + +/** + * Generate a new UUID v4 correlation ID. + */ +export function generateCorrelationId(): string { + return crypto.randomUUID(); +} + +/** + * Sanitise a caller-supplied X-Correlation-Id header value. + * + * Rules: must be 8–128 alphanumeric/hyphen/underscore characters only. + * If the value is missing, empty, or fails validation, a fresh ID is generated. + */ +export function resolveCorrelationId(headerValue: string | undefined): string { + if (headerValue && VALID_CORRELATION_ID.test(headerValue)) { + return headerValue; + } + return generateCorrelationId(); +} diff --git a/apps/api/src/telemetry/index.ts b/apps/api/src/telemetry/index.ts new file mode 100644 index 0000000..82b071e --- /dev/null +++ b/apps/api/src/telemetry/index.ts @@ -0,0 +1,3 @@ +export { generateCorrelationId, resolveCorrelationId } from './correlation'; +export { createLogger } from './logger'; +export type { LogEntry, Logger } from './logger'; diff --git a/apps/api/src/telemetry/logger.spec.ts b/apps/api/src/telemetry/logger.spec.ts new file mode 100644 index 0000000..e1cbdda --- /dev/null +++ b/apps/api/src/telemetry/logger.spec.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { PostKitErrorCode } from '@singleton-sd/post-kit-types'; +import { createLogger } from './logger'; + +describe('createLogger', () => { + it('info() emits JSON containing msg and correlationId', () => { + const lines: string[] = []; + const logger = createLogger('corr-1', (line) => lines.push(line)); + + logger.info('test.event'); + + assert.equal(lines.length, 1); + const entry = JSON.parse(lines[0]!); + assert.equal(entry.msg, 'test.event'); + assert.equal(entry.correlationId, 'corr-1'); + assert.equal(entry.level, 'info'); + }); + + it('error() includes errorCode in output', () => { + const lines: string[] = []; + const logger = createLogger('corr-2', (line) => lines.push(line)); + + logger.error('contact.request.failed', { + outcome: 'failed', + errorCode: PostKitErrorCode.PROVIDER_FAILURE, + }); + + const entry = JSON.parse(lines[0]!); + assert.equal(entry.level, 'error'); + assert.equal(entry.errorCode, PostKitErrorCode.PROVIDER_FAILURE); + assert.equal(entry.outcome, 'failed'); + assert.equal(entry.correlationId, 'corr-2'); + }); + + it('injected write function receives each log line', () => { + const lines: string[] = []; + const logger = createLogger('corr-3', (line) => lines.push(line)); + + logger.info('first'); + logger.info('second'); + logger.error('third'); + + assert.equal(lines.length, 3); + assert.equal(JSON.parse(lines[0]!).msg, 'first'); + assert.equal(JSON.parse(lines[1]!).msg, 'second'); + assert.equal(JSON.parse(lines[2]!).msg, 'third'); + }); + + it('partial fields are included in the output', () => { + const lines: string[] = []; + const logger = createLogger('corr-4', (line) => lines.push(line)); + + logger.info('contact.request.completed', { outcome: 'sent', durationMs: 42 }); + + const entry = JSON.parse(lines[0]!); + assert.equal(entry.outcome, 'sent'); + assert.equal(entry.durationMs, 42); + }); + + it('missing (undefined) fields are omitted from the JSON output', () => { + const lines: string[] = []; + const logger = createLogger('corr-5', (line) => lines.push(line)); + + logger.info('contact.request.received', { tenantId: undefined, outcome: 'sent' }); + + const entry = JSON.parse(lines[0]!); + assert.ok(!('tenantId' in entry), 'tenantId should be omitted when undefined'); + assert.equal(entry.outcome, 'sent'); + }); + + it('correlationId in fields is not duplicated / overwritten', () => { + const lines: string[] = []; + const logger = createLogger('corr-6', (line) => lines.push(line)); + + // Even if caller passes correlationId in fields, the logger's own id wins + logger.info('event', { correlationId: 'different' }); + + const entry = JSON.parse(lines[0]!); + assert.equal(entry.correlationId, 'corr-6'); + }); + + it('falls back to console.log when no write function supplied (smoke test)', () => { + // Just verify no exception is thrown when using the default writer + const logger = createLogger('corr-default'); + assert.doesNotThrow(() => logger.info('smoke')); + assert.doesNotThrow(() => logger.error('smoke')); + }); +}); diff --git a/apps/api/src/telemetry/logger.ts b/apps/api/src/telemetry/logger.ts new file mode 100644 index 0000000..85a75fa --- /dev/null +++ b/apps/api/src/telemetry/logger.ts @@ -0,0 +1,69 @@ +/** + * Structured JSON logger for per-request telemetry. + * + * Design notes: + * - No external logging library; emits newline-delimited JSON to the injected + * write function (default: console.log, suitable for Azure Functions). + * - Logger instances are per-request — never use as a singleton. + * - Never log PII: no recipient addresses, variable values, or tokens. + */ + +import type { PostKitErrorCode } from '@singleton-sd/post-kit-types'; + +/** + * Structured fields that may appear in a log entry. + * All fields are optional except correlationId (carried by the logger instance). + */ +export interface LogEntry { + correlationId: string; + tenantId?: string; + environment?: string; + templateKey?: string; + outcome?: 'sent' | 'failed' | 'validation_error' | 'auth_error'; + durationMs?: number; + providerMessageId?: string; + errorCode?: PostKitErrorCode | string; + // NOTE: never log recipient addresses, variable values, or tokens +} + +/** Minimal logger interface exposed to callers. */ +export interface Logger { + info(msg: string, fields?: Partial): void; + error(msg: string, fields?: Partial): void; +} + +/** + * Create a per-request logger bound to a correlation ID. + * + * @param correlationId - The correlation ID for this request. + * @param write - Optional write function; defaults to console.log. + * Tests inject a capture function here. + */ +export function createLogger( + correlationId: string, + write: (line: string) => void = console.log, +): Logger { + function emit(level: 'info' | 'error', msg: string, fields?: Partial): void { + // Build the entry: correlationId always present; omit undefined/missing fields. + const entry: Record = { level, msg, correlationId }; + + if (fields) { + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && key !== 'correlationId') { + entry[key] = value; + } + } + } + + write(JSON.stringify(entry)); + } + + return { + info(msg, fields) { + emit('info', msg, fields); + }, + error(msg, fields) { + emit('error', msg, fields); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b6d5d2..a5e9564 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@singleton-sd/post-kit-email': specifier: workspace:* version: link:../../packages/post-kit-email + '@singleton-sd/post-kit-types': + specifier: workspace:^ + version: link:../../packages/post-kit-types devDependencies: '@types/node': specifier: ^22.10.2 From 6bf9b3d9741ce39e9196d122b11876b2dbc43c4d Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 23 Aug 2026 22:36:56 +1000 Subject: [PATCH 2/4] fixup: address CodeRabbit inline feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logger: emit only known LogEntry contract keys (not arbitrary enumerable properties) — iterate LOG_ENTRY_KEYS allowlist instead of Object.entries - contact handler: log logger.error for 503 config-error and 429 rate-limit terminal branches with stable errorCode values ('configuration', 'rate_limit') - package.json test script: remove redundant pre-build steps; pnpm build already builds dependencies transitively --- apps/api/package.json | 2 +- apps/api/src/functions/contact.ts | 18 +++++++++++++++++- apps/api/src/telemetry/logger.ts | 19 ++++++++++++++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index b54a340..3f1e162 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", "lint": "echo \"lint:api — covered by root eslint on staged files\"", - "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", + "test": "pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", "start": "func start" }, "dependencies": { diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index a016e8a..bc4e0b5 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -19,9 +19,15 @@ export async function contactHandler( try { await ensureAppConfiguration(); } catch (error) { + const durationMs = Date.now() - startMs; context.error('app configuration load failed', { name: error instanceof Error ? error.name : 'Error', }); + logger.error('contact.request.failed', { + outcome: 'failed', + errorCode: 'configuration', + durationMs, + }); return { status: 503, headers: { @@ -43,6 +49,12 @@ export async function contactHandler( const ip = clientIpFromHeaders(request.headers); const limit = getContactRateLimiter().tryConsume(ip); if (!limit.allowed) { + const durationMs = Date.now() - startMs; + logger.error('contact.request.failed', { + outcome: 'failed', + errorCode: 'rate_limit', + durationMs, + }); return { status: 429, headers: { @@ -87,7 +99,11 @@ export async function contactHandler( if (statusFromValidation === 400) { return { status: 400, - headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId }, + headers: { + ...cors, + 'Content-Type': 'application/json', + 'X-Correlation-Id': correlationId, + }, jsonBody: { error: error instanceof Error ? error.message : 'Invalid request' }, }; } diff --git a/apps/api/src/telemetry/logger.ts b/apps/api/src/telemetry/logger.ts index 85a75fa..1ff7f35 100644 --- a/apps/api/src/telemetry/logger.ts +++ b/apps/api/src/telemetry/logger.ts @@ -26,6 +26,17 @@ export interface LogEntry { // NOTE: never log recipient addresses, variable values, or tokens } +/** The explicit set of optional LogEntry keys (excludes correlationId which is always set). */ +const LOG_ENTRY_KEYS: ReadonlyArray> = [ + 'tenantId', + 'environment', + 'templateKey', + 'outcome', + 'durationMs', + 'providerMessageId', + 'errorCode', +]; + /** Minimal logger interface exposed to callers. */ export interface Logger { info(msg: string, fields?: Partial): void; @@ -44,12 +55,14 @@ export function createLogger( write: (line: string) => void = console.log, ): Logger { function emit(level: 'info' | 'error', msg: string, fields?: Partial): void { - // Build the entry: correlationId always present; omit undefined/missing fields. + // Build the entry: only emit the known LogEntry contract keys to avoid + // leaking arbitrary properties. correlationId is always present. const entry: Record = { level, msg, correlationId }; if (fields) { - for (const [key, value] of Object.entries(fields)) { - if (value !== undefined && key !== 'correlationId') { + for (const key of LOG_ENTRY_KEYS) { + const value = fields[key]; + if (value !== undefined) { entry[key] = value; } } From 9638a0dfd2e386c5d9735af64af806328678a8e4 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Mon, 24 Aug 2026 23:42:11 +1000 Subject: [PATCH 3/4] fix(api): classify validation errors before emitting telemetry in contact handler --- apps/api/src/functions/contact.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index bc4e0b5..f31ab56 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -83,6 +83,11 @@ export async function contactHandler( const errorCode = error instanceof EmailProviderError ? error.kind : (error as Error | undefined)?.name; + const statusFromValidation = + error instanceof Error && 'status' in error + ? Number((error as Error & { status?: number }).status) + : undefined; + context.error('contact failed', { name: error instanceof Error ? error.name : 'Error', kind: error instanceof EmailProviderError ? error.kind : undefined, @@ -90,12 +95,12 @@ export async function contactHandler( correlationId: error instanceof EmailProviderError ? error.correlationId : undefined, }); - logger.error('contact.request.failed', { outcome: 'failed', errorCode, durationMs }); + logger.error('contact.request.failed', { + outcome: statusFromValidation === 400 ? 'validation_error' : 'failed', + errorCode, + durationMs, + }); - const statusFromValidation = - error instanceof Error && 'status' in error - ? Number((error as Error & { status?: number }).status) - : undefined; if (statusFromValidation === 400) { return { status: 400, From 86a1d1346f3c78901390142d8f909c7c5475327c Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Mon, 24 Aug 2026 23:56:18 +1000 Subject: [PATCH 4/4] fix(api): put request correlation IDs on contact error bodies Keep native error logs on the request ID and omit extra logger fields so error responses stay traceable without leaking PII. Co-authored-by: Cursor --- apps/api/src/functions/contact.spec.ts | 77 ++++++++++++++++++++++++++ apps/api/src/functions/contact.ts | 15 ++++- apps/api/src/telemetry/logger.spec.ts | 16 ++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/functions/contact.spec.ts diff --git a/apps/api/src/functions/contact.spec.ts b/apps/api/src/functions/contact.spec.ts new file mode 100644 index 0000000..f4885cd --- /dev/null +++ b/apps/api/src/functions/contact.spec.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { HttpRequest, InvocationContext } from '@azure/functions'; +import { getContactRateLimiter } from '../contact-rate-limit'; +import { contactHandler } from './contact'; + +function fakeRequest(options: { + method?: string; + headers?: Record; + json?: unknown; +}): HttpRequest { + const headers = new Headers(options.headers); + return { + method: options.method ?? 'POST', + headers: { + get: (name: string) => headers.get(name), + }, + json: async () => options.json ?? null, + } as unknown as HttpRequest; +} + +function fakeContext(): InvocationContext & { errors: unknown[] } { + const errors: unknown[] = []; + return { + error: (...args: unknown[]) => { + errors.push(args); + }, + errors, + } as unknown as InvocationContext & { errors: unknown[] }; +} + +describe('contactHandler', () => { + it('propagates a valid X-Correlation-Id on validation errors and includes it in the body', async () => { + getContactRateLimiter().reset(); + const correlationId = 'client-corr-id-01'; + const response = await contactHandler( + fakeRequest({ + headers: { 'x-correlation-id': correlationId }, + json: { name: 'bad' }, + }), + fakeContext(), + ); + + assert.equal(response.status, 400); + assert.equal(response.headers?.['X-Correlation-Id'], correlationId); + assert.equal((response.jsonBody as { correlationId?: string }).correlationId, correlationId); + assert.equal(typeof (response.jsonBody as { error?: string }).error, 'string'); + }); + + it('generates a correlation ID when the header is absent', async () => { + getContactRateLimiter().reset(); + const response = await contactHandler(fakeRequest({ json: { name: 'bad' } }), fakeContext()); + + assert.equal(response.status, 400); + const headerId = response.headers?.['X-Correlation-Id']; + assert.equal(typeof headerId, 'string'); + assert.match(headerId ?? '', /^[0-9a-f-]{36}$/i); + assert.equal((response.jsonBody as { correlationId?: string }).correlationId, headerId); + }); + + it('logs the request correlation ID on native error output, not the provider ID', async () => { + getContactRateLimiter().reset(); + const correlationId = 'handler-corr-id-01'; + const context = fakeContext(); + await contactHandler( + fakeRequest({ + headers: { 'x-correlation-id': correlationId }, + json: { name: 'bad' }, + }), + context, + ); + + const logged = context.errors[0] as [string, { correlationId?: string }]; + assert.equal(logged[1]?.correlationId, correlationId); + assert.ok(!('emailCorrelationId' in (logged[1] ?? {}) && logged[1].emailCorrelationId)); + }); +}); diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index f31ab56..4eeaa5f 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -37,6 +37,7 @@ export async function contactHandler( }, jsonBody: { error: 'Contact delivery is temporarily unavailable. Please try again later.', + correlationId, }, }; } @@ -63,7 +64,10 @@ export async function contactHandler( 'Retry-After': String(limit.retryAfterSec), 'X-Correlation-Id': correlationId, }, - jsonBody: { error: 'Too many messages were sent. Please wait a minute and try again.' }, + jsonBody: { + error: 'Too many messages were sent. Please wait a minute and try again.', + correlationId, + }, }; } @@ -92,7 +96,8 @@ export async function contactHandler( name: error instanceof Error ? error.name : 'Error', kind: error instanceof EmailProviderError ? error.kind : undefined, statusCode: error instanceof EmailProviderError ? error.statusCode : undefined, - correlationId: error instanceof EmailProviderError ? error.correlationId : undefined, + correlationId, + emailCorrelationId: error instanceof EmailProviderError ? error.correlationId : undefined, }); logger.error('contact.request.failed', { @@ -109,7 +114,10 @@ export async function contactHandler( 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId, }, - jsonBody: { error: error instanceof Error ? error.message : 'Invalid request' }, + jsonBody: { + error: error instanceof Error ? error.message : 'Invalid request', + correlationId, + }, }; } @@ -124,6 +132,7 @@ export async function contactHandler( error: unavailable ? 'Contact delivery is temporarily unavailable. Please try again later.' : 'We could not send your message. Please try again shortly.', + correlationId, }, }; } diff --git a/apps/api/src/telemetry/logger.spec.ts b/apps/api/src/telemetry/logger.spec.ts index e1cbdda..3bb31d8 100644 --- a/apps/api/src/telemetry/logger.spec.ts +++ b/apps/api/src/telemetry/logger.spec.ts @@ -69,6 +69,22 @@ describe('createLogger', () => { assert.equal(entry.outcome, 'sent'); }); + it('omits unsupported extra properties from the serialized entry', () => { + const lines: string[] = []; + const logger = createLogger('corr-extra', (line) => lines.push(line)); + + logger.info('event', { + outcome: 'sent', + recipientEmail: 'user@example.com', + templateVariables: { name: 'Ada' }, + } as Parameters[1]); + + const entry = JSON.parse(lines[0]!); + assert.equal(entry.outcome, 'sent'); + assert.ok(!('recipientEmail' in entry)); + assert.ok(!('templateVariables' in entry)); + }); + it('correlationId in fields is not duplicated / overwritten', () => { const lines: string[] = []; const logger = createLogger('corr-6', (line) => lines.push(line));