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
45 changes: 45 additions & 0 deletions docs/STRUCTURED_JSON_LOGGING.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions listener/src/utils/json-logger-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
71 changes: 71 additions & 0 deletions listener/src/utils/json-logger-formatter.ts
Original file line number Diff line number Diff line change
@@ -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, unknown> | 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<string, unknown>;

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<string, unknown> } : {}),
...sanitizedMetadata,
};

return JSON.stringify(record);
});
}

/**
* Determines whether structured JSON logging should be enabled.
*/
export function isJsonLoggingEnabled(env: Record<string, string | undefined> = 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;
}