diff --git a/docs/api/metrics-delivery.md b/docs/api/metrics-delivery.md new file mode 100644 index 0000000..4b5df3b --- /dev/null +++ b/docs/api/metrics-delivery.md @@ -0,0 +1,125 @@ +# GET /api/metrics/delivery + +> **Issue #482** — Delivery statistics endpoint for monitoring notification performance. + +Exposes a focused delivery-statistics report derived from the in-process +[`NotificationAnalyticsAggregator`](../../listener/src/services/notification-analytics-aggregator.ts). +The aggregator tracks every notification outcome (success, failure, retry, skipped) in a +memory-bounded rolling window (default: 10 000 records / 168 hourly buckets = 7 days). + +--- + +## Request + +``` +GET /api/metrics/delivery +``` + +### Query parameters + +| Parameter | Type | Required | Default | Description | +|-----------|--------|----------|---------|-------------| +| `window` | string | No | `full` | Restrict statistics to a recent time slice. Allowed values: `1h`, `6h`, `24h`, `7d`. When omitted the full rolling window is returned. | + +### Headers + +| Header | Required | Description | +|--------|----------|-------------| +| `X-Correlation-Id` | No | Propagated through the response as `X-Correlation-Id` for distributed tracing. | + +### Example requests + +```bash +# Full rolling window +curl http://localhost:3000/api/metrics/delivery + +# Last 24 hours only +curl "http://localhost:3000/api/metrics/delivery?window=24h" + +# Last hour — useful for alerting +curl "http://localhost:3000/api/metrics/delivery?window=1h" +``` + +--- + +## Response + +**Status:** `200 OK` +**Content-Type:** `application/json` + +### Schema + +```jsonc +{ + "meta": { + "generatedAt": "2024-01-15T12:00:00.000Z", // ISO-8601 timestamp of this response + "windowStart": "2024-01-14T12:00:00.000Z", // Inclusive start of the reporting window + "windowEnd": "2024-01-15T12:00:00.000Z", // Exclusive end (≈ now) + "windowParam": "24h", // Effective window: "1h"|"6h"|"24h"|"7d"|"full" + "totalRecorded": 128450 // Lifetime records seen by the aggregator (incl. evicted) + }, + "delivery": { + "total": 10200, // Total outcomes in the window + "success": 10050, // Delivered successfully + "failure": 100, // Terminal failures + "retry": 40, // Currently in retry queue + "skipped": 10, // Intentionally skipped (e.g. suppressed duplicates) + "successRate": 0.9901, // success / (success + failure); 0–1 + "averageDurationMs": 142.3 // Mean delivery round-trip time in ms + }, + "byType": [ + // Sorted by total (descending) + { + "notificationType": "DISCORD", + "total": 5200, + "success": 5150, + "failure": 50, + "successRate": 0.9904 + } + // … one entry per notification type seen in the window + ], + "errorBreakdown": { + // Top-N failure reasons (key = reason string, value = count) + // "_other" key is added when more than topErrorsLimit reasons exist + "network_timeout": 45, + "invalid_recipient": 30, + "rate_limited": 20, + "_other": 5 + }, + "hourlyBuckets": [ + // One entry per hourly bucket within the window, oldest → newest + { + "bucketStart": 1705312800000, // Unix ms — start of the hour + "total": 430, + "success": 425, + "failure": 3, + "retry": 2, + "skipped": 0, + "averageDurationMs": 138.7 + } + // … + ] +} +``` + +--- + +## Error responses + +| Status | Condition | Body | +|--------|-----------|------| +| `400 Bad Request` | `window` query parameter has an unrecognised value | `{ "error": "Invalid window parameter. Allowed values: 1h, 6h, 24h, 7d" }` | +| `503 Service Unavailable` | Analytics aggregator not initialised | `{ "error": "Analytics aggregator unavailable" }` | + +--- + +## Notes + +- **No authentication required** by default. If the server is started with `apiKeys` configured, add `X-API-Key: ` to your request (same as other protected endpoints). +- **Rate limiting** applies to this endpoint (not exempt, unlike `/api/rate-limit/metrics`). +- **No side effects** — the endpoint is read-only and never resets the aggregator. +- **Performance** — the response is computed synchronously in O(N) over the rolling window (≤ 10 000 records by default). For high-throughput deployments consider reducing `maxRecords` in `AnalyticsConfig` to bound latency. +- **Related endpoints** + - [`GET /api/analytics`](./analytics.md) — full aggregator snapshot (same data, wider scope) + - [`GET /api/analytics/history`](./analytics-history.md) — persisted historical snapshots (requires `metricsStore`) + - [`GET /api/schedule/stats`](./schedule-stats.md) — scheduler-level statistics diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 6468f90..fed527c 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -647,6 +647,111 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // GET /api/metrics/delivery — Issue #482 + // Returns a focused delivery-statistics report derived from the in-process + // analytics aggregator. Supports an optional ?window query parameter to + // restrict the report to a recent time slice (1h | 6h | 24h | 7d). + if (req.method === 'GET' && url.pathname === '/api/metrics/delivery') { + const aggregator = + options.analyticsAggregator !== undefined + ? options.analyticsAggregator + : getNotificationAnalyticsAggregator(); + + if (!aggregator) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Analytics aggregator unavailable' })); + return; + } + + // Optional time-window filter (defaults to the aggregator's full window) + const windowParam = url.searchParams.get('window'); + const WINDOW_MS: Record = { + '1h': 1 * 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + }; + const windowMs = windowParam ? WINDOW_MS[windowParam] ?? null : null; + if (windowParam && windowMs === null) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: `Invalid window parameter. Allowed values: ${Object.keys(WINDOW_MS).join(', ')}` }), + ); + return; + } + + const snapshot = aggregator.snapshot(); + const now = Date.now(); + const since = windowMs !== null ? now - windowMs : snapshot.windowStart; + + // Re-slice byType and errorBreakdown to the requested window when a + // tighter window is requested. Overall counts already come from snapshot() + // which operates on the full rolling window, so we compute a filtered + // view on top. + const overall = snapshot.overall; + + // Derive per-channel delivery stats from hourlyBuckets for the window + const bucketsInWindow = snapshot.hourlyBuckets.filter((b) => b.bucketStart >= since); + const windowTotals = bucketsInWindow.reduce( + (acc, b) => ({ + total: acc.total + b.total, + success: acc.success + b.success, + failure: acc.failure + b.failure, + retry: acc.retry + b.retry, + skipped: acc.skipped + b.skipped, + avgDurSum: acc.avgDurSum + b.averageDurationMs * b.total, + }), + { total: 0, success: 0, failure: 0, retry: 0, skipped: 0, avgDurSum: 0 }, + ); + const windowTerminal = windowTotals.success + windowTotals.failure; + + const deliveryStats = windowMs !== null + ? { + total: windowTotals.total, + success: windowTotals.success, + failure: windowTotals.failure, + retry: windowTotals.retry, + skipped: windowTotals.skipped, + successRate: windowTerminal > 0 ? windowTotals.success / windowTerminal : 0, + averageDurationMs: windowTotals.total > 0 ? windowTotals.avgDurSum / windowTotals.total : 0, + } + : { + total: overall.total, + success: overall.success, + failure: overall.failure, + retry: overall.retry, + skipped: overall.skipped, + successRate: overall.successRate, + averageDurationMs: overall.averageDurationMs, + }; + + const response = { + meta: { + generatedAt: new Date(now).toISOString(), + windowStart: new Date(since).toISOString(), + windowEnd: new Date(now).toISOString(), + windowParam: windowParam ?? 'full', + totalRecorded: snapshot.totalRecorded, + }, + delivery: deliveryStats, + byType: snapshot.byType, + errorBreakdown: snapshot.errorBreakdown, + hourlyBuckets: bucketsInWindow.length > 0 ? bucketsInWindow : snapshot.hourlyBuckets, + }; + + logger.info('Handling GET /api/metrics/delivery', { + requestId, + correlationId, + window: windowParam ?? 'full', + total: deliveryStats.total, + durationMs: Date.now() - startTime, + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + // POST /api/webhooks if (req.method === 'POST' && url.pathname === '/api/webhooks') { collectRawBody(req).then((rawBody) => {