api-observe 1.0.1 → 1.1.0

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
@@ -40,10 +40,10 @@ await fastify.register(apiObserve, {
40
40
  // Attach to your HTTP clients after registration
41
41
  fastify.after(() => {
42
42
  // Option A: single client
43
- fastify.psObserve.attach(myHttpClient);
43
+ fastify.observer.attach(myHttpClient);
44
44
 
45
45
  // Option B: auto-attach to all clients on an object
46
- fastify.psObserve.attachToAll(fastify.services);
46
+ fastify.observer.attachToAll(fastify.services);
47
47
  });
48
48
 
49
49
  await fastify.listen({ port: 3000 });
@@ -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
  *
@@ -33,8 +35,8 @@ function attachInterceptor(client, store, opts = {}) {
33
35
  }
34
36
 
35
37
  // Mark this client so we don't double-attach
36
- if (client.__psObserveAttached) return;
37
- client.__psObserveAttached = true;
38
+ if (client.__observerAttached) return;
39
+ client.__observerAttached = true;
38
40
 
39
41
  // ── Eject existing response interceptors ───────────���────────────────────
40
42
  // Save them, clear them, add ours first, then re-add the originals.
@@ -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,9 +7,9 @@
7
7
  *
8
8
  * Usage in a consuming service:
9
9
  *
10
- * const psObserve = require('@pruservices/ps-observe');
10
+ * const observer = require('api-observe');
11
11
  *
12
- * fastify.register(psObserve, {
12
+ * fastify.register(observer, {
13
13
  * // (optional) max failure records to keep in memory (default: 500)
14
14
  * maxEntries: 500,
15
15
  *
@@ -26,27 +26,48 @@
26
26
  * // After registering your service clients, attach the interceptors:
27
27
  * fastify.after(() => {
28
28
  * // Option A: Attach to individual clients
29
- * fastify.psObserve.attach(myClient);
29
+ * fastify.observer.attach(myClient);
30
30
  *
31
31
  * // Option B: Auto-attach to all clients on an object
32
- * fastify.psObserve.attachToAll(fastify.services);
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');
39
57
 
40
- async function psObservePlugin(fastify, opts) {
58
+ const CTX_KEY = Symbol.for('ps-observe.ctx');
59
+
60
+ async function observerPlugin(fastify, opts) {
41
61
  const store = new FailureStore({
42
62
  maxEntries: opts.maxEntries,
43
63
  ttlMs: opts.ttlMs,
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
- fastify.decorate('psObserve', {
70
+ fastify.decorate('observer', {
50
71
  /** Attach interceptor to a single BaseHttpClient instance */
51
72
  attach(client) {
52
73
  attachInterceptor(client, store, sanitizeOpts);
@@ -61,11 +82,21 @@ async function psObservePlugin(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 psObservePlugin(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,51 +136,126 @@ async function psObservePlugin(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
 
154
- // Skip encapsulation so psObserve decorator is visible to the parent scope
155
- psObservePlugin[Symbol.for('skip-override')] = true;
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
+
258
+ // Skip encapsulation so observer decorator is visible to the parent scope
259
+ observerPlugin[Symbol.for('skip-override')] = true;
156
260
 
157
- module.exports = psObservePlugin;
261
+ module.exports = observerPlugin;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-observe",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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
  }