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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming health updates via Server-Sent Events (SSE).
|
|
3
|
+
*
|
|
4
|
+
* Push health/drift/anomaly updates to browser dashboards in real-time.
|
|
5
|
+
*
|
|
6
|
+
* Express:
|
|
7
|
+
* app.get('/margin/stream', marginSSE(monitor));
|
|
8
|
+
*
|
|
9
|
+
* Next.js Pages Router:
|
|
10
|
+
* export default marginSSEHandler(monitor);
|
|
11
|
+
*
|
|
12
|
+
* Browser client:
|
|
13
|
+
* const source = new EventSource('/margin/stream');
|
|
14
|
+
* source.onmessage = (e) => {
|
|
15
|
+
* const health = JSON.parse(e.data);
|
|
16
|
+
* updateDashboard(health);
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* Zero dependencies. Works with any framework that supports
|
|
20
|
+
* writable response streams (Express, Fastify, Next.js, raw Node http).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Confidence,
|
|
25
|
+
Health,
|
|
26
|
+
Observation,
|
|
27
|
+
Expression,
|
|
28
|
+
Parser,
|
|
29
|
+
createExpression,
|
|
30
|
+
expressionToString,
|
|
31
|
+
expressionToDict,
|
|
32
|
+
classifyDrift,
|
|
33
|
+
DriftClassification,
|
|
34
|
+
DriftState,
|
|
35
|
+
DriftDirection,
|
|
36
|
+
classifyAnomaly,
|
|
37
|
+
AnomalyClassification,
|
|
38
|
+
AnomalyState,
|
|
39
|
+
} from '../index.js';
|
|
40
|
+
|
|
41
|
+
// -----------------------------------------------------------------------
|
|
42
|
+
// StreamingMonitor — the TS equivalent of Python's Monitor
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
interface ComponentWindow {
|
|
46
|
+
values: number[];
|
|
47
|
+
observations: Observation[];
|
|
48
|
+
maxWindow: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class StreamingMonitor {
|
|
52
|
+
private parser: Parser;
|
|
53
|
+
private windows: Map<string, ComponentWindow> = new Map();
|
|
54
|
+
private _expression: Expression | null = null;
|
|
55
|
+
private _step = 0;
|
|
56
|
+
private maxWindow: number;
|
|
57
|
+
private minAnomalyRef: number;
|
|
58
|
+
|
|
59
|
+
constructor(parser: Parser, options: { window?: number; minAnomalyRef?: number } = {}) {
|
|
60
|
+
this.parser = parser;
|
|
61
|
+
this.maxWindow = options.window ?? 100;
|
|
62
|
+
this.minAnomalyRef = options.minAnomalyRef ?? 10;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get expression(): Expression | null { return this._expression; }
|
|
66
|
+
get step(): number { return this._step; }
|
|
67
|
+
|
|
68
|
+
update(values: Record<string, number>, now?: Date): Expression {
|
|
69
|
+
now = now ?? new Date();
|
|
70
|
+
this._expression = this.parser.parse(values, { step: this._step });
|
|
71
|
+
this._step++;
|
|
72
|
+
|
|
73
|
+
// Update per-component windows
|
|
74
|
+
for (const obs of this._expression.observations) {
|
|
75
|
+
if (!this.windows.has(obs.name)) {
|
|
76
|
+
this.windows.set(obs.name, { values: [], observations: [], maxWindow: this.maxWindow });
|
|
77
|
+
}
|
|
78
|
+
const w = this.windows.get(obs.name)!;
|
|
79
|
+
w.values.push(obs.value);
|
|
80
|
+
// Stamp the observation with time for drift
|
|
81
|
+
const stamped = { ...obs, measuredAt: now };
|
|
82
|
+
w.observations.push(stamped);
|
|
83
|
+
if (w.values.length > this.maxWindow) {
|
|
84
|
+
w.values.shift();
|
|
85
|
+
w.observations.shift();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this._expression;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
drift(component: string): DriftClassification | null {
|
|
93
|
+
const w = this.windows.get(component);
|
|
94
|
+
if (!w || w.observations.length < 3) return null;
|
|
95
|
+
return classifyDrift(w.observations);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
anomaly(component: string): AnomalyClassification | null {
|
|
99
|
+
const w = this.windows.get(component);
|
|
100
|
+
if (!w || w.values.length < this.minAnomalyRef + 1) return null;
|
|
101
|
+
const ref = w.values.slice(0, -1);
|
|
102
|
+
const current = w.values[w.values.length - 1];
|
|
103
|
+
return classifyAnomaly(current, ref, { component });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
status(): Record<string, unknown> {
|
|
107
|
+
const drift: Record<string, unknown> = {};
|
|
108
|
+
const anomaly: Record<string, unknown> = {};
|
|
109
|
+
|
|
110
|
+
for (const [name] of this.windows) {
|
|
111
|
+
const dc = this.drift(name);
|
|
112
|
+
if (dc) drift[name] = { state: dc.state, direction: dc.direction, rate: dc.rate };
|
|
113
|
+
const ac = this.anomaly(name);
|
|
114
|
+
if (ac) anomaly[name] = { state: ac.state, zScore: ac.zScore };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
step: this._step,
|
|
119
|
+
expression: this._expression ? expressionToString(this._expression) : null,
|
|
120
|
+
health: this._expression ? expressionToDict(this._expression) : null,
|
|
121
|
+
drift,
|
|
122
|
+
anomaly,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
reset(): void {
|
|
127
|
+
this.windows.clear();
|
|
128
|
+
this._expression = null;
|
|
129
|
+
this._step = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// SSE helpers
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** Connected SSE client. */
|
|
138
|
+
interface SSEClient {
|
|
139
|
+
id: string;
|
|
140
|
+
res: any;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let _clients: SSEClient[] = [];
|
|
144
|
+
let _clientIdCounter = 0;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Broadcast a status update to all connected SSE clients.
|
|
148
|
+
* Call this after each monitor.update().
|
|
149
|
+
*/
|
|
150
|
+
export function broadcast(monitor: StreamingMonitor): void {
|
|
151
|
+
const data = JSON.stringify(monitor.status());
|
|
152
|
+
const message = `data: ${data}\n\n`;
|
|
153
|
+
_clients = _clients.filter(client => {
|
|
154
|
+
try {
|
|
155
|
+
client.res.write(message);
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false; // client disconnected
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Express/Fastify route handler for SSE streaming.
|
|
165
|
+
*
|
|
166
|
+
* app.get('/margin/stream', marginSSE(monitor));
|
|
167
|
+
*
|
|
168
|
+
* Each connected browser receives real-time health updates.
|
|
169
|
+
* Call broadcast(monitor) after each monitor.update() to push.
|
|
170
|
+
*/
|
|
171
|
+
export function marginSSE(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
172
|
+
return (req: any, res: any) => {
|
|
173
|
+
res.writeHead(200, {
|
|
174
|
+
'Content-Type': 'text/event-stream',
|
|
175
|
+
'Cache-Control': 'no-cache',
|
|
176
|
+
'Connection': 'keep-alive',
|
|
177
|
+
'Access-Control-Allow-Origin': '*',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Send current state immediately
|
|
181
|
+
const initial = JSON.stringify(monitor.status());
|
|
182
|
+
res.write(`data: ${initial}\n\n`);
|
|
183
|
+
|
|
184
|
+
const client: SSEClient = { id: String(++_clientIdCounter), res };
|
|
185
|
+
_clients.push(client);
|
|
186
|
+
|
|
187
|
+
req.on('close', () => {
|
|
188
|
+
_clients = _clients.filter(c => c.id !== client.id);
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Next.js Pages Router SSE handler.
|
|
195
|
+
*
|
|
196
|
+
* // pages/api/margin/stream.ts
|
|
197
|
+
* export default marginSSEHandler(monitor);
|
|
198
|
+
*/
|
|
199
|
+
export function marginSSEHandler(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
200
|
+
return marginSSE(monitor);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create a polling endpoint (for environments that don't support SSE).
|
|
205
|
+
* Returns the current status as JSON on each request.
|
|
206
|
+
*
|
|
207
|
+
* app.get('/margin/poll', marginPoll(monitor));
|
|
208
|
+
*/
|
|
209
|
+
export function marginPoll(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
210
|
+
return (_req: any, res: any) => {
|
|
211
|
+
res.json(monitor.status());
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Number of connected SSE clients. */
|
|
216
|
+
export function connectedClients(): number {
|
|
217
|
+
return _clients.length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Disconnect all SSE clients (for testing). */
|
|
221
|
+
export function resetClients(): void {
|
|
222
|
+
for (const client of _clients) {
|
|
223
|
+
try { client.res.end(); } catch {}
|
|
224
|
+
}
|
|
225
|
+
_clients = [];
|
|
226
|
+
_clientIdCounter = 0;
|
|
227
|
+
}
|
|
@@ -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,30 @@ 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';
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
withMargin, withMarginApp,
|
|
50
|
+
marginHealthHandler, marginHealthAppHandler,
|
|
51
|
+
resetRoutes,
|
|
52
|
+
DEFAULT_NEXTJS_THRESHOLDS,
|
|
53
|
+
type WithMarginOptions, type NextjsThresholds,
|
|
54
|
+
} from './adapters/nextjs.js';
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
StreamingMonitor,
|
|
58
|
+
broadcast, marginSSE, marginSSEHandler, marginPoll,
|
|
59
|
+
connectedClients, resetClients,
|
|
60
|
+
} from './adapters/stream.js';
|