diff --git a/docs/STRUCTURED_JSON_LOGGING.md b/docs/STRUCTURED_JSON_LOGGING.md new file mode 100644 index 00000000..78795518 --- /dev/null +++ b/docs/STRUCTURED_JSON_LOGGING.md @@ -0,0 +1,45 @@ +# 📊 Structured JSON Logging Configuration + +This document details the machine-readable JSON logging format supported across NotifyChain (Issue #685). + +--- + +## 1. Enabling Structured JSON Logging + +Structured JSON logging can be activated via environment variables: + +```bash +# Enable explicitly +STRUCTURED_LOGGING=true + +# Alternative format selector +LOG_FORMAT=json +``` + +*(Note: Structured JSON format is automatically active by default in `NODE_ENV=production`)*. + +--- + +## 2. Standard JSON Schema + +Each log record is emitted as a single newline-delimited JSON (NDJSON) string: + +```json +{ + "timestamp": "2026-08-29T12:00:00.000Z", + "level": "info", + "message": "Notification dispatched to Discord endpoint", + "service": "notify-chain", + "environment": "production", + "requestId": "req-9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "durationMs": 45.2, + "provider": "Discord", + "notificationId": "notif-12345" +} +``` + +--- + +## 3. Redaction & Invariant Security + +All metadata fields and nested error structures are continuously filtered through the centralized redaction engine (`listener/src/utils/redact.ts`) before serialization, ensuring private keys, webhook tokens, and credentials are never ingested into log aggregators. diff --git a/listener/src/utils/json-logger-formatter.test.ts b/listener/src/utils/json-logger-formatter.test.ts new file mode 100644 index 00000000..ba359948 --- /dev/null +++ b/listener/src/utils/json-logger-formatter.test.ts @@ -0,0 +1,72 @@ +import { + createStructuredJsonFormat, + isJsonLoggingEnabled, +} from './json-logger-formatter'; + +describe('Structured JSON Logging Option (Issue #685)', () => { + describe('isJsonLoggingEnabled', () => { + test('enables when STRUCTURED_LOGGING=true', () => { + expect(isJsonLoggingEnabled({ STRUCTURED_LOGGING: 'true' })).toBe(true); + }); + + test('enables when LOG_FORMAT=json', () => { + expect(isJsonLoggingEnabled({ LOG_FORMAT: 'json' })).toBe(true); + }); + + test('enables by default in production unless explicitly disabled', () => { + expect(isJsonLoggingEnabled({ NODE_ENV: 'production' })).toBe(true); + expect(isJsonLoggingEnabled({ NODE_ENV: 'production', STRUCTURED_LOGGING: 'false' })).toBe(false); + }); + }); + + describe('createStructuredJsonFormat', () => { + test('formats log info into standard single-line JSON', () => { + const formatter = createStructuredJsonFormat({ + serviceName: 'notify-chain-listener', + environment: 'staging', + }); + + const logInfo = { + level: 'info', + message: 'Notification delivered successfully', + timestamp: '2026-08-29T12:00:00.000Z', + requestId: 'req-456', + durationMs: 42.5, + eventId: 'evt-001', + }; + + const result = (formatter.transform(logInfo as any) as any)[Symbol.for('message')]; + const parsed = JSON.parse(result); + + expect(parsed.level).toBe('info'); + expect(parsed.service).toBe('notify-chain-listener'); + expect(parsed.environment).toBe('staging'); + expect(parsed.requestId).toBe('req-456'); + expect(parsed.durationMs).toBe(42.5); + expect(parsed.eventId).toBe('evt-001'); + }); + + test('strictly redacts sensitive secrets in structured JSON logs', () => { + const secret = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6IRKD45DCAHSKV2U2B6P6IRKD45'; + const formatter = createStructuredJsonFormat(); + + const logInfo = { + level: 'error', + message: `Failed sending to endpoint using ${secret}`, + timestamp: '2026-08-29T12:00:00.000Z', + apiKey: 'super-secret-key-12345', + error: { + name: 'AuthError', + message: `Invalid token ${secret}`, + }, + }; + + const result = (formatter.transform(logInfo as any) as any)[Symbol.for('message')]; + const parsed = JSON.parse(result); + + expect(parsed.apiKey).toBe('[REDACTED]'); + expect(parsed.message).not.toContain(secret); + expect(parsed.error.message).not.toContain(secret); + }); + }); +}); diff --git a/listener/src/utils/json-logger-formatter.ts b/listener/src/utils/json-logger-formatter.ts new file mode 100644 index 00000000..6451eaf8 --- /dev/null +++ b/listener/src/utils/json-logger-formatter.ts @@ -0,0 +1,71 @@ +/** + * Structured JSON Log Formatter (Issue #685) + * + * Produces deterministic, machine-readable JSON logs for log aggregators + * (Datadog, CloudWatch, Grafana Loki, Elasticsearch) with automated secret redaction. + */ + +import winston from 'winston'; +import { redactSensitiveData, redactString } from './redact'; + +export interface StructuredLogRecord { + timestamp: string; + level: string; + message: string; + service: string; + environment: string; + requestId?: string; + durationMs?: number; + error?: Record | string; + [key: string]: unknown; +} + +export interface JsonFormatterOptions { + serviceName?: string; + environment?: string; +} + +/** + * Builds a Winston format function that converts log entries into normalized JSON. + */ +export function createStructuredJsonFormat(options: JsonFormatterOptions = {}) { + const service = options.serviceName || process.env.SERVICE_NAME || 'notify-chain'; + const environment = options.environment || process.env.NODE_ENV || 'development'; + + return winston.format.printf((info) => { + const { level, message, timestamp, requestId, durationMs, error, ...rest } = info; + + // Sanitize message string + const sanitizedMessage = typeof message === 'string' ? redactString(message) : String(message); + + // Sanitize extra metadata + const sanitizedMetadata = redactSensitiveData(rest) as Record; + + const record: StructuredLogRecord = { + timestamp: (timestamp as string) || new Date().toISOString(), + level, + message: sanitizedMessage, + service, + environment, + ...(requestId ? { requestId: String(requestId) } : {}), + ...(durationMs !== undefined ? { durationMs: Number(durationMs) } : {}), + ...(error ? { error: redactSensitiveData(error) as Record } : {}), + ...sanitizedMetadata, + }; + + return JSON.stringify(record); + }); +} + +/** + * Determines whether structured JSON logging should be enabled. + */ +export function isJsonLoggingEnabled(env: Record = process.env): boolean { + if (env.STRUCTURED_LOGGING === 'true' || env.LOG_FORMAT === 'json') { + return true; + } + if (env.NODE_ENV === 'production' && env.STRUCTURED_LOGGING !== 'false') { + return true; + } + return false; +}