margin-ts 0.6.0 → 0.6.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/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/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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/express.ts +342 -0
- package/src/adapters/vitest.ts +141 -0
- package/src/index.ts +13 -0
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
import { Thresholds } from '../index.js';
|
|
18
|
+
interface RouteMetrics {
|
|
19
|
+
route: string;
|
|
20
|
+
totalRequests: number;
|
|
21
|
+
totalErrors: number;
|
|
22
|
+
total4xx: number;
|
|
23
|
+
latencies: number[];
|
|
24
|
+
timestamps: Date[];
|
|
25
|
+
maxWindow: number;
|
|
26
|
+
}
|
|
27
|
+
export interface EndpointThresholds {
|
|
28
|
+
p50Latency: Thresholds;
|
|
29
|
+
p99Latency: Thresholds;
|
|
30
|
+
errorRate: Thresholds;
|
|
31
|
+
requestRate: Thresholds;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_THRESHOLDS: EndpointThresholds;
|
|
34
|
+
export interface MarginMiddlewareOptions {
|
|
35
|
+
/** Track per-route or aggregate only (default: true) */
|
|
36
|
+
perRoute?: boolean;
|
|
37
|
+
/** Max latency samples to keep per route (default: 200) */
|
|
38
|
+
window?: number;
|
|
39
|
+
/** Custom thresholds */
|
|
40
|
+
thresholds?: EndpointThresholds;
|
|
41
|
+
/** Routes to ignore (e.g. ['/margin/health', '/favicon.ico']) */
|
|
42
|
+
ignore?: string[];
|
|
43
|
+
/** Normalize route paths: replace numeric segments with :id (default: true) */
|
|
44
|
+
normalizePaths?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** The shared state object — accessible for custom endpoints */
|
|
47
|
+
export interface MarginState {
|
|
48
|
+
routes: Map<string, RouteMetrics>;
|
|
49
|
+
aggregate: RouteMetrics;
|
|
50
|
+
options: Required<MarginMiddlewareOptions>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Express middleware that tracks request latency and error rates.
|
|
54
|
+
*
|
|
55
|
+
* Attach to your app before routes:
|
|
56
|
+
* app.use(marginMiddleware());
|
|
57
|
+
*/
|
|
58
|
+
export declare function marginMiddleware(options?: MarginMiddlewareOptions): any;
|
|
59
|
+
export interface HealthResponse {
|
|
60
|
+
status: string;
|
|
61
|
+
expression: string;
|
|
62
|
+
aggregate: Record<string, unknown>;
|
|
63
|
+
routes?: Record<string, Record<string, unknown>>;
|
|
64
|
+
drift?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Express route handler for /margin/health.
|
|
68
|
+
*
|
|
69
|
+
* Returns typed health classification for all tracked routes.
|
|
70
|
+
*
|
|
71
|
+
* app.get('/margin/health', marginHealthRoute());
|
|
72
|
+
*
|
|
73
|
+
* Or pass the middleware directly:
|
|
74
|
+
* const mw = marginMiddleware();
|
|
75
|
+
* app.use(mw);
|
|
76
|
+
* app.get('/margin/health', marginHealthRoute(mw));
|
|
77
|
+
*/
|
|
78
|
+
export declare function marginHealthRoute(middleware?: any): any;
|
|
79
|
+
export {};
|
|
80
|
+
//# sourceMappingURL=express.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/adapters/express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAGL,UAAU,EAcX,MAAM,aAAa,CAAC;AAMrB,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,EAAE,IAAI,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA8BD,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,UAAU,CAAC;IACvB,UAAU,EAAE,UAAU,CAAC;IACvB,SAAS,EAAE,UAAU,CAAC;IACtB,WAAW,EAAE,UAAU,CAAC;CACzB;AAED,eAAO,MAAM,kBAAkB,EAAE,kBAKhC,CAAC;AAmFF,MAAM,WAAW,uBAAuB;IACtC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,UAAU,CAAC,EAAE,kBAAkB,CAAC;IAChC,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAClC,SAAS,EAAE,YAAY,CAAC;IACxB,OAAO,EAAE,QAAQ,CAAC,uBAAuB,CAAC,CAAC;CAC5C;AAMD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,GAAG,CAoD3E;AAMD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,CAAC,EAAE,GAAG,GAAG,GAAG,CA4DvD"}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Express middleware adapter for margin.
|
|
4
|
+
*
|
|
5
|
+
* Tracks per-route health: latency, error rate, request rate.
|
|
6
|
+
* Exposes /margin/health endpoint with typed classifications.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import express from 'express';
|
|
10
|
+
* import { marginMiddleware, marginHealthRoute } from 'margin-ts/adapters/express';
|
|
11
|
+
*
|
|
12
|
+
* const app = express();
|
|
13
|
+
* app.use(marginMiddleware());
|
|
14
|
+
* app.get('/margin/health', marginHealthRoute());
|
|
15
|
+
*
|
|
16
|
+
* Zero dependencies beyond margin-ts core (express types are optional).
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.DEFAULT_THRESHOLDS = void 0;
|
|
20
|
+
exports.marginMiddleware = marginMiddleware;
|
|
21
|
+
exports.marginHealthRoute = marginHealthRoute;
|
|
22
|
+
const index_js_1 = require("../index.js");
|
|
23
|
+
function createRouteMetrics(route, maxWindow = 200) {
|
|
24
|
+
return {
|
|
25
|
+
route,
|
|
26
|
+
totalRequests: 0,
|
|
27
|
+
totalErrors: 0,
|
|
28
|
+
total4xx: 0,
|
|
29
|
+
latencies: [],
|
|
30
|
+
timestamps: [],
|
|
31
|
+
maxWindow,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function recordRequest(rm, latencyMs, statusCode) {
|
|
35
|
+
rm.totalRequests++;
|
|
36
|
+
if (statusCode >= 500)
|
|
37
|
+
rm.totalErrors++;
|
|
38
|
+
if (statusCode >= 400 && statusCode < 500)
|
|
39
|
+
rm.total4xx++;
|
|
40
|
+
rm.latencies.push(latencyMs);
|
|
41
|
+
rm.timestamps.push(new Date());
|
|
42
|
+
if (rm.latencies.length > rm.maxWindow) {
|
|
43
|
+
rm.latencies.shift();
|
|
44
|
+
rm.timestamps.shift();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.DEFAULT_THRESHOLDS = {
|
|
48
|
+
p50Latency: (0, index_js_1.createThresholds)(100, 500, false), // ms, lower is better
|
|
49
|
+
p99Latency: (0, index_js_1.createThresholds)(500, 2000, false), // ms, lower is better
|
|
50
|
+
errorRate: (0, index_js_1.createThresholds)(0.01, 0.10, false), // ratio, lower is better
|
|
51
|
+
requestRate: (0, index_js_1.createThresholds)(1.0, 0.1, true), // req/s, higher is better
|
|
52
|
+
};
|
|
53
|
+
// -----------------------------------------------------------------------
|
|
54
|
+
// Metrics → Observations
|
|
55
|
+
// -----------------------------------------------------------------------
|
|
56
|
+
function percentile(sorted, p) {
|
|
57
|
+
if (sorted.length === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
const idx = Math.ceil(sorted.length * p) - 1;
|
|
60
|
+
return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
|
|
61
|
+
}
|
|
62
|
+
function classifyRoute(rm, thresholds, now) {
|
|
63
|
+
if (rm.latencies.length < 3)
|
|
64
|
+
return [];
|
|
65
|
+
const sorted = [...rm.latencies].sort((a, b) => a - b);
|
|
66
|
+
const p50 = percentile(sorted, 0.5);
|
|
67
|
+
const p99 = percentile(sorted, 0.99);
|
|
68
|
+
const errorRate = rm.totalRequests > 0 ? rm.totalErrors / rm.totalRequests : 0;
|
|
69
|
+
// Request rate: requests in window / window duration
|
|
70
|
+
let requestRate = 0;
|
|
71
|
+
if (rm.timestamps.length >= 2) {
|
|
72
|
+
const windowMs = rm.timestamps[rm.timestamps.length - 1].getTime() - rm.timestamps[0].getTime();
|
|
73
|
+
if (windowMs > 0)
|
|
74
|
+
requestRate = (rm.timestamps.length / windowMs) * 1000;
|
|
75
|
+
}
|
|
76
|
+
const prefix = rm.route === '*' ? '' : `${rm.route}:`;
|
|
77
|
+
const observations = [
|
|
78
|
+
{
|
|
79
|
+
name: `${prefix}p50_latency`,
|
|
80
|
+
health: (0, index_js_1.classify)(p50, index_js_1.Confidence.HIGH, thresholds.p50Latency),
|
|
81
|
+
value: p50,
|
|
82
|
+
baseline: thresholds.p50Latency.intact,
|
|
83
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
84
|
+
higherIsBetter: false,
|
|
85
|
+
provenance: [],
|
|
86
|
+
measuredAt: now,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: `${prefix}p99_latency`,
|
|
90
|
+
health: (0, index_js_1.classify)(p99, index_js_1.Confidence.HIGH, thresholds.p99Latency),
|
|
91
|
+
value: p99,
|
|
92
|
+
baseline: thresholds.p99Latency.intact,
|
|
93
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
94
|
+
higherIsBetter: false,
|
|
95
|
+
provenance: [],
|
|
96
|
+
measuredAt: now,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: `${prefix}error_rate`,
|
|
100
|
+
health: (0, index_js_1.classify)(errorRate, index_js_1.Confidence.HIGH, thresholds.errorRate),
|
|
101
|
+
value: errorRate,
|
|
102
|
+
baseline: 0.001,
|
|
103
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
104
|
+
higherIsBetter: false,
|
|
105
|
+
provenance: [],
|
|
106
|
+
measuredAt: now,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: `${prefix}request_rate`,
|
|
110
|
+
health: (0, index_js_1.classify)(requestRate, index_js_1.Confidence.MODERATE, thresholds.requestRate),
|
|
111
|
+
value: requestRate,
|
|
112
|
+
baseline: 10.0,
|
|
113
|
+
confidence: index_js_1.Confidence.MODERATE,
|
|
114
|
+
higherIsBetter: true,
|
|
115
|
+
provenance: [],
|
|
116
|
+
measuredAt: now,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
return observations;
|
|
120
|
+
}
|
|
121
|
+
function normalizePath(path) {
|
|
122
|
+
return path.replace(/\/\d+/g, '/:id').replace(/\/[0-9a-f]{24}/g, '/:id');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Express middleware that tracks request latency and error rates.
|
|
126
|
+
*
|
|
127
|
+
* Attach to your app before routes:
|
|
128
|
+
* app.use(marginMiddleware());
|
|
129
|
+
*/
|
|
130
|
+
function marginMiddleware(options = {}) {
|
|
131
|
+
const opts = {
|
|
132
|
+
perRoute: options.perRoute ?? true,
|
|
133
|
+
window: options.window ?? 200,
|
|
134
|
+
thresholds: options.thresholds ?? exports.DEFAULT_THRESHOLDS,
|
|
135
|
+
ignore: options.ignore ?? ['/margin/health', '/favicon.ico'],
|
|
136
|
+
normalizePaths: options.normalizePaths ?? true,
|
|
137
|
+
};
|
|
138
|
+
const state = {
|
|
139
|
+
routes: new Map(),
|
|
140
|
+
aggregate: createRouteMetrics('*', opts.window),
|
|
141
|
+
options: opts,
|
|
142
|
+
};
|
|
143
|
+
const middleware = (req, res, next) => {
|
|
144
|
+
const path = req.path || req.url || '/';
|
|
145
|
+
if (opts.ignore.includes(path)) {
|
|
146
|
+
next();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const start = Date.now();
|
|
150
|
+
// Hook into response finish
|
|
151
|
+
const onFinish = () => {
|
|
152
|
+
res.removeListener('finish', onFinish);
|
|
153
|
+
const latency = Date.now() - start;
|
|
154
|
+
const status = res.statusCode || 200;
|
|
155
|
+
// Aggregate
|
|
156
|
+
recordRequest(state.aggregate, latency, status);
|
|
157
|
+
// Per-route
|
|
158
|
+
if (opts.perRoute) {
|
|
159
|
+
const route = opts.normalizePaths ? normalizePath(path) : path;
|
|
160
|
+
if (!state.routes.has(route)) {
|
|
161
|
+
state.routes.set(route, createRouteMetrics(route, opts.window));
|
|
162
|
+
}
|
|
163
|
+
recordRequest(state.routes.get(route), latency, status);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
res.on('finish', onFinish);
|
|
167
|
+
next();
|
|
168
|
+
};
|
|
169
|
+
// Attach state to middleware for access by health route
|
|
170
|
+
middleware.__marginState = state;
|
|
171
|
+
return middleware;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Express route handler for /margin/health.
|
|
175
|
+
*
|
|
176
|
+
* Returns typed health classification for all tracked routes.
|
|
177
|
+
*
|
|
178
|
+
* app.get('/margin/health', marginHealthRoute());
|
|
179
|
+
*
|
|
180
|
+
* Or pass the middleware directly:
|
|
181
|
+
* const mw = marginMiddleware();
|
|
182
|
+
* app.use(mw);
|
|
183
|
+
* app.get('/margin/health', marginHealthRoute(mw));
|
|
184
|
+
*/
|
|
185
|
+
function marginHealthRoute(middleware) {
|
|
186
|
+
return (req, res) => {
|
|
187
|
+
// Find state from middleware
|
|
188
|
+
let state;
|
|
189
|
+
if (middleware && middleware.__marginState) {
|
|
190
|
+
state = middleware.__marginState;
|
|
191
|
+
}
|
|
192
|
+
else if (req.app) {
|
|
193
|
+
// Walk through app middleware stack to find ours
|
|
194
|
+
const stack = req.app._router?.stack || [];
|
|
195
|
+
for (const layer of stack) {
|
|
196
|
+
if (layer.handle?.__marginState) {
|
|
197
|
+
state = layer.handle.__marginState;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!state) {
|
|
203
|
+
res.status(500).json({ error: 'margin middleware not found' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const thresholds = state.options.thresholds;
|
|
208
|
+
// Aggregate health
|
|
209
|
+
const aggObs = classifyRoute(state.aggregate, thresholds, now);
|
|
210
|
+
const aggExpr = (0, index_js_1.createExpression)(aggObs, [], 'aggregate');
|
|
211
|
+
// Determine overall status
|
|
212
|
+
const SEVERITY_MAP = {
|
|
213
|
+
[index_js_1.Health.INTACT]: 0, [index_js_1.Health.RECOVERING]: 1, [index_js_1.Health.DEGRADED]: 2,
|
|
214
|
+
[index_js_1.Health.ABLATED]: 3, [index_js_1.Health.OOD]: 4,
|
|
215
|
+
};
|
|
216
|
+
const worstHealth = aggObs.reduce((worst, o) => SEVERITY_MAP[o.health] > SEVERITY_MAP[worst] ? o.health : worst, index_js_1.Health.INTACT);
|
|
217
|
+
const response = {
|
|
218
|
+
status: worstHealth,
|
|
219
|
+
expression: (0, index_js_1.expressionToString)(aggExpr),
|
|
220
|
+
aggregate: (0, index_js_1.expressionToDict)(aggExpr),
|
|
221
|
+
};
|
|
222
|
+
// Per-route health
|
|
223
|
+
if (state.options.perRoute && state.routes.size > 0) {
|
|
224
|
+
const routes = {};
|
|
225
|
+
for (const [route, metrics] of state.routes) {
|
|
226
|
+
const obs = classifyRoute(metrics, thresholds, now);
|
|
227
|
+
if (obs.length > 0) {
|
|
228
|
+
const expr = (0, index_js_1.createExpression)(obs, [], route);
|
|
229
|
+
routes[route] = (0, index_js_1.expressionToDict)(expr);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
response.routes = routes;
|
|
233
|
+
}
|
|
234
|
+
res.json(response);
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.js","sourceRoot":"","sources":["../../src/adapters/express.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;GAeG;;;AA4LH,4CAoDC;AA0BD,8CA4DC;AApUD,0CAiBqB;AAgBrB,SAAS,kBAAkB,CAAC,KAAa,EAAE,SAAS,GAAG,GAAG;IACxD,OAAO;QACL,KAAK;QACL,aAAa,EAAE,CAAC;QAChB,WAAW,EAAE,CAAC;QACd,QAAQ,EAAE,CAAC;QACX,SAAS,EAAE,EAAE;QACb,UAAU,EAAE,EAAE;QACd,SAAS;KACV,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,EAAgB,EAAE,SAAiB,EAAE,UAAkB;IAC5E,EAAE,CAAC,aAAa,EAAE,CAAC;IACnB,IAAI,UAAU,IAAI,GAAG;QAAE,EAAE,CAAC,WAAW,EAAE,CAAC;IACxC,IAAI,UAAU,IAAI,GAAG,IAAI,UAAU,GAAG,GAAG;QAAE,EAAE,CAAC,QAAQ,EAAE,CAAC;IACzD,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAC7B,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAC/B,IAAI,EAAE,CAAC,SAAS,CAAC,MAAM,GAAG,EAAE,CAAC,SAAS,EAAE,CAAC;QACvC,EAAE,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QACrB,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;AACH,CAAC;AAaY,QAAA,kBAAkB,GAAuB;IACpD,UAAU,EAAE,IAAA,2BAAgB,EAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,EAAM,sBAAsB;IACzE,UAAU,EAAE,IAAA,2BAAgB,EAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,EAAM,sBAAsB;IAC1E,SAAS,EAAE,IAAA,2BAAgB,EAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAM,yBAAyB;IAC7E,WAAW,EAAE,IAAA,2BAAgB,EAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,EAAO,0BAA0B;CAC/E,CAAC;AAEF,0EAA0E;AAC1E,yBAAyB;AACzB,0EAA0E;AAE1E,SAAS,UAAU,CAAC,MAAgB,EAAE,CAAS;IAC7C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAClC,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;IAC7C,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED,SAAS,aAAa,CACpB,EAAgB,EAChB,UAA8B,EAC9B,GAAS;IAET,IAAI,EAAE,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACvD,MAAM,GAAG,GAAG,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACpC,MAAM,GAAG,GAAG,UAAU,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACrC,MAAM,SAAS,GAAG,EAAE,CAAC,aAAa,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,WAAW,GAAG,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;IAE/E,qDAAqD;IACrD,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,IAAI,EAAE,CAAC,UAAU,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QAC9B,MAAM,QAAQ,GAAG,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;QAChG,IAAI,QAAQ,GAAG,CAAC;YAAE,WAAW,GAAG,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC;IAC3E,CAAC;IAED,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,GAAG,CAAC;IAEtD,MAAM,YAAY,GAAkB;QAClC;YACE,IAAI,EAAE,GAAG,MAAM,aAAa;YAC5B,MAAM,EAAE,IAAA,mBAAQ,EAAC,GAAG,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,UAAU,CAAC;YAC7D,KAAK,EAAE,GAAG;YACV,QAAQ,EAAE,UAAU,CAAC,UAAU,CAAC,MAAM;YACtC,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,GAAG,MAAM,aAAa;YAC5B,MAAM,EAAE,IAAA,mBAAQ,EAAC,GAAG,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,UAAU,CAAC;YAC7D,KAAK,EAAE,GAAG;YACV,QAAQ,EAAE,UAAU,CAAC,UAAU,CAAC,MAAM;YACtC,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,GAAG,MAAM,YAAY;YAC3B,MAAM,EAAE,IAAA,mBAAQ,EAAC,SAAS,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,SAAS,CAAC;YAClE,KAAK,EAAE,SAAS;YAChB,QAAQ,EAAE,KAAK;YACf,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,GAAG,MAAM,cAAc;YAC7B,MAAM,EAAE,IAAA,mBAAQ,EAAC,WAAW,EAAE,qBAAU,CAAC,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC;YAC1E,KAAK,EAAE,WAAW;YAClB,QAAQ,EAAE,IAAI;YACd,UAAU,EAAE,qBAAU,CAAC,QAAQ;YAC/B,cAAc,EAAE,IAAI;YACpB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;KACF,CAAC;IAEF,OAAO,YAAY,CAAC;AACtB,CAAC;AA0BD,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;AAC3E,CAAC;AAED;;;;;GAKG;AACH,SAAgB,gBAAgB,CAAC,UAAmC,EAAE;IACpE,MAAM,IAAI,GAAsC;QAC9C,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,IAAI;QAClC,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,GAAG;QAC7B,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,0BAAkB;QACpD,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,CAAC,gBAAgB,EAAE,cAAc,CAAC;QAC5D,cAAc,EAAE,OAAO,CAAC,cAAc,IAAI,IAAI;KAC/C,CAAC;IAEF,MAAM,KAAK,GAAgB;QACzB,MAAM,EAAE,IAAI,GAAG,EAAE;QACjB,SAAS,EAAE,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,CAAC;QAC/C,OAAO,EAAE,IAAI;KACd,CAAC;IAEF,MAAM,UAAU,GAAG,CAAC,GAAQ,EAAE,GAAQ,EAAE,IAAS,EAAE,EAAE;QACnD,MAAM,IAAI,GAAW,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC;QAEhD,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/B,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEzB,4BAA4B;QAC5B,MAAM,QAAQ,GAAG,GAAG,EAAE;YACpB,GAAG,CAAC,cAAc,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;YACvC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC;YACnC,MAAM,MAAM,GAAW,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC;YAE7C,YAAY;YACZ,aAAa,CAAC,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;YAEhD,YAAY;YACZ,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAClB,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;gBAC/D,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;oBAC7B,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,kBAAkB,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;gBAClE,CAAC;gBACD,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAE,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;YAC3D,CAAC;QACH,CAAC,CAAC;QAEF,GAAG,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAC3B,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;IAEF,wDAAwD;IACvD,UAAkB,CAAC,aAAa,GAAG,KAAK,CAAC;IAE1C,OAAO,UAAU,CAAC;AACpB,CAAC;AAcD;;;;;;;;;;;GAWG;AACH,SAAgB,iBAAiB,CAAC,UAAgB;IAChD,OAAO,CAAC,GAAQ,EAAE,GAAQ,EAAE,EAAE;QAC5B,6BAA6B;QAC7B,IAAI,KAA8B,CAAC;QACnC,IAAI,UAAU,IAAK,UAAkB,CAAC,aAAa,EAAE,CAAC;YACpD,KAAK,GAAI,UAAkB,CAAC,aAAa,CAAC;QAC5C,CAAC;aAAM,IAAI,GAAG,CAAC,GAAG,EAAE,CAAC;YACnB,iDAAiD;YACjD,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,IAAI,EAAE,CAAC;YAC3C,KAAK,MAAM,KAAK,IAAI,KAAK,EAAE,CAAC;gBAC1B,IAAI,KAAK,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC;oBAChC,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC;oBACnC,MAAM;gBACR,CAAC;YACH,CAAC;QACH,CAAC;QAED,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,6BAA6B,EAAE,CAAC,CAAC;YAC/D,OAAO;QACT,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC;QAE5C,mBAAmB;QACnB,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,SAAS,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;QAC/D,MAAM,OAAO,GAAG,IAAA,2BAAgB,EAAC,MAAM,EAAE,EAAE,EAAE,WAAW,CAAC,CAAC;QAE1D,2BAA2B;QAC3B,MAAM,YAAY,GAA2B;YAC3C,CAAC,iBAAM,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,iBAAM,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,iBAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YAChE,CAAC,iBAAM,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,iBAAM,CAAC,GAAG,CAAC,EAAE,CAAC;SACrC,CAAC;QACF,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAC/B,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,EAC7E,iBAAM,CAAC,MAAM,CACd,CAAC;QAEF,MAAM,QAAQ,GAAmB;YAC/B,MAAM,EAAE,WAAW;YACnB,UAAU,EAAE,IAAA,6BAAkB,EAAC,OAAO,CAAC;YACvC,SAAS,EAAE,IAAA,2BAAgB,EAAC,OAAO,CAAC;SACrC,CAAC;QAEF,mBAAmB;QACnB,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;YACpD,MAAM,MAAM,GAA4C,EAAE,CAAC;YAC3D,KAAK,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;gBAC5C,MAAM,GAAG,GAAG,aAAa,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACpD,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBACnB,MAAM,IAAI,GAAG,IAAA,2BAAgB,EAAC,GAAG,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;oBAC9C,MAAM,CAAC,KAAK,CAAC,GAAG,IAAA,2BAAgB,EAAC,IAAI,CAAC,CAAC;gBACzC,CAAC;YACH,CAAC;YACD,QAAQ,CAAC,MAAM,GAAG,MAAM,CAAC;QAC3B,CAAC;QAED,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACrB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest/Jest test health reporter for margin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies test suite health after every run: pass rate, duration,
|
|
5
|
+
* flake rate. The JS equivalent of margin's pytest plugin.
|
|
6
|
+
*
|
|
7
|
+
* Usage with vitest:
|
|
8
|
+
* // vitest.config.ts
|
|
9
|
+
* import { marginReporter } from 'margin-ts/adapters/vitest';
|
|
10
|
+
* export default { test: { reporters: ['default', marginReporter()] } };
|
|
11
|
+
*
|
|
12
|
+
* Or use standalone after collecting results:
|
|
13
|
+
* import { classifySuite } from 'margin-ts/adapters/vitest';
|
|
14
|
+
* const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
|
|
15
|
+
*/
|
|
16
|
+
import { Thresholds, Expression } from '../index.js';
|
|
17
|
+
export interface SuiteThresholds {
|
|
18
|
+
passRate: Thresholds;
|
|
19
|
+
skipRate: Thresholds;
|
|
20
|
+
meanDurationMs: Thresholds;
|
|
21
|
+
totalDurationMs: Thresholds;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_SUITE_THRESHOLDS: SuiteThresholds;
|
|
24
|
+
export interface SuiteResults {
|
|
25
|
+
passed: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
skipped: number;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
testDurations?: number[];
|
|
30
|
+
}
|
|
31
|
+
export declare function classifySuite(results: SuiteResults, thresholds?: SuiteThresholds, label?: string): Expression;
|
|
32
|
+
/**
|
|
33
|
+
* Format suite health as a printable string.
|
|
34
|
+
*/
|
|
35
|
+
export declare function suiteHealthString(results: SuiteResults, thresholds?: SuiteThresholds): string;
|
|
36
|
+
//# sourceMappingURL=vitest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../../src/adapters/vitest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAGL,UAAU,EAIV,UAAU,EAGX,MAAM,aAAa,CAAC;AAMrB,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,UAAU,CAAC;IACrB,QAAQ,EAAE,UAAU,CAAC;IACrB,cAAc,EAAE,UAAU,CAAC;IAC3B,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,eAAO,MAAM,wBAAwB,EAAE,eAKtC,CAAC;AAMF,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,eAA0C,EACtD,KAAK,SAAK,GACT,UAAU,CAiEZ;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,EAAE,UAAU,CAAC,EAAE,eAAe,GAAG,MAAM,CAQ7F"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Vitest/Jest test health reporter for margin.
|
|
4
|
+
*
|
|
5
|
+
* Classifies test suite health after every run: pass rate, duration,
|
|
6
|
+
* flake rate. The JS equivalent of margin's pytest plugin.
|
|
7
|
+
*
|
|
8
|
+
* Usage with vitest:
|
|
9
|
+
* // vitest.config.ts
|
|
10
|
+
* import { marginReporter } from 'margin-ts/adapters/vitest';
|
|
11
|
+
* export default { test: { reporters: ['default', marginReporter()] } };
|
|
12
|
+
*
|
|
13
|
+
* Or use standalone after collecting results:
|
|
14
|
+
* import { classifySuite } from 'margin-ts/adapters/vitest';
|
|
15
|
+
* const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.DEFAULT_SUITE_THRESHOLDS = void 0;
|
|
19
|
+
exports.classifySuite = classifySuite;
|
|
20
|
+
exports.suiteHealthString = suiteHealthString;
|
|
21
|
+
const index_js_1 = require("../index.js");
|
|
22
|
+
exports.DEFAULT_SUITE_THRESHOLDS = {
|
|
23
|
+
passRate: (0, index_js_1.createThresholds)(0.95, 0.70, true), // higher is better
|
|
24
|
+
skipRate: (0, index_js_1.createThresholds)(0.05, 0.20, false), // lower is better
|
|
25
|
+
meanDurationMs: (0, index_js_1.createThresholds)(100, 1000, false), // lower is better
|
|
26
|
+
totalDurationMs: (0, index_js_1.createThresholds)(30000, 120000, false), // lower is better
|
|
27
|
+
};
|
|
28
|
+
function classifySuite(results, thresholds = exports.DEFAULT_SUITE_THRESHOLDS, label = '') {
|
|
29
|
+
const total = results.passed + results.failed + results.skipped;
|
|
30
|
+
if (total === 0)
|
|
31
|
+
return (0, index_js_1.createExpression)([], [], label);
|
|
32
|
+
const passRate = results.passed / total;
|
|
33
|
+
const skipRate = results.skipped / total;
|
|
34
|
+
const meanDuration = results.testDurations && results.testDurations.length > 0
|
|
35
|
+
? results.testDurations.reduce((a, b) => a + b, 0) / results.testDurations.length
|
|
36
|
+
: results.durationMs / total;
|
|
37
|
+
const now = new Date();
|
|
38
|
+
const observations = [
|
|
39
|
+
{
|
|
40
|
+
name: 'pass_rate',
|
|
41
|
+
health: (0, index_js_1.classify)(passRate, index_js_1.Confidence.HIGH, thresholds.passRate),
|
|
42
|
+
value: passRate,
|
|
43
|
+
baseline: 1.0,
|
|
44
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
45
|
+
higherIsBetter: true,
|
|
46
|
+
provenance: [],
|
|
47
|
+
measuredAt: now,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'skip_rate',
|
|
51
|
+
health: (0, index_js_1.classify)(skipRate, index_js_1.Confidence.HIGH, thresholds.skipRate),
|
|
52
|
+
value: skipRate,
|
|
53
|
+
baseline: 0.0,
|
|
54
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
55
|
+
higherIsBetter: false,
|
|
56
|
+
provenance: [],
|
|
57
|
+
measuredAt: now,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'mean_duration',
|
|
61
|
+
health: (0, index_js_1.classify)(meanDuration, index_js_1.Confidence.HIGH, thresholds.meanDurationMs),
|
|
62
|
+
value: meanDuration,
|
|
63
|
+
baseline: 50,
|
|
64
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
65
|
+
higherIsBetter: false,
|
|
66
|
+
provenance: [],
|
|
67
|
+
measuredAt: now,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'total_duration',
|
|
71
|
+
health: (0, index_js_1.classify)(results.durationMs, index_js_1.Confidence.MODERATE, thresholds.totalDurationMs),
|
|
72
|
+
value: results.durationMs,
|
|
73
|
+
baseline: 10000,
|
|
74
|
+
confidence: index_js_1.Confidence.MODERATE,
|
|
75
|
+
higherIsBetter: false,
|
|
76
|
+
provenance: [],
|
|
77
|
+
measuredAt: now,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'failures',
|
|
81
|
+
health: (0, index_js_1.classify)(results.failed, index_js_1.Confidence.HIGH, (0, index_js_1.createThresholds)(0, 5, false)),
|
|
82
|
+
value: results.failed,
|
|
83
|
+
baseline: 0,
|
|
84
|
+
confidence: index_js_1.Confidence.HIGH,
|
|
85
|
+
higherIsBetter: false,
|
|
86
|
+
provenance: [],
|
|
87
|
+
measuredAt: now,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
return (0, index_js_1.createExpression)(observations, [], label || `suite (${total} tests)`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Format suite health as a printable string.
|
|
94
|
+
*/
|
|
95
|
+
function suiteHealthString(results, thresholds) {
|
|
96
|
+
const expr = classifySuite(results, thresholds);
|
|
97
|
+
const total = results.passed + results.failed + results.skipped;
|
|
98
|
+
const lines = [
|
|
99
|
+
`margin health: ${total} tests (${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped)`,
|
|
100
|
+
` ${(0, index_js_1.expressionToString)(expr)}`,
|
|
101
|
+
];
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=vitest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.js","sourceRoot":"","sources":["../../src/adapters/vitest.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;;;AA4CH,sCAqEC;AAKD,8CAQC;AA5HD,0CAUqB;AAaR,QAAA,wBAAwB,GAAoB;IACvD,QAAQ,EAAE,IAAA,2BAAgB,EAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAS,mBAAmB;IACxE,QAAQ,EAAE,IAAA,2BAAgB,EAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAS,kBAAkB;IACxE,cAAc,EAAE,IAAA,2BAAgB,EAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,EAAI,kBAAkB;IACxE,eAAe,EAAE,IAAA,2BAAgB,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,kBAAkB;CAC5E,CAAC;AAcF,SAAgB,aAAa,CAC3B,OAAqB,EACrB,aAA8B,gCAAwB,EACtD,KAAK,GAAG,EAAE;IAEV,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAChE,IAAI,KAAK,KAAK,CAAC;QAAE,OAAO,IAAA,2BAAgB,EAAC,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;IAExD,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC;IACxC,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC;IACzC,MAAM,YAAY,GAAG,OAAO,CAAC,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC;QAC5E,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM;QACjF,CAAC,CAAC,OAAO,CAAC,UAAU,GAAG,KAAK,CAAC;IAE/B,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IACvB,MAAM,YAAY,GAAkB;QAClC;YACE,IAAI,EAAE,WAAW;YACjB,MAAM,EAAE,IAAA,mBAAQ,EAAC,QAAQ,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,QAAQ,CAAC;YAChE,KAAK,EAAE,QAAQ;YACf,QAAQ,EAAE,GAAG;YACb,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,IAAI;YACpB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,WAAW;YACjB,MAAM,EAAE,IAAA,mBAAQ,EAAC,QAAQ,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,QAAQ,CAAC;YAChE,KAAK,EAAE,QAAQ;YACf,QAAQ,EAAE,GAAG;YACb,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,eAAe;YACrB,MAAM,EAAE,IAAA,mBAAQ,EAAC,YAAY,EAAE,qBAAU,CAAC,IAAI,EAAE,UAAU,CAAC,cAAc,CAAC;YAC1E,KAAK,EAAE,YAAY;YACnB,QAAQ,EAAE,EAAE;YACZ,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,gBAAgB;YACtB,MAAM,EAAE,IAAA,mBAAQ,EAAC,OAAO,CAAC,UAAU,EAAE,qBAAU,CAAC,QAAQ,EAAE,UAAU,CAAC,eAAe,CAAC;YACrF,KAAK,EAAE,OAAO,CAAC,UAAU;YACzB,QAAQ,EAAE,KAAK;YACf,UAAU,EAAE,qBAAU,CAAC,QAAQ;YAC/B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;QACD;YACE,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,IAAA,mBAAQ,EAAC,OAAO,CAAC,MAAM,EAAE,qBAAU,CAAC,IAAI,EAAE,IAAA,2BAAgB,EAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;YAChF,KAAK,EAAE,OAAO,CAAC,MAAM;YACrB,QAAQ,EAAE,CAAC;YACX,UAAU,EAAE,qBAAU,CAAC,IAAI;YAC3B,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,GAAG;SAChB;KACF,CAAC;IAEF,OAAO,IAAA,2BAAgB,EAAC,YAAY,EAAE,EAAE,EAAE,KAAK,IAAI,UAAU,KAAK,SAAS,CAAC,CAAC;AAC/E,CAAC;AAED;;GAEG;AACH,SAAgB,iBAAiB,CAAC,OAAqB,EAAE,UAA4B;IACnF,MAAM,IAAI,GAAG,aAAa,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IAChD,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAChE,MAAM,KAAK,GAAG;QACZ,kBAAkB,KAAK,WAAW,OAAO,CAAC,MAAM,YAAY,OAAO,CAAC,MAAM,YAAY,OAAO,CAAC,OAAO,WAAW;QAChH,KAAK,IAAA,6BAAkB,EAAC,IAAI,CAAC,EAAE;KAChC,CAAC;IACF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,4 +11,6 @@ export { Health, SEVERITY, Thresholds, createThresholds, isIntact, isAblated, cl
|
|
|
11
11
|
export { Op, Observation, Correction, Expression, observationSigma, observationToAtom, observationToDict, observationFromDict, correctionIsActive, createExpression, healthOf, degraded, expressionToString, expressionToDict, Parser, } from './observation.js';
|
|
12
12
|
export { DriftState, DriftDirection, DriftClassification, classifyDrift, } from './drift.js';
|
|
13
13
|
export { AnomalyState, ANOMALY_SEVERITY, AnomalyClassification, classifyAnomaly, } from './anomaly.js';
|
|
14
|
+
export { marginMiddleware, marginHealthRoute, DEFAULT_THRESHOLDS as EXPRESS_THRESHOLDS, type MarginMiddlewareOptions, type MarginState, type EndpointThresholds, } from './adapters/express.js';
|
|
15
|
+
export { classifySuite, suiteHealthString, DEFAULT_SUITE_THRESHOLDS, type SuiteResults, type SuiteThresholds, } from './adapters/vitest.js';
|
|
14
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEzG,OAAO,EACL,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAC9C,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAC9B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EACvC,gBAAgB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAC3E,kBAAkB,EAClB,gBAAgB,EAAE,QAAQ,EAAE,QAAQ,EAAE,kBAAkB,EAAE,gBAAgB,EAC1E,MAAM,GACP,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EAAE,cAAc,EAAE,mBAAmB,EAC/C,aAAa,GACd,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EACrD,eAAe,GAChB,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEzG,OAAO,EACL,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAC9C,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAC9B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EACvC,gBAAgB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAC3E,kBAAkB,EAClB,gBAAgB,EAAE,QAAQ,EAAE,QAAQ,EAAE,kBAAkB,EAAE,gBAAgB,EAC1E,MAAM,GACP,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EAAE,cAAc,EAAE,mBAAmB,EAC/C,aAAa,GACd,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EACrD,eAAe,GAChB,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,gBAAgB,EAAE,iBAAiB,EACnC,kBAAkB,IAAI,kBAAkB,EACxC,KAAK,uBAAuB,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,GACxE,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,aAAa,EAAE,iBAAiB,EAChC,wBAAwB,EACxB,KAAK,YAAY,EAAE,KAAK,eAAe,GACxC,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Copyright (c) 2026 Cope Labs LLC. MIT License.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.classifyAnomaly = exports.ANOMALY_SEVERITY = exports.AnomalyState = exports.classifyDrift = exports.DriftDirection = exports.DriftState = exports.Parser = exports.expressionToDict = exports.expressionToString = exports.degraded = exports.healthOf = exports.createExpression = exports.correctionIsActive = exports.observationFromDict = exports.observationToDict = exports.observationToAtom = exports.observationSigma = exports.Op = exports.classify = exports.isAblated = exports.isIntact = exports.createThresholds = exports.SEVERITY = exports.Health = exports.minConfidence = exports.confidenceLt = exports.confidenceGte = exports.confidenceRank = exports.Confidence = void 0;
|
|
11
|
+
exports.DEFAULT_SUITE_THRESHOLDS = exports.suiteHealthString = exports.classifySuite = exports.EXPRESS_THRESHOLDS = exports.marginHealthRoute = exports.marginMiddleware = exports.classifyAnomaly = exports.ANOMALY_SEVERITY = exports.AnomalyState = exports.classifyDrift = exports.DriftDirection = exports.DriftState = exports.Parser = exports.expressionToDict = exports.expressionToString = exports.degraded = exports.healthOf = exports.createExpression = exports.correctionIsActive = exports.observationFromDict = exports.observationToDict = exports.observationToAtom = exports.observationSigma = exports.Op = exports.classify = exports.isAblated = exports.isIntact = exports.createThresholds = exports.SEVERITY = exports.Health = exports.minConfidence = exports.confidenceLt = exports.confidenceGte = exports.confidenceRank = exports.Confidence = void 0;
|
|
12
12
|
var confidence_js_1 = require("./confidence.js");
|
|
13
13
|
Object.defineProperty(exports, "Confidence", { enumerable: true, get: function () { return confidence_js_1.Confidence; } });
|
|
14
14
|
Object.defineProperty(exports, "confidenceRank", { enumerable: true, get: function () { return confidence_js_1.confidenceRank; } });
|
|
@@ -43,4 +43,13 @@ var anomaly_js_1 = require("./anomaly.js");
|
|
|
43
43
|
Object.defineProperty(exports, "AnomalyState", { enumerable: true, get: function () { return anomaly_js_1.AnomalyState; } });
|
|
44
44
|
Object.defineProperty(exports, "ANOMALY_SEVERITY", { enumerable: true, get: function () { return anomaly_js_1.ANOMALY_SEVERITY; } });
|
|
45
45
|
Object.defineProperty(exports, "classifyAnomaly", { enumerable: true, get: function () { return anomaly_js_1.classifyAnomaly; } });
|
|
46
|
+
// Adapters
|
|
47
|
+
var express_js_1 = require("./adapters/express.js");
|
|
48
|
+
Object.defineProperty(exports, "marginMiddleware", { enumerable: true, get: function () { return express_js_1.marginMiddleware; } });
|
|
49
|
+
Object.defineProperty(exports, "marginHealthRoute", { enumerable: true, get: function () { return express_js_1.marginHealthRoute; } });
|
|
50
|
+
Object.defineProperty(exports, "EXPRESS_THRESHOLDS", { enumerable: true, get: function () { return express_js_1.DEFAULT_THRESHOLDS; } });
|
|
51
|
+
var vitest_js_1 = require("./adapters/vitest.js");
|
|
52
|
+
Object.defineProperty(exports, "classifySuite", { enumerable: true, get: function () { return vitest_js_1.classifySuite; } });
|
|
53
|
+
Object.defineProperty(exports, "suiteHealthString", { enumerable: true, get: function () { return vitest_js_1.suiteHealthString; } });
|
|
54
|
+
Object.defineProperty(exports, "DEFAULT_SUITE_THRESHOLDS", { enumerable: true, get: function () { return vitest_js_1.DEFAULT_SUITE_THRESHOLDS; } });
|
|
46
55
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;GAOG;;;AAEH,iDAAyG;AAAhG,2GAAA,UAAU,OAAA;AAAE,+GAAA,cAAc,OAAA;AAAE,8GAAA,aAAa,OAAA;AAAE,6GAAA,YAAY,OAAA;AAAE,8GAAA,aAAa,OAAA;AAE/E,yCAGqB;AAFnB,mGAAA,MAAM,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAAc,6GAAA,gBAAgB,OAAA;AAC9C,qGAAA,QAAQ,OAAA;AAAE,sGAAA,SAAS,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAG/B,mDAM0B;AALxB,oGAAA,EAAE,OAAA;AACF,kHAAA,gBAAgB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,qHAAA,mBAAmB,OAAA;AAC3E,oHAAA,kBAAkB,OAAA;AAClB,kHAAA,gBAAgB,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,oHAAA,kBAAkB,OAAA;AAAE,kHAAA,gBAAgB,OAAA;AAC1E,wGAAA,MAAM,OAAA;AAGR,uCAGoB;AAFlB,sGAAA,UAAU,OAAA;AAAE,0GAAA,cAAc,OAAA;AAC1B,yGAAA,aAAa,OAAA;AAGf,2CAGsB;AAFpB,0GAAA,YAAY,OAAA;AAAE,8GAAA,gBAAgB,OAAA;AAC9B,6GAAA,eAAe,OAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;GAOG;;;AAEH,iDAAyG;AAAhG,2GAAA,UAAU,OAAA;AAAE,+GAAA,cAAc,OAAA;AAAE,8GAAA,aAAa,OAAA;AAAE,6GAAA,YAAY,OAAA;AAAE,8GAAA,aAAa,OAAA;AAE/E,yCAGqB;AAFnB,mGAAA,MAAM,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAAc,6GAAA,gBAAgB,OAAA;AAC9C,qGAAA,QAAQ,OAAA;AAAE,sGAAA,SAAS,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAG/B,mDAM0B;AALxB,oGAAA,EAAE,OAAA;AACF,kHAAA,gBAAgB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,qHAAA,mBAAmB,OAAA;AAC3E,oHAAA,kBAAkB,OAAA;AAClB,kHAAA,gBAAgB,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,oHAAA,kBAAkB,OAAA;AAAE,kHAAA,gBAAgB,OAAA;AAC1E,wGAAA,MAAM,OAAA;AAGR,uCAGoB;AAFlB,sGAAA,UAAU,OAAA;AAAE,0GAAA,cAAc,OAAA;AAC1B,yGAAA,aAAa,OAAA;AAGf,2CAGsB;AAFpB,0GAAA,YAAY,OAAA;AAAE,8GAAA,gBAAgB,OAAA;AAC9B,6GAAA,eAAe,OAAA;AAGjB,WAAW;AACX,oDAI+B;AAH7B,8GAAA,gBAAgB,OAAA;AAAE,+GAAA,iBAAiB,OAAA;AACnC,gHAAA,kBAAkB,OAAsB;AAI1C,kDAI8B;AAH5B,0GAAA,aAAa,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAChC,qHAAA,wBAAwB,OAAA"}
|
package/package.json
CHANGED
|
@@ -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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest/Jest test health reporter for margin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies test suite health after every run: pass rate, duration,
|
|
5
|
+
* flake rate. The JS equivalent of margin's pytest plugin.
|
|
6
|
+
*
|
|
7
|
+
* Usage with vitest:
|
|
8
|
+
* // vitest.config.ts
|
|
9
|
+
* import { marginReporter } from 'margin-ts/adapters/vitest';
|
|
10
|
+
* export default { test: { reporters: ['default', marginReporter()] } };
|
|
11
|
+
*
|
|
12
|
+
* Or use standalone after collecting results:
|
|
13
|
+
* import { classifySuite } from 'margin-ts/adapters/vitest';
|
|
14
|
+
* const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Confidence,
|
|
19
|
+
Health,
|
|
20
|
+
Thresholds,
|
|
21
|
+
createThresholds,
|
|
22
|
+
classify,
|
|
23
|
+
Observation,
|
|
24
|
+
Expression,
|
|
25
|
+
createExpression,
|
|
26
|
+
expressionToString,
|
|
27
|
+
} from '../index.js';
|
|
28
|
+
|
|
29
|
+
// -----------------------------------------------------------------------
|
|
30
|
+
// Suite metrics thresholds
|
|
31
|
+
// -----------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface SuiteThresholds {
|
|
34
|
+
passRate: Thresholds;
|
|
35
|
+
skipRate: Thresholds;
|
|
36
|
+
meanDurationMs: Thresholds;
|
|
37
|
+
totalDurationMs: Thresholds;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_SUITE_THRESHOLDS: SuiteThresholds = {
|
|
41
|
+
passRate: createThresholds(0.95, 0.70, true), // higher is better
|
|
42
|
+
skipRate: createThresholds(0.05, 0.20, false), // lower is better
|
|
43
|
+
meanDurationMs: createThresholds(100, 1000, false), // lower is better
|
|
44
|
+
totalDurationMs: createThresholds(30000, 120000, false), // lower is better
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// -----------------------------------------------------------------------
|
|
48
|
+
// Suite results → Expression
|
|
49
|
+
// -----------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface SuiteResults {
|
|
52
|
+
passed: number;
|
|
53
|
+
failed: number;
|
|
54
|
+
skipped: number;
|
|
55
|
+
durationMs: number;
|
|
56
|
+
testDurations?: number[]; // per-test durations in ms
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function classifySuite(
|
|
60
|
+
results: SuiteResults,
|
|
61
|
+
thresholds: SuiteThresholds = DEFAULT_SUITE_THRESHOLDS,
|
|
62
|
+
label = '',
|
|
63
|
+
): Expression {
|
|
64
|
+
const total = results.passed + results.failed + results.skipped;
|
|
65
|
+
if (total === 0) return createExpression([], [], label);
|
|
66
|
+
|
|
67
|
+
const passRate = results.passed / total;
|
|
68
|
+
const skipRate = results.skipped / total;
|
|
69
|
+
const meanDuration = results.testDurations && results.testDurations.length > 0
|
|
70
|
+
? results.testDurations.reduce((a, b) => a + b, 0) / results.testDurations.length
|
|
71
|
+
: results.durationMs / total;
|
|
72
|
+
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const observations: Observation[] = [
|
|
75
|
+
{
|
|
76
|
+
name: 'pass_rate',
|
|
77
|
+
health: classify(passRate, Confidence.HIGH, thresholds.passRate),
|
|
78
|
+
value: passRate,
|
|
79
|
+
baseline: 1.0,
|
|
80
|
+
confidence: Confidence.HIGH,
|
|
81
|
+
higherIsBetter: true,
|
|
82
|
+
provenance: [],
|
|
83
|
+
measuredAt: now,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'skip_rate',
|
|
87
|
+
health: classify(skipRate, Confidence.HIGH, thresholds.skipRate),
|
|
88
|
+
value: skipRate,
|
|
89
|
+
baseline: 0.0,
|
|
90
|
+
confidence: Confidence.HIGH,
|
|
91
|
+
higherIsBetter: false,
|
|
92
|
+
provenance: [],
|
|
93
|
+
measuredAt: now,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'mean_duration',
|
|
97
|
+
health: classify(meanDuration, Confidence.HIGH, thresholds.meanDurationMs),
|
|
98
|
+
value: meanDuration,
|
|
99
|
+
baseline: 50,
|
|
100
|
+
confidence: Confidence.HIGH,
|
|
101
|
+
higherIsBetter: false,
|
|
102
|
+
provenance: [],
|
|
103
|
+
measuredAt: now,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'total_duration',
|
|
107
|
+
health: classify(results.durationMs, Confidence.MODERATE, thresholds.totalDurationMs),
|
|
108
|
+
value: results.durationMs,
|
|
109
|
+
baseline: 10000,
|
|
110
|
+
confidence: Confidence.MODERATE,
|
|
111
|
+
higherIsBetter: false,
|
|
112
|
+
provenance: [],
|
|
113
|
+
measuredAt: now,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'failures',
|
|
117
|
+
health: classify(results.failed, Confidence.HIGH, createThresholds(0, 5, false)),
|
|
118
|
+
value: results.failed,
|
|
119
|
+
baseline: 0,
|
|
120
|
+
confidence: Confidence.HIGH,
|
|
121
|
+
higherIsBetter: false,
|
|
122
|
+
provenance: [],
|
|
123
|
+
measuredAt: now,
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
return createExpression(observations, [], label || `suite (${total} tests)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format suite health as a printable string.
|
|
132
|
+
*/
|
|
133
|
+
export function suiteHealthString(results: SuiteResults, thresholds?: SuiteThresholds): string {
|
|
134
|
+
const expr = classifySuite(results, thresholds);
|
|
135
|
+
const total = results.passed + results.failed + results.skipped;
|
|
136
|
+
const lines = [
|
|
137
|
+
`margin health: ${total} tests (${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped)`,
|
|
138
|
+
` ${expressionToString(expr)}`,
|
|
139
|
+
];
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,3 +31,16 @@ export {
|
|
|
31
31
|
AnomalyState, ANOMALY_SEVERITY, AnomalyClassification,
|
|
32
32
|
classifyAnomaly,
|
|
33
33
|
} from './anomaly.js';
|
|
34
|
+
|
|
35
|
+
// Adapters
|
|
36
|
+
export {
|
|
37
|
+
marginMiddleware, marginHealthRoute,
|
|
38
|
+
DEFAULT_THRESHOLDS as EXPRESS_THRESHOLDS,
|
|
39
|
+
type MarginMiddlewareOptions, type MarginState, type EndpointThresholds,
|
|
40
|
+
} from './adapters/express.js';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
classifySuite, suiteHealthString,
|
|
44
|
+
DEFAULT_SUITE_THRESHOLDS,
|
|
45
|
+
type SuiteResults, type SuiteThresholds,
|
|
46
|
+
} from './adapters/vitest.js';
|