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 +2 -2
- package/lib/interceptor.js +10 -2
- package/lib/plugin.js +148 -44
- package/lib/request-context.js +18 -0
- package/package.json +6 -2
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.
|
|
43
|
+
fastify.observer.attach(myHttpClient);
|
|
44
44
|
|
|
45
45
|
// Option B: auto-attach to all clients on an object
|
|
46
|
-
fastify.
|
|
46
|
+
fastify.observer.attachToAll(fastify.services);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
await fastify.listen({ port: 3000 });
|
package/lib/interceptor.js
CHANGED
|
@@ -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.
|
|
37
|
-
client.
|
|
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
|
|
10
|
+
* const observer = require('api-observe');
|
|
11
11
|
*
|
|
12
|
-
* fastify.register(
|
|
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.
|
|
29
|
+
* fastify.observer.attach(myClient);
|
|
30
30
|
*
|
|
31
31
|
* // Option B: Auto-attach to all clients on an object
|
|
32
|
-
* fastify.
|
|
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
|
-
|
|
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('
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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 =
|
|
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
|
|
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
|
}
|