api-observe 1.0.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/express.js +3 -0
- package/http.js +3 -0
- package/lib/adapters/express.js +127 -0
- package/lib/adapters/http.js +133 -0
- package/lib/dashboard/index.js +568 -0
- package/lib/failure-store.js +159 -0
- package/lib/index.js +35 -0
- package/lib/interceptor.js +180 -0
- package/lib/plugin.js +157 -0
- package/lib/router.js +76 -0
- package/lib/routes.js +42 -0
- package/package.json +43 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/failure-store.js
|
|
5
|
+
* @description In-memory ring buffer that stores the last N API failure records.
|
|
6
|
+
*
|
|
7
|
+
* Each failure record contains full request/response details:
|
|
8
|
+
* - timestamp, service, method, url, correlationId
|
|
9
|
+
* - request: headers, params, body
|
|
10
|
+
* - response: statusCode, headers, body
|
|
11
|
+
* - durationMs, errorMessage
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
class FailureStore {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ maxEntries?: number, ttlMs?: number }} [opts]
|
|
17
|
+
*/
|
|
18
|
+
constructor(opts = {}) {
|
|
19
|
+
this._maxEntries = opts.maxEntries ?? 500;
|
|
20
|
+
this._ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000; // 24 hours
|
|
21
|
+
this._entries = [];
|
|
22
|
+
this._idCounter = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Add a failure record to the store.
|
|
27
|
+
* @param {object} failure
|
|
28
|
+
* @returns {object} The stored record (with id and timestamp).
|
|
29
|
+
*/
|
|
30
|
+
add(failure) {
|
|
31
|
+
const record = {
|
|
32
|
+
id: ++this._idCounter,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
...failure,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
this._entries.push(record);
|
|
38
|
+
|
|
39
|
+
// Trim to max size
|
|
40
|
+
if (this._entries.length > this._maxEntries) {
|
|
41
|
+
this._entries = this._entries.slice(-this._maxEntries);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
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]
|
|
50
|
+
* @returns {{ data: object[], total: number }}
|
|
51
|
+
*/
|
|
52
|
+
query(filters = {}) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
let results = this._entries.filter(e => {
|
|
55
|
+
// Evict expired entries on read
|
|
56
|
+
if (now - new Date(e.timestamp).getTime() > this._ttlMs) return false;
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Update internal store (lazy eviction)
|
|
61
|
+
this._entries = results;
|
|
62
|
+
|
|
63
|
+
// Apply filters
|
|
64
|
+
if (filters.type) {
|
|
65
|
+
const type = filters.type.toLowerCase();
|
|
66
|
+
results = results.filter(e => (e.type || 'upstream').toLowerCase() === type);
|
|
67
|
+
}
|
|
68
|
+
if (filters.service) {
|
|
69
|
+
const svc = filters.service.toLowerCase();
|
|
70
|
+
results = results.filter(e => e.service?.toLowerCase().includes(svc));
|
|
71
|
+
}
|
|
72
|
+
if (filters.statusCode) {
|
|
73
|
+
const code = Number(filters.statusCode);
|
|
74
|
+
results = results.filter(e => e.statusCode === code);
|
|
75
|
+
}
|
|
76
|
+
if (filters.method) {
|
|
77
|
+
const method = filters.method.toUpperCase();
|
|
78
|
+
results = results.filter(e => e.method?.toUpperCase() === method);
|
|
79
|
+
}
|
|
80
|
+
if (filters.url) {
|
|
81
|
+
const url = filters.url.toLowerCase();
|
|
82
|
+
results = results.filter(e => e.url?.toLowerCase().includes(url));
|
|
83
|
+
}
|
|
84
|
+
if (filters.from) {
|
|
85
|
+
const from = new Date(filters.from).getTime();
|
|
86
|
+
results = results.filter(e => new Date(e.timestamp).getTime() >= from);
|
|
87
|
+
}
|
|
88
|
+
if (filters.to) {
|
|
89
|
+
const to = new Date(filters.to).getTime();
|
|
90
|
+
results = results.filter(e => new Date(e.timestamp).getTime() <= to);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sort newest first
|
|
94
|
+
results.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
95
|
+
|
|
96
|
+
const total = results.length;
|
|
97
|
+
const offset = Number(filters.offset) || 0;
|
|
98
|
+
const limit = Number(filters.limit) || 50;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
data: results.slice(offset, offset + limit),
|
|
102
|
+
total,
|
|
103
|
+
limit,
|
|
104
|
+
offset,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get a single failure by ID.
|
|
110
|
+
* @param {number} id
|
|
111
|
+
* @returns {object|null}
|
|
112
|
+
*/
|
|
113
|
+
getById(id) {
|
|
114
|
+
return this._entries.find(e => e.id === Number(id)) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get summary stats grouped by service.
|
|
119
|
+
* @returns {object}
|
|
120
|
+
*/
|
|
121
|
+
summary() {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const active = this._entries.filter(
|
|
124
|
+
e => now - new Date(e.timestamp).getTime() <= this._ttlMs,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const byService = {};
|
|
128
|
+
const byStatus = {};
|
|
129
|
+
const byType = {};
|
|
130
|
+
|
|
131
|
+
for (const entry of active) {
|
|
132
|
+
const svc = entry.service || 'unknown';
|
|
133
|
+
byService[svc] = (byService[svc] || 0) + 1;
|
|
134
|
+
|
|
135
|
+
const code = entry.statusCode || 'unknown';
|
|
136
|
+
byStatus[code] = (byStatus[code] || 0) + 1;
|
|
137
|
+
|
|
138
|
+
const type = entry.type || 'upstream';
|
|
139
|
+
byType[type] = (byType[type] || 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
totalFailures: active.length,
|
|
144
|
+
byService,
|
|
145
|
+
byStatusCode: byStatus,
|
|
146
|
+
byType,
|
|
147
|
+
oldestEntry: active[0]?.timestamp ?? null,
|
|
148
|
+
newestEntry: active[active.length - 1]?.timestamp ?? null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Clear all entries. */
|
|
153
|
+
clear() {
|
|
154
|
+
this._entries = [];
|
|
155
|
+
this._idCounter = 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { FailureStore };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/index.js
|
|
5
|
+
* @description Entry point for ps-observe.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* - default: Fastify plugin (register this for Fastify apps)
|
|
9
|
+
* - expressMiddleware: Express/Connect middleware factory
|
|
10
|
+
* - createHttpHandler: Plain Node.js http handler factory
|
|
11
|
+
* - FailureStore: For standalone use without any framework
|
|
12
|
+
* - attachInterceptor: For manual attachment to a single BaseHttpClient
|
|
13
|
+
* - attachToAll: For auto-attachment to all clients on an object
|
|
14
|
+
* - createRouter: Framework-agnostic route handler factory
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const plugin = require('./plugin');
|
|
18
|
+
const { FailureStore } = require('./failure-store');
|
|
19
|
+
const { attachInterceptor, attachToAll } = require('./interceptor');
|
|
20
|
+
const { expressMiddleware } = require('./adapters/express');
|
|
21
|
+
const { createHttpHandler } = require('./adapters/http');
|
|
22
|
+
const { createRouter } = require('./router');
|
|
23
|
+
|
|
24
|
+
// Default export is the Fastify plugin (backwards compatible)
|
|
25
|
+
module.exports = plugin;
|
|
26
|
+
|
|
27
|
+
// Framework adapters
|
|
28
|
+
module.exports.expressMiddleware = expressMiddleware;
|
|
29
|
+
module.exports.createHttpHandler = createHttpHandler;
|
|
30
|
+
|
|
31
|
+
// Core building blocks
|
|
32
|
+
module.exports.FailureStore = FailureStore;
|
|
33
|
+
module.exports.attachInterceptor = attachInterceptor;
|
|
34
|
+
module.exports.attachToAll = attachToAll;
|
|
35
|
+
module.exports.createRouter = createRouter;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/interceptor.js
|
|
5
|
+
* @description Hooks into a BaseHttpClient's axios instance to capture API
|
|
6
|
+
* failures with full request/response details.
|
|
7
|
+
*
|
|
8
|
+
* The key challenge is interceptor ordering. BaseHttpClient registers its own
|
|
9
|
+
* response interceptor in its constructor, which converts axios errors into
|
|
10
|
+
* UpstreamError/UpstreamTimeoutError (losing the original .config and .response).
|
|
11
|
+
*
|
|
12
|
+
* To capture the raw error details, we eject the existing response interceptors,
|
|
13
|
+
* add ours first, then re-add the originals. This way our interceptor sees the
|
|
14
|
+
* original axios error with full request/response data before BaseHttpClient
|
|
15
|
+
* transforms it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attach the observe interceptor to a BaseHttpClient instance.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} client - A BaseHttpClient instance (has _axios and serviceName)
|
|
22
|
+
* @param {import('./failure-store').FailureStore} store
|
|
23
|
+
* @param {{ sanitize?: (data: any) => any }} [opts]
|
|
24
|
+
*/
|
|
25
|
+
function attachInterceptor(client, store, opts = {}) {
|
|
26
|
+
const sanitize = opts.sanitize ?? defaultSanitize;
|
|
27
|
+
const axiosInstance = client._axios;
|
|
28
|
+
|
|
29
|
+
if (!axiosInstance) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`ps-observe: Cannot attach interceptor — client "${client.serviceName}" has no _axios instance`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mark this client so we don't double-attach
|
|
36
|
+
if (client.__psObserveAttached) return;
|
|
37
|
+
client.__psObserveAttached = true;
|
|
38
|
+
|
|
39
|
+
// ── Eject existing response interceptors ───────────���────────────────────
|
|
40
|
+
// Save them, clear them, add ours first, then re-add the originals.
|
|
41
|
+
// This ensures our interceptor runs BEFORE BaseHttpClient's error handler.
|
|
42
|
+
const existingInterceptors = [];
|
|
43
|
+
axiosInstance.interceptors.response.handlers.forEach((handler) => {
|
|
44
|
+
if (handler) {
|
|
45
|
+
existingInterceptors.push(handler);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Eject all existing response interceptors
|
|
50
|
+
axiosInstance.interceptors.response.handlers.length = 0;
|
|
51
|
+
|
|
52
|
+
// ── Add our interceptor FIRST ───────────────────────────────────────────
|
|
53
|
+
axiosInstance.interceptors.response.use(
|
|
54
|
+
// Success — pass through, we only care about failures
|
|
55
|
+
response => response,
|
|
56
|
+
|
|
57
|
+
// Error — capture failure details then re-throw the ORIGINAL error
|
|
58
|
+
// so BaseHttpClient's interceptor can still transform it as usual
|
|
59
|
+
error => {
|
|
60
|
+
const config = error.config || {};
|
|
61
|
+
const response = error.response;
|
|
62
|
+
const durationMs = Date.now() - (config.metadata?.startMs ?? Date.now());
|
|
63
|
+
|
|
64
|
+
const record = {
|
|
65
|
+
type: 'upstream',
|
|
66
|
+
service: client.serviceName,
|
|
67
|
+
method: config.method?.toUpperCase() ?? 'UNKNOWN',
|
|
68
|
+
url: buildFullUrl(config),
|
|
69
|
+
correlationId: config.correlationId ?? config.headers?.['x-correlation-id'] ?? null,
|
|
70
|
+
durationMs,
|
|
71
|
+
errorMessage: error.message,
|
|
72
|
+
errorCode: error.code ?? null,
|
|
73
|
+
|
|
74
|
+
// Request details
|
|
75
|
+
request: {
|
|
76
|
+
headers: sanitize(config.headers),
|
|
77
|
+
params: config.params ?? null,
|
|
78
|
+
body: sanitize(config.data),
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Response details (null if no response, e.g. timeout/network error)
|
|
82
|
+
statusCode: response?.status ?? null,
|
|
83
|
+
response: response
|
|
84
|
+
? {
|
|
85
|
+
headers: response.headers ?? null,
|
|
86
|
+
body: sanitize(response.data),
|
|
87
|
+
}
|
|
88
|
+
: null,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
store.add(record);
|
|
92
|
+
|
|
93
|
+
// Re-throw the original error so BaseHttpClient's handler still works
|
|
94
|
+
throw error;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// ── Re-add the original interceptors AFTER ours ─────────────���───────────
|
|
99
|
+
for (const handler of existingInterceptors) {
|
|
100
|
+
axiosInstance.interceptors.response.handlers.push(handler);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Attach the interceptor to every BaseHttpClient found on a service instance.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} serviceContainer - Object whose values may be BaseHttpClient instances
|
|
108
|
+
* @param {import('./failure-store').FailureStore} store
|
|
109
|
+
* @param {{ sanitize?: (data: any) => any }} [opts]
|
|
110
|
+
*/
|
|
111
|
+
function attachToAll(serviceContainer, store, opts = {}) {
|
|
112
|
+
for (const key of Object.keys(serviceContainer)) {
|
|
113
|
+
const val = serviceContainer[key];
|
|
114
|
+
|
|
115
|
+
// Direct BaseHttpClient
|
|
116
|
+
if (isHttpClient(val)) {
|
|
117
|
+
attachInterceptor(val, store, opts);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Service wrapper that has a .client property (e.g. PsPartiesService)
|
|
122
|
+
if (val && isHttpClient(val.client)) {
|
|
123
|
+
attachInterceptor(val.client, store, opts);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ��─ Helpers ───────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function isHttpClient(obj) {
|
|
131
|
+
return obj && typeof obj === 'object' && obj._axios && obj.serviceName;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildFullUrl(config) {
|
|
135
|
+
const base = config.baseURL ?? '';
|
|
136
|
+
const path = config.url ?? '';
|
|
137
|
+
if (base && path) return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
|
138
|
+
return base || path || 'unknown';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Default sanitizer — redacts common sensitive headers/fields.
|
|
143
|
+
*/
|
|
144
|
+
function defaultSanitize(data) {
|
|
145
|
+
if (!data || typeof data !== 'object') return data;
|
|
146
|
+
|
|
147
|
+
const REDACTED = '[REDACTED]';
|
|
148
|
+
const SENSITIVE_KEYS = new Set([
|
|
149
|
+
'authorization',
|
|
150
|
+
'x-access-token',
|
|
151
|
+
'cookie',
|
|
152
|
+
'set-cookie',
|
|
153
|
+
'secret',
|
|
154
|
+
'password',
|
|
155
|
+
'token',
|
|
156
|
+
'refresh',
|
|
157
|
+
'x-api-key',
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
// Handle string data (e.g. serialized JSON body)
|
|
161
|
+
if (typeof data === 'string') {
|
|
162
|
+
try {
|
|
163
|
+
data = JSON.parse(data);
|
|
164
|
+
} catch {
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sanitized = Array.isArray(data) ? [...data] : { ...data };
|
|
170
|
+
|
|
171
|
+
for (const key of Object.keys(sanitized)) {
|
|
172
|
+
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
|
|
173
|
+
sanitized[key] = REDACTED;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return sanitized;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { attachInterceptor, attachToAll, defaultSanitize };
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/plugin.js
|
|
5
|
+
* @description Fastify plugin that wires up the failure store, interceptors,
|
|
6
|
+
* and dashboard routes.
|
|
7
|
+
*
|
|
8
|
+
* Usage in a consuming service:
|
|
9
|
+
*
|
|
10
|
+
* const psObserve = require('@pruservices/ps-observe');
|
|
11
|
+
*
|
|
12
|
+
* fastify.register(psObserve, {
|
|
13
|
+
* // (optional) max failure records to keep in memory (default: 500)
|
|
14
|
+
* maxEntries: 500,
|
|
15
|
+
*
|
|
16
|
+
* // (optional) TTL in ms before entries are evicted (default: 24h)
|
|
17
|
+
* ttlMs: 24 * 60 * 60 * 1000,
|
|
18
|
+
*
|
|
19
|
+
* // (optional) custom sanitizer for request/response bodies
|
|
20
|
+
* sanitize: (data) => data,
|
|
21
|
+
*
|
|
22
|
+
* // (optional) prefix for observe routes (default: '')
|
|
23
|
+
* prefix: '',
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // After registering your service clients, attach the interceptors:
|
|
27
|
+
* fastify.after(() => {
|
|
28
|
+
* // Option A: Attach to individual clients
|
|
29
|
+
* fastify.psObserve.attach(myClient);
|
|
30
|
+
*
|
|
31
|
+
* // Option B: Auto-attach to all clients on an object
|
|
32
|
+
* fastify.psObserve.attachToAll(fastify.services);
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { FailureStore } = require('./failure-store');
|
|
37
|
+
const { attachInterceptor, attachToAll } = require('./interceptor');
|
|
38
|
+
const { registerRoutes } = require('./routes');
|
|
39
|
+
|
|
40
|
+
async function psObservePlugin(fastify, opts) {
|
|
41
|
+
const store = new FailureStore({
|
|
42
|
+
maxEntries: opts.maxEntries,
|
|
43
|
+
ttlMs: opts.ttlMs,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const sanitizeOpts = opts.sanitize ? { sanitize: opts.sanitize } : {};
|
|
47
|
+
|
|
48
|
+
// Decorate fastify with the observe API so consumers can attach clients
|
|
49
|
+
fastify.decorate('psObserve', {
|
|
50
|
+
/** Attach interceptor to a single BaseHttpClient instance */
|
|
51
|
+
attach(client) {
|
|
52
|
+
attachInterceptor(client, store, sanitizeOpts);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Auto-attach to all BaseHttpClient instances found on an object */
|
|
56
|
+
attachToAll(serviceContainer) {
|
|
57
|
+
attachToAll(serviceContainer, store, sanitizeOpts);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/** Direct access to the failure store (for advanced use) */
|
|
61
|
+
store,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Catch controller-level errors via onError hook ────────────────────────
|
|
65
|
+
// This fires for ANY error thrown during the request lifecycle (controllers,
|
|
66
|
+
// hooks, serialization, etc.) — not just upstream HTTP failures.
|
|
67
|
+
const sanitize = opts.sanitize ?? require('./interceptor').defaultSanitize;
|
|
68
|
+
|
|
69
|
+
fastify.addHook('onError', (request, reply, error, done) => {
|
|
70
|
+
// Skip /observe routes to avoid recursive captures
|
|
71
|
+
if (request.url.startsWith('/observe')) {
|
|
72
|
+
done();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Determine error source
|
|
77
|
+
const isUpstream = error.name === 'UpstreamError' || error.name === 'UpstreamTimeoutError';
|
|
78
|
+
|
|
79
|
+
// Skip upstream errors — they're already captured by the axios interceptor
|
|
80
|
+
if (isUpstream) {
|
|
81
|
+
done();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
store.add({
|
|
86
|
+
type: 'controller',
|
|
87
|
+
service: request.routeOptions?.url ?? request.url,
|
|
88
|
+
method: request.method,
|
|
89
|
+
url: request.url,
|
|
90
|
+
correlationId: request.correlationId ?? request.headers['x-correlation-id'] ?? null,
|
|
91
|
+
durationMs: null,
|
|
92
|
+
errorMessage: error.message,
|
|
93
|
+
errorCode: error.code ?? null,
|
|
94
|
+
errorName: error.name ?? 'Error',
|
|
95
|
+
stack: error.stack ?? null,
|
|
96
|
+
|
|
97
|
+
// Incoming request details
|
|
98
|
+
request: {
|
|
99
|
+
headers: sanitize(request.headers),
|
|
100
|
+
params: request.params ?? null,
|
|
101
|
+
query: request.query ?? null,
|
|
102
|
+
body: sanitize(request.body),
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// No upstream response for controller errors
|
|
106
|
+
statusCode: error.statusCode ?? 500,
|
|
107
|
+
response: null,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
done();
|
|
111
|
+
});
|
|
112
|
+
|
|
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) => {
|
|
117
|
+
if (request.url.startsWith('/observe')) {
|
|
118
|
+
done();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
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
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
done();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Register the dashboard and API routes
|
|
151
|
+
registerRoutes(fastify, store);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Skip encapsulation so psObserve decorator is visible to the parent scope
|
|
155
|
+
psObservePlugin[Symbol.for('skip-override')] = true;
|
|
156
|
+
|
|
157
|
+
module.exports = psObservePlugin;
|
package/lib/router.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/router.js
|
|
5
|
+
* @description Framework-agnostic route handler for observe endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Each handler receives parsed request info and returns a plain response object:
|
|
8
|
+
* { status: number, headers: object, body: string|object }
|
|
9
|
+
*
|
|
10
|
+
* Framework adapters (Fastify, Express, plain HTTP) call into this router
|
|
11
|
+
* so the actual route logic lives in one place.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { getDashboardHtml } = require('./dashboard/index');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a router bound to a FailureStore instance.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('./failure-store').FailureStore} store
|
|
20
|
+
* @returns {function} routeHandler(method, pathname, query, params) => response
|
|
21
|
+
*/
|
|
22
|
+
function createRouter(store) {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} method - HTTP method (uppercase)
|
|
25
|
+
* @param {string} pathname - URL path, e.g. "/observe/api/failures/3"
|
|
26
|
+
* @param {object} query - Parsed query parameters
|
|
27
|
+
* @returns {{ status: number, headers: object, body: any } | null}
|
|
28
|
+
* null means "no matching route"
|
|
29
|
+
*/
|
|
30
|
+
return function routeHandler(method, pathname, query) {
|
|
31
|
+
// Normalise: strip trailing slash (except root "/observe")
|
|
32
|
+
const path = pathname.replace(/\/$/, '') || '/';
|
|
33
|
+
|
|
34
|
+
// ── Dashboard ──────────────────────────────────────────────────────────
|
|
35
|
+
if (method === 'GET' && path === '/observe') {
|
|
36
|
+
return {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
39
|
+
body: getDashboardHtml(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── List failures ──────────────────────────────────────────────────────
|
|
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 });
|
|
47
|
+
return { status: 200, headers: { 'content-type': 'application/json' }, body: result };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Single failure detail ──────────────────────────────────────────────
|
|
51
|
+
const detailMatch = path.match(/^\/observe\/api\/failures\/(\d+)$/);
|
|
52
|
+
if (method === 'GET' && detailMatch) {
|
|
53
|
+
const record = store.getById(detailMatch[1]);
|
|
54
|
+
if (!record) {
|
|
55
|
+
return { status: 404, headers: { 'content-type': 'application/json' }, body: { error: 'Failure record not found' } };
|
|
56
|
+
}
|
|
57
|
+
return { status: 200, headers: { 'content-type': 'application/json' }, body: record };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Summary stats ──────────────────────────────────────────────────────
|
|
61
|
+
if (method === 'GET' && path === '/observe/api/summary') {
|
|
62
|
+
return { status: 200, headers: { 'content-type': 'application/json' }, body: store.summary() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Clear all ──────────────────────────────────────────────────────────
|
|
66
|
+
if (method === 'DELETE' && path === '/observe/api/failures') {
|
|
67
|
+
store.clear();
|
|
68
|
+
return { status: 200, headers: { 'content-type': 'application/json' }, body: { cleared: true } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// No matching route
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { createRouter };
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file lib/routes.js
|
|
5
|
+
* @description Fastify-specific route registration using the generic router.
|
|
6
|
+
*
|
|
7
|
+
* GET /observe — HTML dashboard
|
|
8
|
+
* GET /observe/api/failures — List failures (with filters)
|
|
9
|
+
* GET /observe/api/failures/:id — Single failure detail
|
|
10
|
+
* GET /observe/api/summary — Failure stats grouped by service / status code
|
|
11
|
+
* DELETE /observe/api/failures — Clear all stored failures
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { createRouter } = require('./router');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register observe routes on a Fastify instance.
|
|
18
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
19
|
+
* @param {import('./failure-store').FailureStore} store
|
|
20
|
+
*/
|
|
21
|
+
function registerRoutes(fastify, store) {
|
|
22
|
+
const router = createRouter(store);
|
|
23
|
+
|
|
24
|
+
// Catch-all handler for /observe routes
|
|
25
|
+
const handler = (req, reply) => {
|
|
26
|
+
const url = new URL(req.url, 'http://localhost');
|
|
27
|
+
const result = router(req.method, url.pathname, req.query);
|
|
28
|
+
if (!result) {
|
|
29
|
+
reply.code(404).send({ error: 'Not found' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
reply.code(result.status).headers(result.headers).send(result.body);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
fastify.get('/observe', handler);
|
|
36
|
+
fastify.get('/observe/api/failures', handler);
|
|
37
|
+
fastify.get('/observe/api/failures/:id', handler);
|
|
38
|
+
fastify.get('/observe/api/summary', handler);
|
|
39
|
+
fastify.delete('/observe/api/failures', handler);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { registerRoutes };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "api-observe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Framework-agnostic plugin that captures and displays API failure details — works with Fastify, Express, NestJS, Koa, or plain Node.js HTTP",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib/",
|
|
8
|
+
"express.js",
|
|
9
|
+
"http.js"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test test/"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"fastify",
|
|
16
|
+
"express",
|
|
17
|
+
"nestjs",
|
|
18
|
+
"koa",
|
|
19
|
+
"observability",
|
|
20
|
+
"api-failures",
|
|
21
|
+
"monitoring",
|
|
22
|
+
"error-tracking",
|
|
23
|
+
"middleware"
|
|
24
|
+
],
|
|
25
|
+
"author": "darenjob",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/darenjob/api-observe"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"fastify": ">=4.0.0",
|
|
33
|
+
"fastify-plugin": ">=4.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"fastify": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"fastify-plugin": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|