margin-ts 0.6.0 → 0.6.2
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/dist/adapters/express.d.ts +80 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +237 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/nextjs.d.ts +65 -0
- package/dist/adapters/nextjs.d.ts.map +1 -0
- package/dist/adapters/nextjs.js +233 -0
- package/dist/adapters/nextjs.js.map +1 -0
- package/dist/adapters/stream.d.ts +74 -0
- package/dist/adapters/stream.d.ts.map +1 -0
- package/dist/adapters/stream.js +187 -0
- package/dist/adapters/stream.js.map +1 -0
- package/dist/adapters/vitest.d.ts +36 -0
- package/dist/adapters/vitest.d.ts.map +1 -0
- package/dist/adapters/vitest.js +104 -0
- package/dist/adapters/vitest.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/express.ts +342 -0
- package/src/adapters/nextjs.ts +290 -0
- package/src/adapters/stream.ts +227 -0
- package/src/adapters/vitest.ts +141 -0
- package/src/index.ts +27 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware adapter for margin.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-route health: latency, error rate, request rate.
|
|
5
|
+
* Exposes /margin/health endpoint with typed classifications.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import express from 'express';
|
|
9
|
+
* import { marginMiddleware, marginHealthRoute } from 'margin-ts/adapters/express';
|
|
10
|
+
*
|
|
11
|
+
* const app = express();
|
|
12
|
+
* app.use(marginMiddleware());
|
|
13
|
+
* app.get('/margin/health', marginHealthRoute());
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies beyond margin-ts core (express types are optional).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Confidence,
|
|
20
|
+
Health,
|
|
21
|
+
Thresholds,
|
|
22
|
+
createThresholds,
|
|
23
|
+
classify,
|
|
24
|
+
Observation,
|
|
25
|
+
Expression,
|
|
26
|
+
createExpression,
|
|
27
|
+
observationSigma,
|
|
28
|
+
expressionToString,
|
|
29
|
+
expressionToDict,
|
|
30
|
+
DriftState,
|
|
31
|
+
DriftDirection,
|
|
32
|
+
classifyDrift,
|
|
33
|
+
AnomalyState,
|
|
34
|
+
classifyAnomaly,
|
|
35
|
+
} from '../index.js';
|
|
36
|
+
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
// Route metrics tracking
|
|
39
|
+
// -----------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
interface RouteMetrics {
|
|
42
|
+
route: string;
|
|
43
|
+
totalRequests: number;
|
|
44
|
+
totalErrors: number; // status >= 500
|
|
45
|
+
total4xx: number; // status 400-499
|
|
46
|
+
latencies: number[]; // last N response times in ms
|
|
47
|
+
timestamps: Date[]; // when each request happened
|
|
48
|
+
maxWindow: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createRouteMetrics(route: string, maxWindow = 200): RouteMetrics {
|
|
52
|
+
return {
|
|
53
|
+
route,
|
|
54
|
+
totalRequests: 0,
|
|
55
|
+
totalErrors: 0,
|
|
56
|
+
total4xx: 0,
|
|
57
|
+
latencies: [],
|
|
58
|
+
timestamps: [],
|
|
59
|
+
maxWindow,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recordRequest(rm: RouteMetrics, latencyMs: number, statusCode: number): void {
|
|
64
|
+
rm.totalRequests++;
|
|
65
|
+
if (statusCode >= 500) rm.totalErrors++;
|
|
66
|
+
if (statusCode >= 400 && statusCode < 500) rm.total4xx++;
|
|
67
|
+
rm.latencies.push(latencyMs);
|
|
68
|
+
rm.timestamps.push(new Date());
|
|
69
|
+
if (rm.latencies.length > rm.maxWindow) {
|
|
70
|
+
rm.latencies.shift();
|
|
71
|
+
rm.timestamps.shift();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -----------------------------------------------------------------------
|
|
76
|
+
// Default thresholds
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface EndpointThresholds {
|
|
80
|
+
p50Latency: Thresholds;
|
|
81
|
+
p99Latency: Thresholds;
|
|
82
|
+
errorRate: Thresholds;
|
|
83
|
+
requestRate: Thresholds;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const DEFAULT_THRESHOLDS: EndpointThresholds = {
|
|
87
|
+
p50Latency: createThresholds(100, 500, false), // ms, lower is better
|
|
88
|
+
p99Latency: createThresholds(500, 2000, false), // ms, lower is better
|
|
89
|
+
errorRate: createThresholds(0.01, 0.10, false), // ratio, lower is better
|
|
90
|
+
requestRate: createThresholds(1.0, 0.1, true), // req/s, higher is better
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
// Metrics → Observations
|
|
95
|
+
// -----------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function percentile(sorted: number[], p: number): number {
|
|
98
|
+
if (sorted.length === 0) return 0;
|
|
99
|
+
const idx = Math.ceil(sorted.length * p) - 1;
|
|
100
|
+
return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function classifyRoute(
|
|
104
|
+
rm: RouteMetrics,
|
|
105
|
+
thresholds: EndpointThresholds,
|
|
106
|
+
now: Date,
|
|
107
|
+
): Observation[] {
|
|
108
|
+
if (rm.latencies.length < 3) return [];
|
|
109
|
+
|
|
110
|
+
const sorted = [...rm.latencies].sort((a, b) => a - b);
|
|
111
|
+
const p50 = percentile(sorted, 0.5);
|
|
112
|
+
const p99 = percentile(sorted, 0.99);
|
|
113
|
+
const errorRate = rm.totalRequests > 0 ? rm.totalErrors / rm.totalRequests : 0;
|
|
114
|
+
|
|
115
|
+
// Request rate: requests in window / window duration
|
|
116
|
+
let requestRate = 0;
|
|
117
|
+
if (rm.timestamps.length >= 2) {
|
|
118
|
+
const windowMs = rm.timestamps[rm.timestamps.length - 1].getTime() - rm.timestamps[0].getTime();
|
|
119
|
+
if (windowMs > 0) requestRate = (rm.timestamps.length / windowMs) * 1000;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const prefix = rm.route === '*' ? '' : `${rm.route}:`;
|
|
123
|
+
|
|
124
|
+
const observations: Observation[] = [
|
|
125
|
+
{
|
|
126
|
+
name: `${prefix}p50_latency`,
|
|
127
|
+
health: classify(p50, Confidence.HIGH, thresholds.p50Latency),
|
|
128
|
+
value: p50,
|
|
129
|
+
baseline: thresholds.p50Latency.intact,
|
|
130
|
+
confidence: Confidence.HIGH,
|
|
131
|
+
higherIsBetter: false,
|
|
132
|
+
provenance: [],
|
|
133
|
+
measuredAt: now,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: `${prefix}p99_latency`,
|
|
137
|
+
health: classify(p99, Confidence.HIGH, thresholds.p99Latency),
|
|
138
|
+
value: p99,
|
|
139
|
+
baseline: thresholds.p99Latency.intact,
|
|
140
|
+
confidence: Confidence.HIGH,
|
|
141
|
+
higherIsBetter: false,
|
|
142
|
+
provenance: [],
|
|
143
|
+
measuredAt: now,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: `${prefix}error_rate`,
|
|
147
|
+
health: classify(errorRate, Confidence.HIGH, thresholds.errorRate),
|
|
148
|
+
value: errorRate,
|
|
149
|
+
baseline: 0.001,
|
|
150
|
+
confidence: Confidence.HIGH,
|
|
151
|
+
higherIsBetter: false,
|
|
152
|
+
provenance: [],
|
|
153
|
+
measuredAt: now,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: `${prefix}request_rate`,
|
|
157
|
+
health: classify(requestRate, Confidence.MODERATE, thresholds.requestRate),
|
|
158
|
+
value: requestRate,
|
|
159
|
+
baseline: 10.0,
|
|
160
|
+
confidence: Confidence.MODERATE,
|
|
161
|
+
higherIsBetter: true,
|
|
162
|
+
provenance: [],
|
|
163
|
+
measuredAt: now,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
return observations;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Middleware
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export interface MarginMiddlewareOptions {
|
|
175
|
+
/** Track per-route or aggregate only (default: true) */
|
|
176
|
+
perRoute?: boolean;
|
|
177
|
+
/** Max latency samples to keep per route (default: 200) */
|
|
178
|
+
window?: number;
|
|
179
|
+
/** Custom thresholds */
|
|
180
|
+
thresholds?: EndpointThresholds;
|
|
181
|
+
/** Routes to ignore (e.g. ['/margin/health', '/favicon.ico']) */
|
|
182
|
+
ignore?: string[];
|
|
183
|
+
/** Normalize route paths: replace numeric segments with :id (default: true) */
|
|
184
|
+
normalizePaths?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The shared state object — accessible for custom endpoints */
|
|
188
|
+
export interface MarginState {
|
|
189
|
+
routes: Map<string, RouteMetrics>;
|
|
190
|
+
aggregate: RouteMetrics;
|
|
191
|
+
options: Required<MarginMiddlewareOptions>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizePath(path: string): string {
|
|
195
|
+
return path.replace(/\/\d+/g, '/:id').replace(/\/[0-9a-f]{24}/g, '/:id');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Express middleware that tracks request latency and error rates.
|
|
200
|
+
*
|
|
201
|
+
* Attach to your app before routes:
|
|
202
|
+
* app.use(marginMiddleware());
|
|
203
|
+
*/
|
|
204
|
+
export function marginMiddleware(options: MarginMiddlewareOptions = {}): any {
|
|
205
|
+
const opts: Required<MarginMiddlewareOptions> = {
|
|
206
|
+
perRoute: options.perRoute ?? true,
|
|
207
|
+
window: options.window ?? 200,
|
|
208
|
+
thresholds: options.thresholds ?? DEFAULT_THRESHOLDS,
|
|
209
|
+
ignore: options.ignore ?? ['/margin/health', '/favicon.ico'],
|
|
210
|
+
normalizePaths: options.normalizePaths ?? true,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const state: MarginState = {
|
|
214
|
+
routes: new Map(),
|
|
215
|
+
aggregate: createRouteMetrics('*', opts.window),
|
|
216
|
+
options: opts,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const middleware = (req: any, res: any, next: any) => {
|
|
220
|
+
const path: string = req.path || req.url || '/';
|
|
221
|
+
|
|
222
|
+
if (opts.ignore.includes(path)) {
|
|
223
|
+
next();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const start = Date.now();
|
|
228
|
+
|
|
229
|
+
// Hook into response finish
|
|
230
|
+
const onFinish = () => {
|
|
231
|
+
res.removeListener('finish', onFinish);
|
|
232
|
+
const latency = Date.now() - start;
|
|
233
|
+
const status: number = res.statusCode || 200;
|
|
234
|
+
|
|
235
|
+
// Aggregate
|
|
236
|
+
recordRequest(state.aggregate, latency, status);
|
|
237
|
+
|
|
238
|
+
// Per-route
|
|
239
|
+
if (opts.perRoute) {
|
|
240
|
+
const route = opts.normalizePaths ? normalizePath(path) : path;
|
|
241
|
+
if (!state.routes.has(route)) {
|
|
242
|
+
state.routes.set(route, createRouteMetrics(route, opts.window));
|
|
243
|
+
}
|
|
244
|
+
recordRequest(state.routes.get(route)!, latency, status);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
res.on('finish', onFinish);
|
|
249
|
+
next();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Attach state to middleware for access by health route
|
|
253
|
+
(middleware as any).__marginState = state;
|
|
254
|
+
|
|
255
|
+
return middleware;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// -----------------------------------------------------------------------
|
|
259
|
+
// Health endpoint
|
|
260
|
+
// -----------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
export interface HealthResponse {
|
|
263
|
+
status: string;
|
|
264
|
+
expression: string;
|
|
265
|
+
aggregate: Record<string, unknown>;
|
|
266
|
+
routes?: Record<string, Record<string, unknown>>;
|
|
267
|
+
drift?: Record<string, unknown>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Express route handler for /margin/health.
|
|
272
|
+
*
|
|
273
|
+
* Returns typed health classification for all tracked routes.
|
|
274
|
+
*
|
|
275
|
+
* app.get('/margin/health', marginHealthRoute());
|
|
276
|
+
*
|
|
277
|
+
* Or pass the middleware directly:
|
|
278
|
+
* const mw = marginMiddleware();
|
|
279
|
+
* app.use(mw);
|
|
280
|
+
* app.get('/margin/health', marginHealthRoute(mw));
|
|
281
|
+
*/
|
|
282
|
+
export function marginHealthRoute(middleware?: any): any {
|
|
283
|
+
return (req: any, res: any) => {
|
|
284
|
+
// Find state from middleware
|
|
285
|
+
let state: MarginState | undefined;
|
|
286
|
+
if (middleware && (middleware as any).__marginState) {
|
|
287
|
+
state = (middleware as any).__marginState;
|
|
288
|
+
} else if (req.app) {
|
|
289
|
+
// Walk through app middleware stack to find ours
|
|
290
|
+
const stack = req.app._router?.stack || [];
|
|
291
|
+
for (const layer of stack) {
|
|
292
|
+
if (layer.handle?.__marginState) {
|
|
293
|
+
state = layer.handle.__marginState;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!state) {
|
|
300
|
+
res.status(500).json({ error: 'margin middleware not found' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const now = new Date();
|
|
305
|
+
const thresholds = state.options.thresholds;
|
|
306
|
+
|
|
307
|
+
// Aggregate health
|
|
308
|
+
const aggObs = classifyRoute(state.aggregate, thresholds, now);
|
|
309
|
+
const aggExpr = createExpression(aggObs, [], 'aggregate');
|
|
310
|
+
|
|
311
|
+
// Determine overall status
|
|
312
|
+
const SEVERITY_MAP: Record<Health, number> = {
|
|
313
|
+
[Health.INTACT]: 0, [Health.RECOVERING]: 1, [Health.DEGRADED]: 2,
|
|
314
|
+
[Health.ABLATED]: 3, [Health.OOD]: 4,
|
|
315
|
+
};
|
|
316
|
+
const worstHealth = aggObs.reduce(
|
|
317
|
+
(worst, o) => SEVERITY_MAP[o.health] > SEVERITY_MAP[worst] ? o.health : worst,
|
|
318
|
+
Health.INTACT,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const response: HealthResponse = {
|
|
322
|
+
status: worstHealth,
|
|
323
|
+
expression: expressionToString(aggExpr),
|
|
324
|
+
aggregate: expressionToDict(aggExpr),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Per-route health
|
|
328
|
+
if (state.options.perRoute && state.routes.size > 0) {
|
|
329
|
+
const routes: Record<string, Record<string, unknown>> = {};
|
|
330
|
+
for (const [route, metrics] of state.routes) {
|
|
331
|
+
const obs = classifyRoute(metrics, thresholds, now);
|
|
332
|
+
if (obs.length > 0) {
|
|
333
|
+
const expr = createExpression(obs, [], route);
|
|
334
|
+
routes[route] = expressionToDict(expr);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
response.routes = routes;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
res.json(response);
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js API route adapter for margin.
|
|
3
|
+
*
|
|
4
|
+
* One-liner health tracking for any API route:
|
|
5
|
+
*
|
|
6
|
+
* // pages/api/users.ts (Pages Router)
|
|
7
|
+
* import { withMargin } from 'margin-ts/adapters/nextjs';
|
|
8
|
+
* export default withMargin(handler);
|
|
9
|
+
*
|
|
10
|
+
* // app/api/users/route.ts (App Router)
|
|
11
|
+
* import { withMarginApp } from 'margin-ts/adapters/nextjs';
|
|
12
|
+
* export const GET = withMarginApp(handler);
|
|
13
|
+
*
|
|
14
|
+
* // Health endpoint
|
|
15
|
+
* // pages/api/margin/health.ts
|
|
16
|
+
* import { marginHealthHandler } from 'margin-ts/adapters/nextjs';
|
|
17
|
+
* export default marginHealthHandler();
|
|
18
|
+
*
|
|
19
|
+
* Tracks latency, error rate, and request count per route.
|
|
20
|
+
* Zero dependencies beyond margin-ts core.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Confidence,
|
|
25
|
+
Health,
|
|
26
|
+
Thresholds,
|
|
27
|
+
createThresholds,
|
|
28
|
+
classify,
|
|
29
|
+
Observation,
|
|
30
|
+
createExpression,
|
|
31
|
+
expressionToString,
|
|
32
|
+
expressionToDict,
|
|
33
|
+
} from '../index.js';
|
|
34
|
+
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
// Shared state — singleton across all wrapped routes
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface RouteStats {
|
|
40
|
+
totalRequests: number;
|
|
41
|
+
totalErrors: number;
|
|
42
|
+
latencies: number[];
|
|
43
|
+
maxWindow: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const _routes = new Map<string, RouteStats>();
|
|
47
|
+
const _MAX_WINDOW = 200;
|
|
48
|
+
|
|
49
|
+
function getOrCreateRoute(route: string): RouteStats {
|
|
50
|
+
if (!_routes.has(route)) {
|
|
51
|
+
_routes.set(route, {
|
|
52
|
+
totalRequests: 0,
|
|
53
|
+
totalErrors: 0,
|
|
54
|
+
latencies: [],
|
|
55
|
+
maxWindow: _MAX_WINDOW,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return _routes.get(route)!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function record(route: string, latencyMs: number, isError: boolean): void {
|
|
62
|
+
const stats = getOrCreateRoute(route);
|
|
63
|
+
stats.totalRequests++;
|
|
64
|
+
if (isError) stats.totalErrors++;
|
|
65
|
+
stats.latencies.push(latencyMs);
|
|
66
|
+
if (stats.latencies.length > stats.maxWindow) stats.latencies.shift();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -----------------------------------------------------------------------
|
|
70
|
+
// Thresholds
|
|
71
|
+
// -----------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export interface NextjsThresholds {
|
|
74
|
+
p50Latency: Thresholds;
|
|
75
|
+
p99Latency: Thresholds;
|
|
76
|
+
errorRate: Thresholds;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_NEXTJS_THRESHOLDS: NextjsThresholds = {
|
|
80
|
+
p50Latency: createThresholds(100, 500, false),
|
|
81
|
+
p99Latency: createThresholds(500, 2000, false),
|
|
82
|
+
errorRate: createThresholds(0.01, 0.10, false),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
// Classification
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function percentile(sorted: number[], p: number): number {
|
|
90
|
+
if (sorted.length === 0) return 0;
|
|
91
|
+
const idx = Math.ceil(sorted.length * p) - 1;
|
|
92
|
+
return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function classifyRoute(route: string, stats: RouteStats, thresholds: NextjsThresholds) {
|
|
96
|
+
if (stats.latencies.length < 3) return null;
|
|
97
|
+
|
|
98
|
+
const sorted = [...stats.latencies].sort((a, b) => a - b);
|
|
99
|
+
const p50 = percentile(sorted, 0.5);
|
|
100
|
+
const p99 = percentile(sorted, 0.99);
|
|
101
|
+
const errorRate = stats.totalRequests > 0 ? stats.totalErrors / stats.totalRequests : 0;
|
|
102
|
+
const now = new Date();
|
|
103
|
+
|
|
104
|
+
const observations: Observation[] = [
|
|
105
|
+
{
|
|
106
|
+
name: `${route}:p50`, health: classify(p50, Confidence.HIGH, thresholds.p50Latency),
|
|
107
|
+
value: p50, baseline: thresholds.p50Latency.intact, confidence: Confidence.HIGH,
|
|
108
|
+
higherIsBetter: false, provenance: [], measuredAt: now,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: `${route}:p99`, health: classify(p99, Confidence.HIGH, thresholds.p99Latency),
|
|
112
|
+
value: p99, baseline: thresholds.p99Latency.intact, confidence: Confidence.HIGH,
|
|
113
|
+
higherIsBetter: false, provenance: [], measuredAt: now,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: `${route}:errors`, health: classify(errorRate, Confidence.HIGH, thresholds.errorRate),
|
|
117
|
+
value: errorRate, baseline: 0.001, confidence: Confidence.HIGH,
|
|
118
|
+
higherIsBetter: false, provenance: [], measuredAt: now,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
return createExpression(observations, [], route);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -----------------------------------------------------------------------
|
|
126
|
+
// Pages Router wrapper
|
|
127
|
+
// -----------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export interface WithMarginOptions {
|
|
130
|
+
route?: string;
|
|
131
|
+
thresholds?: NextjsThresholds;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wrap a Pages Router API handler with margin health tracking.
|
|
136
|
+
*
|
|
137
|
+
* export default withMargin(handler);
|
|
138
|
+
* export default withMargin(handler, { route: '/api/users' });
|
|
139
|
+
*/
|
|
140
|
+
export function withMargin(
|
|
141
|
+
handler: (req: any, res: any) => any,
|
|
142
|
+
options: WithMarginOptions = {},
|
|
143
|
+
): (req: any, res: any) => any {
|
|
144
|
+
return async (req: any, res: any) => {
|
|
145
|
+
const route = options.route || req.url || '/api/unknown';
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await handler(req, res);
|
|
150
|
+
const latency = Date.now() - start;
|
|
151
|
+
const status = res.statusCode || 200;
|
|
152
|
+
record(route, latency, status >= 500);
|
|
153
|
+
return result;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const latency = Date.now() - start;
|
|
156
|
+
record(route, latency, true);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// App Router wrapper
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Wrap an App Router handler with margin health tracking.
|
|
168
|
+
*
|
|
169
|
+
* export const GET = withMarginApp(handler);
|
|
170
|
+
* export const POST = withMarginApp(handler, { route: '/api/users' });
|
|
171
|
+
*/
|
|
172
|
+
export function withMarginApp(
|
|
173
|
+
handler: (req: any) => any,
|
|
174
|
+
options: WithMarginOptions = {},
|
|
175
|
+
): (req: any) => any {
|
|
176
|
+
return async (req: any) => {
|
|
177
|
+
const route = options.route || new URL(req.url || '/', 'http://localhost').pathname;
|
|
178
|
+
const start = Date.now();
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const response = await handler(req);
|
|
182
|
+
const latency = Date.now() - start;
|
|
183
|
+
const status = response?.status || 200;
|
|
184
|
+
record(route, latency, status >= 500);
|
|
185
|
+
return response;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const latency = Date.now() - start;
|
|
188
|
+
record(route, latency, true);
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// Health endpoint
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pages Router: health endpoint handler.
|
|
200
|
+
*
|
|
201
|
+
* // pages/api/margin/health.ts
|
|
202
|
+
* import { marginHealthHandler } from 'margin-ts/adapters/nextjs';
|
|
203
|
+
* export default marginHealthHandler();
|
|
204
|
+
*/
|
|
205
|
+
export function marginHealthHandler(thresholds?: NextjsThresholds): (req: any, res: any) => void {
|
|
206
|
+
const t = thresholds || DEFAULT_NEXTJS_THRESHOLDS;
|
|
207
|
+
|
|
208
|
+
return (_req: any, res: any) => {
|
|
209
|
+
const routeHealth: Record<string, any> = {};
|
|
210
|
+
let worstOverall = Health.INTACT;
|
|
211
|
+
const SEVERITY: Record<Health, number> = {
|
|
212
|
+
[Health.INTACT]: 0, [Health.RECOVERING]: 1, [Health.DEGRADED]: 2,
|
|
213
|
+
[Health.ABLATED]: 3, [Health.OOD]: 4,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
for (const [route, stats] of _routes) {
|
|
217
|
+
const expr = classifyRoute(route, stats, t);
|
|
218
|
+
if (expr) {
|
|
219
|
+
routeHealth[route] = {
|
|
220
|
+
expression: expressionToString(expr),
|
|
221
|
+
...expressionToDict(expr),
|
|
222
|
+
requests: stats.totalRequests,
|
|
223
|
+
errors: stats.totalErrors,
|
|
224
|
+
};
|
|
225
|
+
for (const obs of expr.observations) {
|
|
226
|
+
if (SEVERITY[obs.health] > SEVERITY[worstOverall]) {
|
|
227
|
+
worstOverall = obs.health;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
res.status(200).json({
|
|
234
|
+
status: worstOverall,
|
|
235
|
+
routes: routeHealth,
|
|
236
|
+
totalRoutes: _routes.size,
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* App Router: health endpoint handler.
|
|
243
|
+
*
|
|
244
|
+
* // app/api/margin/health/route.ts
|
|
245
|
+
* import { marginHealthAppHandler } from 'margin-ts/adapters/nextjs';
|
|
246
|
+
* export const GET = marginHealthAppHandler();
|
|
247
|
+
*/
|
|
248
|
+
export function marginHealthAppHandler(thresholds?: NextjsThresholds): (req: any) => any {
|
|
249
|
+
const t = thresholds || DEFAULT_NEXTJS_THRESHOLDS;
|
|
250
|
+
|
|
251
|
+
return (_req: any) => {
|
|
252
|
+
const routeHealth: Record<string, any> = {};
|
|
253
|
+
let worstOverall = Health.INTACT;
|
|
254
|
+
const SEVERITY: Record<Health, number> = {
|
|
255
|
+
[Health.INTACT]: 0, [Health.RECOVERING]: 1, [Health.DEGRADED]: 2,
|
|
256
|
+
[Health.ABLATED]: 3, [Health.OOD]: 4,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const [route, stats] of _routes) {
|
|
260
|
+
const expr = classifyRoute(route, stats, t);
|
|
261
|
+
if (expr) {
|
|
262
|
+
routeHealth[route] = {
|
|
263
|
+
expression: expressionToString(expr),
|
|
264
|
+
...expressionToDict(expr),
|
|
265
|
+
requests: stats.totalRequests,
|
|
266
|
+
errors: stats.totalErrors,
|
|
267
|
+
};
|
|
268
|
+
for (const obs of expr.observations) {
|
|
269
|
+
if (SEVERITY[obs.health] > SEVERITY[worstOverall]) {
|
|
270
|
+
worstOverall = obs.health;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return new Response(JSON.stringify({
|
|
277
|
+
status: worstOverall,
|
|
278
|
+
routes: routeHealth,
|
|
279
|
+
totalRoutes: _routes.size,
|
|
280
|
+
}), {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: { 'Content-Type': 'application/json' },
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Clear all tracked routes (for testing). */
|
|
288
|
+
export function resetRoutes(): void {
|
|
289
|
+
_routes.clear();
|
|
290
|
+
}
|