api-observe 1.0.2 → 1.1.1

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.
@@ -12,7 +12,7 @@ function getDashboardHtml() {
12
12
  <head>
13
13
  <meta charset="UTF-8">
14
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
- <title>ps-observe | API Failure Tracker</title>
15
+ <title>observe | API Failure Tracker</title>
16
16
  <style>
17
17
  * { margin: 0; padding: 0; box-sizing: border-box; }
18
18
 
@@ -306,7 +306,7 @@ function getDashboardHtml() {
306
306
  </head>
307
307
  <body>
308
308
  <header>
309
- <h1>ps-observe</h1>
309
+ <h1>observe</h1>
310
310
  <div class="stats">
311
311
  <span class="count" id="totalCount">0</span> failures tracked
312
312
  </div>
@@ -329,6 +329,7 @@ function getDashboardHtml() {
329
329
  </select>
330
330
  <input type="number" id="filterStatus" placeholder="Status code..." min="100" max="599" />
331
331
  <input type="text" id="filterUrl" placeholder="URL contains..." />
332
+ <input type="text" id="filterCorrelationId" placeholder="Correlation ID..." />
332
333
  <button class="refresh-btn" onclick="loadFailures()">Refresh</button>
333
334
  <button class="danger" onclick="clearAll()">Clear All</button>
334
335
  </div>
@@ -373,12 +374,14 @@ function getDashboardHtml() {
373
374
  const method = document.getElementById('filterMethod').value;
374
375
  const statusCode = document.getElementById('filterStatus').value;
375
376
  const url = document.getElementById('filterUrl').value;
377
+ const correlationId = document.getElementById('filterCorrelationId').value.trim();
376
378
 
377
379
  if (type) params.set('type', type);
378
380
  if (service) params.set('service', service);
379
381
  if (method) params.set('method', method);
380
382
  if (statusCode) params.set('statusCode', statusCode);
381
383
  if (url) params.set('url', url);
384
+ if (correlationId) params.set('correlationId', correlationId);
382
385
  params.set('limit', PAGE_SIZE);
383
386
  params.set('offset', currentOffset);
384
387
 
@@ -46,7 +46,7 @@ class FailureStore {
46
46
 
47
47
  /**
48
48
  * Query failures with optional filters.
49
- * @param {{ service?: string, statusCode?: number, from?: string, to?: string, method?: string, url?: string, limit?: number, offset?: number }} [filters]
49
+ * @param {{ service?: string, statusCode?: number, from?: string, to?: string, method?: string, url?: string, correlationId?: string, limit?: number, offset?: number }} [filters]
50
50
  * @returns {{ data: object[], total: number }}
51
51
  */
52
52
  query(filters = {}) {
@@ -81,6 +81,10 @@ class FailureStore {
81
81
  const url = filters.url.toLowerCase();
82
82
  results = results.filter(e => e.url?.toLowerCase().includes(url));
83
83
  }
84
+ if (filters.correlationId) {
85
+ const cid = String(filters.correlationId).toLowerCase();
86
+ results = results.filter(e => e.correlationId?.toLowerCase().includes(cid));
87
+ }
84
88
  if (filters.from) {
85
89
  const from = new Date(filters.from).getTime();
86
90
  results = results.filter(e => new Date(e.timestamp).getTime() >= from);
@@ -15,6 +15,8 @@
15
15
  * transforms it.
16
16
  */
17
17
 
18
+ const { requestContext } = require('./request-context');
19
+
18
20
  /**
19
21
  * Attach the observe interceptor to a BaseHttpClient instance.
20
22
  *
@@ -90,6 +92,12 @@ function attachInterceptor(client, store, opts = {}) {
90
92
 
91
93
  store.add(record);
92
94
 
95
+ // Mark the in-flight Fastify request (if any) so the response-level
96
+ // hook in plugin.js does not record a duplicate when the controller
97
+ // catches this error and replies with its own 4xx/5xx.
98
+ const ctx = requestContext.getStore();
99
+ if (ctx) ctx.captured = true;
100
+
93
101
  // Re-throw the original error so BaseHttpClient's handler still works
94
102
  throw error;
95
103
  },
package/lib/plugin.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Usage in a consuming service:
9
9
  *
10
- * const observer = require('@pruservices/ps-observe');
10
+ * const observer = require('api-observe');
11
11
  *
12
12
  * fastify.register(observer, {
13
13
  * // (optional) max failure records to keep in memory (default: 500)
@@ -31,11 +31,31 @@
31
31
  * // Option B: Auto-attach to all clients on an object
32
32
  * fastify.observer.attachToAll(fastify.services);
33
33
  * });
34
+ *
35
+ * ── Capture sources ───────────────────────────────────────────────────────
36
+ *
37
+ * The plugin captures failures from three sources, deduplicated to one
38
+ * record per failed user request:
39
+ *
40
+ * 1. Axios interceptor — full upstream request/response when a downstream
41
+ * HTTP call fails (timeouts, network errors, non-2xx).
42
+ * 2. onError hook — uncaught errors thrown during the request lifecycle
43
+ * (validation, controllers, hooks, serialization).
44
+ * 3. onSend hook — any reply with statusCode >= 400 sent without an
45
+ * exception (controllers that build error responses
46
+ * directly via reply.send/reply.code instead of
47
+ * throwing). Captures 404s from setNotFoundHandler too.
48
+ *
49
+ * Sources 1 and 2 mark the request via AsyncLocalStorage so source 3 skips
50
+ * recording a duplicate when the same request has already been captured.
34
51
  */
35
52
 
36
53
  const { FailureStore } = require('./failure-store');
37
- const { attachInterceptor, attachToAll } = require('./interceptor');
54
+ const { attachInterceptor, attachToAll, defaultSanitize } = require('./interceptor');
38
55
  const { registerRoutes } = require('./routes');
56
+ const { requestContext } = require('./request-context');
57
+
58
+ const CTX_KEY = Symbol.for('ps-observe.ctx');
39
59
 
40
60
  async function observerPlugin(fastify, opts) {
41
61
  const store = new FailureStore({
@@ -44,6 +64,7 @@ async function observerPlugin(fastify, opts) {
44
64
  });
45
65
 
46
66
  const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
67
+ const sanitize = opts.sanitize ?? defaultSanitize;
47
68
 
48
69
  // Decorate fastify with the observe API so consumers can attach clients
49
70
  fastify.decorate('observer', {
@@ -61,11 +82,21 @@ async function observerPlugin(fastify, opts) {
61
82
  store,
62
83
  });
63
84
 
85
+ // ── Per-request context (AsyncLocalStorage) ───────────────────────────────
86
+ // `enterWith` makes the store visible to the rest of the synchronous
87
+ // execution and any async operations spawned from it — so when the axios
88
+ // interceptor records a capture deep in the call stack, it can reach back
89
+ // and mark this request as already captured.
90
+ fastify.addHook('onRequest', (request, reply, done) => {
91
+ const ctx = { captured: false, startedAt: Date.now() };
92
+ request[CTX_KEY] = ctx;
93
+ requestContext.enterWith(ctx);
94
+ done();
95
+ });
96
+
64
97
  // ── Catch controller-level errors via onError hook ────────────────────────
65
98
  // This fires for ANY error thrown during the request lifecycle (controllers,
66
99
  // hooks, serialization, etc.) — not just upstream HTTP failures.
67
- const sanitize = opts.sanitize ?? require('./interceptor').defaultSanitize;
68
-
69
100
  fastify.addHook('onError', (request, reply, error, done) => {
70
101
  // Skip /observe routes to avoid recursive captures
71
102
  if (request.url.startsWith('/observe')) {
@@ -73,10 +104,8 @@ async function observerPlugin(fastify, opts) {
73
104
  return;
74
105
  }
75
106
 
76
- // Determine error source
77
- const isUpstream = error.name === 'UpstreamError' || error.name === 'UpstreamTimeoutError';
78
-
79
107
  // Skip upstream errors — they're already captured by the axios interceptor
108
+ const isUpstream = error.name === 'UpstreamError' || error.name === 'UpstreamTimeoutError';
80
109
  if (isUpstream) {
81
110
  done();
82
111
  return;
@@ -107,50 +136,125 @@ async function observerPlugin(fastify, opts) {
107
136
  response: null,
108
137
  });
109
138
 
139
+ const ctx = request[CTX_KEY];
140
+ if (ctx) ctx.captured = true;
141
+
110
142
  done();
111
143
  });
112
144
 
113
- // ── Catch 404s via onResponse hook ──────────────────────────────────────
114
- // 404s bypass onError because they're handled by setNotFoundHandler which
115
- // sends a reply directly without throwing. We catch them after the response.
116
- fastify.addHook('onResponse', (request, reply, done) => {
145
+ // ── Catch any 4xx/5xx reply via onSend ────────────────────────────────────
146
+ // Covers controllers that build error responses directly (reply.code(400)
147
+ // .send(...)) without throwing including the RFC-7807 problem+json
148
+ // pattern where the HTTP status is hard-coded to 400 and the semantic
149
+ // status (404, 502, 504, …) lives in the body. Also covers 404s from
150
+ // setNotFoundHandler since it sends a reply without throwing.
151
+ fastify.addHook('onSend', (request, reply, payload, done) => {
117
152
  if (request.url.startsWith('/observe')) {
118
- done();
153
+ done(null, payload);
119
154
  return;
120
155
  }
121
156
 
122
- if (reply.statusCode === 404) {
123
- store.add({
124
- type: 'controller',
125
- service: '404 Not Found',
126
- method: request.method,
127
- url: request.url,
128
- correlationId: request.correlationId ?? request.headers['x-correlation-id'] ?? null,
129
- durationMs: reply.elapsedTime != null ? Math.round(reply.elapsedTime) : null,
130
- errorMessage: `Route ${request.method} ${request.url} not found`,
131
- errorCode: null,
132
- errorName: 'NotFound',
133
- stack: null,
134
-
135
- request: {
136
- headers: sanitize(request.headers),
137
- params: request.params ?? null,
138
- query: request.query ?? null,
139
- body: sanitize(request.body),
140
- },
141
-
142
- statusCode: 404,
143
- response: null,
144
- });
157
+ if (reply.statusCode < 400) {
158
+ done(null, payload);
159
+ return;
145
160
  }
146
161
 
147
- done();
162
+ // Skip if the axios interceptor or onError hook already recorded this
163
+ // request — we want one record per failed user request.
164
+ const ctx = request[CTX_KEY];
165
+ if (ctx && ctx.captured) {
166
+ done(null, payload);
167
+ return;
168
+ }
169
+
170
+ // Try to surface the response body so the dashboard shows what was
171
+ // actually sent to the client.
172
+ const parsedBody = parsePayload(payload);
173
+ const semanticStatus = (parsedBody && typeof parsedBody === 'object' && Number.isFinite(parsedBody.status))
174
+ ? parsedBody.status
175
+ : reply.statusCode;
176
+ const errorTitle = (parsedBody && typeof parsedBody === 'object' && parsedBody.title)
177
+ || (reply.statusCode === 404 ? 'NotFound' : 'Error');
178
+ const errorDetail = (parsedBody && typeof parsedBody === 'object' && (parsedBody.detail || parsedBody.message))
179
+ || `HTTP ${reply.statusCode}`;
180
+
181
+ const startedAt = ctx?.startedAt ?? null;
182
+ const durationMs = startedAt
183
+ ? Date.now() - startedAt
184
+ : (reply.elapsedTime != null ? Math.round(reply.elapsedTime) : null);
185
+
186
+ store.add({
187
+ type: 'controller',
188
+ service: request.routeOptions?.url ?? request.url,
189
+ method: request.method,
190
+ url: request.url,
191
+ correlationId: request.correlationId ?? request.headers['x-correlation-id'] ?? null,
192
+ durationMs,
193
+ errorMessage: errorDetail,
194
+ errorCode: null,
195
+ errorName: errorTitle,
196
+ stack: null,
197
+
198
+ request: {
199
+ headers: sanitize(request.headers),
200
+ params: request.params ?? null,
201
+ query: request.query ?? null,
202
+ body: sanitize(request.body),
203
+ },
204
+
205
+ // Use the semantic status from the body when available so a 502 carried
206
+ // inside an HTTP-400 problem+json envelope is visible as 502 in the
207
+ // dashboard while still recording the raw HTTP code below.
208
+ statusCode: semanticStatus,
209
+ response: {
210
+ statusCode: reply.statusCode,
211
+ headers: safeReplyHeaders(reply),
212
+ body: sanitize(parsedBody),
213
+ },
214
+ });
215
+
216
+ if (ctx) ctx.captured = true;
217
+
218
+ done(null, payload);
148
219
  });
149
220
 
150
221
  // Register the dashboard and API routes
151
222
  registerRoutes(fastify, store);
152
223
  }
153
224
 
225
+ /**
226
+ * Parse a Fastify onSend payload into a plain JS value. Returns the original
227
+ * payload (or its string form) when JSON parsing fails or it is a stream.
228
+ */
229
+ function parsePayload(payload) {
230
+ if (payload == null) return null;
231
+
232
+ if (typeof payload === 'string') {
233
+ try { return JSON.parse(payload); } catch { return payload; }
234
+ }
235
+
236
+ if (Buffer.isBuffer(payload)) {
237
+ const str = payload.toString('utf8');
238
+ try { return JSON.parse(str); } catch { return str; }
239
+ }
240
+
241
+ // Stream or already-an-object — leave as-is. We don't read streams here
242
+ // because that would consume them before the client sees the response.
243
+ if (typeof payload === 'object' && typeof payload.pipe === 'function') {
244
+ return '[stream]';
245
+ }
246
+
247
+ return payload;
248
+ }
249
+
250
+ function safeReplyHeaders(reply) {
251
+ try {
252
+ return reply.getHeaders ? reply.getHeaders() : null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
154
258
  // Skip encapsulation so observer decorator is visible to the parent scope
155
259
  observerPlugin[Symbol.for('skip-override')] = true;
156
260
 
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @file lib/request-context.js
5
+ * @description Shared AsyncLocalStorage used to flow per-request state between
6
+ * the axios interceptor and the Fastify hooks.
7
+ *
8
+ * Without this, the axios interceptor cannot tell the response-level capture
9
+ * hook ("onSend") that an upstream failure was already recorded for the
10
+ * current request — leading to duplicate entries when an upstream 4xx is
11
+ * caught and translated by the controller into a 4xx reply.
12
+ */
13
+
14
+ const { AsyncLocalStorage } = require('async_hooks');
15
+
16
+ const requestContext = new AsyncLocalStorage();
17
+
18
+ module.exports = { requestContext };
package/lib/router.js CHANGED
@@ -42,8 +42,8 @@ function createRouter(store) {
42
42
 
43
43
  // ── List failures ──────────────────────────────────────────────────────
44
44
  if (method === 'GET' && path === '/observe/api/failures') {
45
- const { service, statusCode, method: m, url, from, to, limit, offset, type } = query || {};
46
- const result = store.query({ service, statusCode, method: m, url, from, to, limit, offset, type });
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 });
47
47
  return { status: 200, headers: { 'content-type': 'application/json' }, body: result };
48
48
  }
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-observe",
3
- "version": "1.0.2",
3
+ "version": "1.1.01",
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": [
@@ -9,7 +9,7 @@
9
9
  "http.js"
10
10
  ],
11
11
  "scripts": {
12
- "test": "node --test test/"
12
+ "test": "node --test test/*.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "fastify",
@@ -39,5 +39,9 @@
39
39
  "fastify-plugin": {
40
40
  "optional": true
41
41
  }
42
+ },
43
+ "devDependencies": {
44
+ "axios": "^1.6.0",
45
+ "fastify": "^4.0.0"
42
46
  }
43
47
  }