api-observe 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,12 @@ Works with **Fastify**, **Express**, **NestJS**, **Koa**, or **plain Node.js HTT
8
8
 
9
9
  - Captures upstream API failures (via Axios interceptors)
10
10
  - Captures controller/route-level errors (via framework hooks)
11
+ - **Failure records are keyed to the inbound request** — when `/parties` calls `/api/aggregate` and it fails, the log entry shows `/parties` at the top level with the full `/api/aggregate` failure nested inside
11
12
  - Built-in HTML dashboard at `/observe`
12
13
  - REST API for querying failures programmatically
13
14
  - In-memory ring buffer with configurable size and TTL
15
+ - **`onCapture` hook** — persist failures to your own database alongside the in-memory store
16
+ - **Custom store** — replace the in-memory store entirely with a database-backed implementation
14
17
  - Automatic redaction of sensitive headers (authorization, tokens, passwords)
15
18
  - Zero external dependencies
16
19
 
@@ -55,6 +58,15 @@ The Fastify plugin automatically captures:
55
58
  - **Controller errors** - any error thrown during the request lifecycle (via `onError` hook)
56
59
  - **404s** - requests to non-existent routes (via `onResponse` hook)
57
60
 
61
+ #### How upstream failures are recorded
62
+
63
+ When an inbound request (e.g. `POST /parties`) triggers a downstream call that fails (e.g. `GET /api/aggregate` returns 500), the failure record is keyed to the **inbound** route — not the downstream URL. Opening the record in the dashboard shows:
64
+
65
+ - **Top level**: `POST /parties` — the service, method, URL, correlation ID, and inbound request headers/body
66
+ - **Upstream Call** section: the downstream service name, `GET /api/aggregate`, status code, duration, error message, outgoing request headers/body, and the downstream response
67
+
68
+ This lets you immediately answer "which of my endpoints is broken?" rather than having to trace a downstream URL back to the calling route.
69
+
58
70
  ---
59
71
 
60
72
  ### Express
@@ -183,9 +195,12 @@ bootstrap();
183
195
  Visit `/observe` in your browser to access the built-in dashboard.
184
196
 
185
197
  The dashboard provides:
186
- - Real-time failure table with filtering by type, service, method, status code, and URL
198
+ - Real-time failure table with filtering by type, service, method, status code, URL, and correlation ID
187
199
  - Summary cards showing total failures, upstream vs controller errors, and affected services
188
- - Detail modal with full request/response headers, bodies, and stack traces
200
+ - Detail modal with:
201
+ - **Inbound request** headers, query params, and body (the route your app received)
202
+ - **Upstream Call** section — downstream service name, URL, status, duration, outgoing request, and downstream response headers/body (only shown for upstream failures)
203
+ - Stack trace (for controller errors)
189
204
  - Auto-refresh every 30 seconds
190
205
  - Pagination for large result sets
191
206
 
@@ -210,6 +225,7 @@ All endpoints are served under `/observe/api/`:
210
225
  | `method` | string | Filter by HTTP method (`GET`, `POST`, etc.) |
211
226
  | `statusCode` | number | Filter by response status code |
212
227
  | `url` | string | Filter by URL (partial match) |
228
+ | `correlationId` | string | Filter by correlation ID (partial match) |
213
229
  | `from` | string | Start date (ISO 8601) |
214
230
  | `to` | string | End date (ISO 8601) |
215
231
  | `limit` | number | Results per page (default: 50) |
@@ -221,6 +237,9 @@ All endpoints are served under `/observe/api/`:
221
237
  # Get all 5xx failures from the auth service
222
238
  curl "http://localhost:3000/observe/api/failures?service=auth&statusCode=500"
223
239
 
240
+ # Get failures by correlation ID
241
+ curl "http://localhost:3000/observe/api/failures?correlationId=abc-123"
242
+
224
243
  # Get summary stats
225
244
  curl "http://localhost:3000/observe/api/summary"
226
245
  ```
@@ -234,6 +253,8 @@ All adapters accept the same options:
234
253
  | `maxEntries` | number | `500` | Maximum failure records to keep in memory |
235
254
  | `ttlMs` | number | `86400000` (24h) | Time-to-live in ms before entries are evicted |
236
255
  | `sanitize` | function | built-in | Custom sanitizer for request/response bodies |
256
+ | `onCapture` | function | — | Called with each captured record; use to persist to a database |
257
+ | `store` | object | — | Replace the in-memory store with a custom implementation (see [Persisting Failures](#persisting-failures-to-a-database)) |
237
258
 
238
259
  ### Default Sanitization
239
260
 
@@ -369,6 +390,116 @@ observe.attachToAll(services);
369
390
  - Direct `BaseHttpClient` instances (has `_axios` + `serviceName`)
370
391
  - Service wrappers with a `.client` property that is a `BaseHttpClient`
371
392
 
393
+ ## Persisting Failures to a Database
394
+
395
+ By default `api-observe` keeps failures in an in-memory ring buffer. Two options let you persist them externally.
396
+
397
+ ---
398
+
399
+ ### Option A — `onCapture` callback (recommended for most apps)
400
+
401
+ Pass an `onCapture` function. It is called with every captured record immediately after it is stored in memory. Use it to write to your own database while still benefiting from the built-in dashboard and in-memory query API.
402
+
403
+ ```js
404
+ // Fastify
405
+ await fastify.register(apiObserve, {
406
+ onCapture: async (record) => {
407
+ await db.collection('api_failures').insertOne(record);
408
+ },
409
+ });
410
+
411
+ // Express
412
+ const observe = expressMiddleware({
413
+ onCapture: async (record) => {
414
+ await db.query(
415
+ 'INSERT INTO api_failures(data) VALUES($1)',
416
+ [JSON.stringify(record)],
417
+ );
418
+ },
419
+ });
420
+ ```
421
+
422
+ `record` is the fully-formed failure object (same shape as returned by `GET /observe/api/failures/:id`). Errors thrown inside `onCapture` are silently swallowed so a failing hook never disrupts your application.
423
+
424
+ ---
425
+
426
+ ### Option B — Custom store (full replacement)
427
+
428
+ Pass your own `store` object to replace the in-memory store entirely. The dashboard and REST API will query it, so failures are served from your database.
429
+
430
+ Your store must implement this interface:
431
+
432
+ ```ts
433
+ interface FailureStore {
434
+ add(failure: object): object; // store a record, return it with id + timestamp
435
+ query(filters: object): { data: object[], total: number, limit: number, offset: number };
436
+ getById(id: number | string): object | null;
437
+ summary(): object;
438
+ clear(): void;
439
+ }
440
+ ```
441
+
442
+ #### Example — MongoDB-backed store
443
+
444
+ ```js
445
+ class MongoFailureStore {
446
+ constructor(collection) {
447
+ this._col = collection;
448
+ this._seq = 0;
449
+ }
450
+
451
+ async add(failure) {
452
+ const record = { id: ++this._seq, timestamp: new Date().toISOString(), ...failure };
453
+ await this._col.insertOne(record);
454
+ return record;
455
+ }
456
+
457
+ async query({ service, statusCode, method, url, correlationId, type, limit = 50, offset = 0 } = {}) {
458
+ const filter = {};
459
+ if (type) filter.type = type;
460
+ if (service) filter.service = new RegExp(service, 'i');
461
+ if (method) filter.method = method.toUpperCase();
462
+ if (statusCode) filter.statusCode = Number(statusCode);
463
+ if (url) filter.url = new RegExp(url, 'i');
464
+ if (correlationId) filter.correlationId = new RegExp(correlationId, 'i');
465
+
466
+ const total = await this._col.countDocuments(filter);
467
+ const data = await this._col.find(filter).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray();
468
+ return { data, total, limit, offset };
469
+ }
470
+
471
+ async getById(id) {
472
+ return this._col.findOne({ id: Number(id) }) ?? null;
473
+ }
474
+
475
+ async summary() {
476
+ const all = await this._col.find({}).toArray();
477
+ const byService = {}, byStatusCode = {}, byType = {};
478
+ for (const e of all) {
479
+ byService[e.service || 'unknown'] = (byService[e.service || 'unknown'] || 0) + 1;
480
+ const code = e.statusCode || 'unknown';
481
+ byStatusCode[code] = (byStatusCode[code] || 0) + 1;
482
+ const type = e.type || 'upstream';
483
+ byType[type] = (byType[type] || 0) + 1;
484
+ }
485
+ return { totalFailures: all.length, byService, byStatusCode, byType };
486
+ }
487
+
488
+ async clear() {
489
+ await this._col.deleteMany({});
490
+ }
491
+ }
492
+
493
+ // Pass it to the adapter
494
+ const observe = expressMiddleware({
495
+ store: new MongoFailureStore(db.collection('api_failures')),
496
+ });
497
+ ```
498
+
499
+ > **Note:** When using a custom async store, your `query` / `getById` / `summary` methods may return Promises — the built-in router will await them automatically.
500
+
501
+ ---
502
+
372
503
  ## Exports
373
504
 
374
505
  ```js
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  const { parse: parseUrl } = require('url');
30
- const { FailureStore } = require('../failure-store');
30
+ const { FailureStore, resolveStore } = require('../failure-store');
31
31
  const { attachInterceptor, attachToAll, defaultSanitize } = require('../interceptor');
32
32
  const { createRouter } = require('../router');
33
33
  const { requestContext } = require('../request-context');
@@ -39,10 +39,7 @@ const { requestContext } = require('../request-context');
39
39
  * @returns {function} Express middleware with .attach(), .attachToAll(), .store, .errorHandler()
40
40
  */
41
41
  function expressMiddleware(opts = {}) {
42
- const store = new FailureStore({
43
- maxEntries: opts.maxEntries,
44
- ttlMs: opts.ttlMs,
45
- });
42
+ const store = resolveStore(opts);
46
43
 
47
44
  const sanitize = opts.sanitize ?? defaultSanitize;
48
45
  const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
@@ -59,19 +56,18 @@ function expressMiddleware(opts = {}) {
59
56
  return;
60
57
  }
61
58
 
62
- const result = router(req.method, pathname, parsed.query);
63
- if (!result) {
64
- next();
65
- return;
66
- }
67
-
68
- res.statusCode = result.status;
69
- for (const [key, value] of Object.entries(result.headers)) {
70
- res.setHeader(key, value);
71
- }
72
-
73
- const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
74
- res.end(body);
59
+ router(req.method, pathname, parsed.query).then(result => {
60
+ if (!result) {
61
+ next();
62
+ return;
63
+ }
64
+ res.statusCode = result.status;
65
+ for (const [key, value] of Object.entries(result.headers)) {
66
+ res.setHeader(key, value);
67
+ }
68
+ const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
69
+ res.end(body);
70
+ }).catch(next);
75
71
  }
76
72
 
77
73
  // ── Error-capturing middleware (use AFTER your routes) ───────────────────
@@ -34,7 +34,7 @@
34
34
  */
35
35
 
36
36
  const { parse: parseUrl } = require('url');
37
- const { FailureStore } = require('../failure-store');
37
+ const { FailureStore, resolveStore } = require('../failure-store');
38
38
  const { attachInterceptor, attachToAll, defaultSanitize } = require('../interceptor');
39
39
  const { createRouter } = require('../router');
40
40
 
@@ -45,10 +45,7 @@ const { createRouter } = require('../router');
45
45
  * @returns {object} Handler with .handle(), .attach(), .attachToAll(), .captureError(), .store
46
46
  */
47
47
  function createHttpHandler(opts = {}) {
48
- const store = new FailureStore({
49
- maxEntries: opts.maxEntries,
50
- ttlMs: opts.ttlMs,
51
- });
48
+ const store = resolveStore(opts);
52
49
 
53
50
  const sanitize = opts.sanitize ?? defaultSanitize;
54
51
  const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
@@ -70,18 +67,24 @@ function createHttpHandler(opts = {}) {
70
67
  return false;
71
68
  }
72
69
 
73
- const result = router(req.method, pathname, parsed.query);
74
- if (!result) {
75
- return false;
76
- }
77
-
78
- res.statusCode = result.status;
79
- for (const [key, value] of Object.entries(result.headers)) {
80
- res.setHeader(key, value);
81
- }
82
-
83
- const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
84
- res.end(body);
70
+ // routeHandler is async (supports custom async stores); signal handled
71
+ // synchronously by returning true, then resolve asynchronously.
72
+ router(req.method, pathname, parsed.query).then(result => {
73
+ if (!result) {
74
+ res.statusCode = 404;
75
+ res.end(JSON.stringify({ error: 'Not found' }));
76
+ return;
77
+ }
78
+ res.statusCode = result.status;
79
+ for (const [key, value] of Object.entries(result.headers)) {
80
+ res.setHeader(key, value);
81
+ }
82
+ const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
83
+ res.end(body);
84
+ }).catch(err => {
85
+ res.statusCode = 500;
86
+ res.end(JSON.stringify({ error: 'Internal error' }));
87
+ });
85
88
  return true;
86
89
  }
87
90
 
@@ -13,13 +13,14 @@
13
13
 
14
14
  class FailureStore {
15
15
  /**
16
- * @param {{ maxEntries?: number, ttlMs?: number }} [opts]
16
+ * @param {{ maxEntries?: number, ttlMs?: number, onCapture?: (record: object) => void }} [opts]
17
17
  */
18
18
  constructor(opts = {}) {
19
19
  this._maxEntries = opts.maxEntries ?? 500;
20
20
  this._ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000; // 24 hours
21
21
  this._entries = [];
22
22
  this._idCounter = 0;
23
+ this._onCapture = typeof opts.onCapture === 'function' ? opts.onCapture : null;
23
24
  }
24
25
 
25
26
  /**
@@ -41,6 +42,12 @@ class FailureStore {
41
42
  this._entries = this._entries.slice(-this._maxEntries);
42
43
  }
43
44
 
45
+ // Fire the capture hook (e.g. persist to an external database).
46
+ // Errors are swallowed so a failing hook never disrupts the app.
47
+ if (this._onCapture) {
48
+ try { this._onCapture(record); } catch (_) {}
49
+ }
50
+
44
51
  return record;
45
52
  }
46
53
 
@@ -161,3 +168,33 @@ class FailureStore {
161
168
  }
162
169
 
163
170
  module.exports = { FailureStore };
171
+
172
+ /**
173
+ * Resolve a store from adapter options.
174
+ *
175
+ * - If `opts.store` is provided it is used as-is (custom/DB-backed store).
176
+ * It must implement: add(failure), query(filters), getById(id), summary(), clear().
177
+ * - Otherwise a new FailureStore is created, optionally with an onCapture hook.
178
+ *
179
+ * @param {{ store?: object, maxEntries?: number, ttlMs?: number, onCapture?: function }} opts
180
+ * @returns {FailureStore|object}
181
+ */
182
+ function resolveStore(opts = {}) {
183
+ if (opts.store) {
184
+ const required = ['add', 'query', 'getById', 'summary', 'clear'];
185
+ for (const method of required) {
186
+ if (typeof opts.store[method] !== 'function') {
187
+ throw new Error(`api-observe: custom store is missing required method "${method}"`);
188
+ }
189
+ }
190
+ return opts.store;
191
+ }
192
+
193
+ return new FailureStore({
194
+ maxEntries: opts.maxEntries,
195
+ ttlMs: opts.ttlMs,
196
+ onCapture: opts.onCapture,
197
+ });
198
+ }
199
+
200
+ module.exports = { FailureStore, resolveStore };
package/lib/plugin.js CHANGED
@@ -50,7 +50,7 @@
50
50
  * recording a duplicate when the same request has already been captured.
51
51
  */
52
52
 
53
- const { FailureStore } = require('./failure-store');
53
+ const { FailureStore, resolveStore } = require('./failure-store');
54
54
  const { attachInterceptor, attachToAll, defaultSanitize } = require('./interceptor');
55
55
  const { registerRoutes } = require('./routes');
56
56
  const { requestContext } = require('./request-context');
@@ -58,10 +58,7 @@ const { requestContext } = require('./request-context');
58
58
  const CTX_KEY = Symbol.for('ps-observe.ctx');
59
59
 
60
60
  async function observerPlugin(fastify, opts) {
61
- const store = new FailureStore({
62
- maxEntries: opts.maxEntries,
63
- ttlMs: opts.ttlMs,
64
- });
61
+ const store = resolveStore(opts);
65
62
 
66
63
  const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
67
64
  const sanitize = opts.sanitize ?? defaultSanitize;
package/lib/router.js CHANGED
@@ -25,9 +25,9 @@ function createRouter(store) {
25
25
  * @param {string} pathname - URL path, e.g. "/observe/api/failures/3"
26
26
  * @param {object} query - Parsed query parameters
27
27
  * @returns {{ status: number, headers: object, body: any } | null}
28
- * null means "no matching route"
28
+ * null means "no matching route" — may be a Promise for async stores
29
29
  */
30
- return function routeHandler(method, pathname, query) {
30
+ return async function routeHandler(method, pathname, query) {
31
31
  // Normalise: strip trailing slash (except root "/observe")
32
32
  const path = pathname.replace(/\/$/, '') || '/';
33
33
 
@@ -43,14 +43,14 @@ function createRouter(store) {
43
43
  // ── List failures ──────────────────────────────────────────────────────
44
44
  if (method === 'GET' && path === '/observe/api/failures') {
45
45
  const { service, statusCode, method: m, url, from, to, limit, offset, type, correlationId } = query || {};
46
- const result = store.query({ service, statusCode, method: m, url, from, to, limit, offset, type, correlationId });
46
+ const result = await store.query({ service, statusCode, method: m, url, from, to, limit, offset, type, correlationId });
47
47
  return { status: 200, headers: { 'content-type': 'application/json' }, body: result };
48
48
  }
49
49
 
50
50
  // ── Single failure detail ──────────────────────────────────────────────
51
51
  const detailMatch = path.match(/^\/observe\/api\/failures\/(\d+)$/);
52
52
  if (method === 'GET' && detailMatch) {
53
- const record = store.getById(detailMatch[1]);
53
+ const record = await store.getById(detailMatch[1]);
54
54
  if (!record) {
55
55
  return { status: 404, headers: { 'content-type': 'application/json' }, body: { error: 'Failure record not found' } };
56
56
  }
@@ -59,12 +59,12 @@ function createRouter(store) {
59
59
 
60
60
  // ── Summary stats ──────────────────────────────────────────────────────
61
61
  if (method === 'GET' && path === '/observe/api/summary') {
62
- return { status: 200, headers: { 'content-type': 'application/json' }, body: store.summary() };
62
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: await store.summary() };
63
63
  }
64
64
 
65
65
  // ── Clear all ──────────────────────────────────────────────────────────
66
66
  if (method === 'DELETE' && path === '/observe/api/failures') {
67
- store.clear();
67
+ await store.clear();
68
68
  return { status: 200, headers: { 'content-type': 'application/json' }, body: { cleared: true } };
69
69
  }
70
70
 
package/lib/routes.js CHANGED
@@ -22,9 +22,9 @@ function registerRoutes(fastify, store) {
22
22
  const router = createRouter(store);
23
23
 
24
24
  // Catch-all handler for /observe routes
25
- const handler = (req, reply) => {
25
+ const handler = async (req, reply) => {
26
26
  const url = new URL(req.url, 'http://localhost');
27
- const result = router(req.method, url.pathname, req.query);
27
+ const result = await router(req.method, url.pathname, req.query);
28
28
  if (!result) {
29
29
  reply.code(404).send({ error: 'Not found' });
30
30
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-observe",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Framework-agnostic plugin that captures and displays API failure details — works with Fastify, Express, NestJS, Koa, or plain Node.js HTTP",
5
5
  "main": "lib/index.js",
6
6
  "files": [