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.
- package/lib/dashboard/index.js +5 -2
- package/lib/failure-store.js +5 -1
- package/lib/interceptor.js +8 -0
- package/lib/plugin.js +140 -36
- package/lib/request-context.js +18 -0
- package/lib/router.js +2 -2
- package/package.json +6 -2
package/lib/dashboard/index.js
CHANGED
|
@@ -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>
|
|
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>
|
|
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
|
|
package/lib/failure-store.js
CHANGED
|
@@ -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);
|
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
|
*
|
|
@@ -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('
|
|
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
|
|
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
|
|
|
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.
|
|
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
|
}
|