api-observe 1.1.1 → 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,9 +27,10 @@
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
+ const { requestContext } = require('../request-context');
33
34
 
34
35
  /**
35
36
  * Create an Express middleware that captures errors and serves the observe dashboard.
@@ -38,10 +39,7 @@ const { createRouter } = require('../router');
38
39
  * @returns {function} Express middleware with .attach(), .attachToAll(), .store, .errorHandler()
39
40
  */
40
41
  function expressMiddleware(opts = {}) {
41
- const store = new FailureStore({
42
- maxEntries: opts.maxEntries,
43
- ttlMs: opts.ttlMs,
44
- });
42
+ const store = resolveStore(opts);
45
43
 
46
44
  const sanitize = opts.sanitize ?? defaultSanitize;
47
45
  const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
@@ -54,23 +52,22 @@ function expressMiddleware(opts = {}) {
54
52
 
55
53
  // Only handle /observe routes
56
54
  if (!pathname.startsWith('/observe')) {
57
- next();
55
+ requestContext.run({ captured: false, startedAt: Date.now(), inboundRequest: req }, next);
58
56
  return;
59
57
  }
60
58
 
61
- const result = router(req.method, pathname, parsed.query);
62
- if (!result) {
63
- next();
64
- return;
65
- }
66
-
67
- res.statusCode = result.status;
68
- for (const [key, value] of Object.entries(result.headers)) {
69
- res.setHeader(key, value);
70
- }
71
-
72
- const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
73
- 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);
74
71
  }
75
72
 
76
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
 
@@ -502,24 +502,46 @@ function getDashboardHtml() {
502
502
  : ''
503
503
  ) +
504
504
 
505
- '<div class="detail-section"><h3>Request Headers</h3>' +
505
+ '<div class="detail-section"><h3>Inbound Request Headers</h3>' +
506
506
  '<pre class="json-block">' + escHtml(formatJson(f.request?.headers)) + '</pre></div>' +
507
507
 
508
508
  (f.request?.query
509
- ? '<div class="detail-section"><h3>Request Query Params</h3>' +
509
+ ? '<div class="detail-section"><h3>Inbound Query Params</h3>' +
510
510
  '<pre class="json-block">' + escHtml(formatJson(f.request?.query)) + '</pre></div>'
511
511
  : ''
512
512
  ) +
513
513
 
514
- '<div class="detail-section"><h3>Request Body</h3>' +
514
+ '<div class="detail-section"><h3>Inbound Request Body</h3>' +
515
515
  '<pre class="json-block">' + escHtml(formatJson(f.request?.body)) + '</pre></div>' +
516
516
 
517
- (f.response
518
- ? '<div class="detail-section"><h3>Response Headers</h3>' +
519
- '<pre class="json-block">' + escHtml(formatJson(f.response?.headers)) + '</pre></div>' +
520
- '<div class="detail-section"><h3>Response Body</h3>' +
521
- '<pre class="json-block">' + escHtml(formatJson(f.response?.body)) + '</pre></div>'
522
- : '<div class="detail-section"><h3>Response</h3><p>No response received (timeout / network error)</p></div>'
517
+ (f.upstreamCall
518
+ ? '<div class="detail-section"><h3>Upstream Call \u2014 ' + escHtml(f.upstreamCall.service) + '</h3>' +
519
+ '<dl class="detail-grid">' +
520
+ '<dt>Method</dt><dd><span class="method-badge method-' + escHtml(f.upstreamCall.method) + '">' + escHtml(f.upstreamCall.method) + '</span></dd>' +
521
+ '<dt>URL</dt><dd>' + escHtml(f.upstreamCall.url || '') + '</dd>' +
522
+ '<dt>Status Code</dt><dd>' + (f.upstreamCall.statusCode || 'N/A') + '</dd>' +
523
+ '<dt>Duration</dt><dd>' + (f.upstreamCall.durationMs != null ? f.upstreamCall.durationMs + 'ms' : 'N/A') + '</dd>' +
524
+ '<dt>Error</dt><dd>' + escHtml(f.upstreamCall.errorMessage || '') + '</dd>' +
525
+ (f.upstreamCall.errorCode ? '<dt>Error Code</dt><dd>' + escHtml(f.upstreamCall.errorCode) + '</dd>' : '') +
526
+ '</dl></div>' +
527
+ '<div class="detail-section"><h3>Upstream Request Headers</h3>' +
528
+ '<pre class="json-block">' + escHtml(formatJson(f.upstreamCall.request?.headers)) + '</pre></div>' +
529
+ '<div class="detail-section"><h3>Upstream Request Body</h3>' +
530
+ '<pre class="json-block">' + escHtml(formatJson(f.upstreamCall.request?.body)) + '</pre></div>' +
531
+ (f.upstreamCall.response
532
+ ? '<div class="detail-section"><h3>Upstream Response Headers</h3>' +
533
+ '<pre class="json-block">' + escHtml(formatJson(f.upstreamCall.response?.headers)) + '</pre></div>' +
534
+ '<div class="detail-section"><h3>Upstream Response Body</h3>' +
535
+ '<pre class="json-block">' + escHtml(formatJson(f.upstreamCall.response?.body)) + '</pre></div>'
536
+ : '<div class="detail-section"><h3>Upstream Response</h3><p>No response received (timeout / network error)</p></div>'
537
+ )
538
+ : (f.response
539
+ ? '<div class="detail-section"><h3>Response Headers</h3>' +
540
+ '<pre class="json-block">' + escHtml(formatJson(f.response?.headers)) + '</pre></div>' +
541
+ '<div class="detail-section"><h3>Response Body</h3>' +
542
+ '<pre class="json-block">' + escHtml(formatJson(f.response?.body)) + '</pre></div>'
543
+ : '<div class="detail-section"><h3>Response</h3><p>No response received (timeout / network error)</p></div>'
544
+ )
523
545
  );
524
546
 
525
547
  document.getElementById('modalOverlay').classList.add('active');
@@ -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 };
@@ -63,39 +63,75 @@ function attachInterceptor(client, store, opts = {}) {
63
63
  const response = error.response;
64
64
  const durationMs = Date.now() - (config.metadata?.startMs ?? Date.now());
65
65
 
66
- const record = {
67
- type: 'upstream',
66
+ const ctx = requestContext.getStore();
67
+ const inboundReq = ctx?.inboundRequest ?? null;
68
+
69
+ // ── Downstream call details ────────────────────────────────────────────
70
+ const upstreamCall = {
68
71
  service: client.serviceName,
69
72
  method: config.method?.toUpperCase() ?? 'UNKNOWN',
70
73
  url: buildFullUrl(config),
71
- correlationId: config.correlationId ?? config.headers?.['x-correlation-id'] ?? null,
72
74
  durationMs,
75
+ statusCode: response?.status ?? null,
73
76
  errorMessage: error.message,
74
77
  errorCode: error.code ?? null,
75
-
76
- // Request details
77
78
  request: {
78
79
  headers: sanitize(config.headers),
79
80
  params: config.params ?? null,
80
81
  body: sanitize(config.data),
81
82
  },
82
-
83
- // Response details (null if no response, e.g. timeout/network error)
84
- statusCode: response?.status ?? null,
85
83
  response: response
86
- ? {
87
- headers: response.headers ?? null,
88
- body: sanitize(response.data),
89
- }
84
+ ? { headers: response.headers ?? null, body: sanitize(response.data) }
90
85
  : null,
91
86
  };
92
87
 
88
+ let record;
89
+ if (inboundReq) {
90
+ // Top-level = the inbound request that triggered this failure.
91
+ // The downstream call details are nested under upstreamCall.
92
+ record = {
93
+ type: 'upstream',
94
+ service: inboundReq.routeOptions?.url ?? inboundReq.originalUrl ?? inboundReq.url ?? 'unknown',
95
+ method: inboundReq.method ?? 'UNKNOWN',
96
+ url: inboundReq.originalUrl ?? inboundReq.url ?? 'unknown',
97
+ correlationId: inboundReq.correlationId ?? inboundReq.headers?.['x-correlation-id'] ?? config.correlationId ?? config.headers?.['x-correlation-id'] ?? null,
98
+ durationMs,
99
+ errorMessage: error.message,
100
+ errorCode: error.code ?? null,
101
+ statusCode: response?.status ?? null,
102
+ request: {
103
+ headers: sanitize(inboundReq.headers),
104
+ params: inboundReq.params ?? null,
105
+ query: inboundReq.query ?? null,
106
+ body: sanitize(inboundReq.body),
107
+ },
108
+ response: null,
109
+ upstreamCall,
110
+ };
111
+ } else {
112
+ // No inbound context (standalone / direct interceptor usage) —
113
+ // keep the downstream call as the top-level record.
114
+ record = {
115
+ type: 'upstream',
116
+ service: client.serviceName,
117
+ method: upstreamCall.method,
118
+ url: upstreamCall.url,
119
+ correlationId: config.correlationId ?? config.headers?.['x-correlation-id'] ?? null,
120
+ durationMs,
121
+ errorMessage: error.message,
122
+ errorCode: error.code ?? null,
123
+ statusCode: response?.status ?? null,
124
+ request: upstreamCall.request,
125
+ response: upstreamCall.response,
126
+ upstreamCall: null,
127
+ };
128
+ }
129
+
93
130
  store.add(record);
94
131
 
95
132
  // Mark the in-flight Fastify request (if any) so the response-level
96
133
  // hook in plugin.js does not record a duplicate when the controller
97
134
  // catches this error and replies with its own 4xx/5xx.
98
- const ctx = requestContext.getStore();
99
135
  if (ctx) ctx.captured = true;
100
136
 
101
137
  // Re-throw the original error so BaseHttpClient's handler still works
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;
@@ -88,7 +85,7 @@ async function observerPlugin(fastify, opts) {
88
85
  // interceptor records a capture deep in the call stack, it can reach back
89
86
  // and mark this request as already captured.
90
87
  fastify.addHook('onRequest', (request, reply, done) => {
91
- const ctx = { captured: false, startedAt: Date.now() };
88
+ const ctx = { captured: false, startedAt: Date.now(), inboundRequest: request };
92
89
  request[CTX_KEY] = ctx;
93
90
  requestContext.enterWith(ctx);
94
91
  done();
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.01",
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": [