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.
@@ -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
+ }