@everystate/perf 1.0.2 → 1.0.3
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/index.d.ts +106 -0
- package/package.json +4 -5
- package/self-test.js +307 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/perf
|
|
3
|
+
*
|
|
4
|
+
* Non-invasive performance monitoring for EveryState stores.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EveryStateStore } from '@everystate/core';
|
|
8
|
+
|
|
9
|
+
export interface PerfMonitorOptions {
|
|
10
|
+
/** Maximum timeline events to keep (default: 10000) */
|
|
11
|
+
maxEvents?: number;
|
|
12
|
+
/** Track values in timeline events (default: false) */
|
|
13
|
+
trackValues?: boolean;
|
|
14
|
+
/** Track get() calls (default: false) */
|
|
15
|
+
trackGets?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PathStat {
|
|
19
|
+
path: string;
|
|
20
|
+
sets: number;
|
|
21
|
+
gets: number;
|
|
22
|
+
fires: number;
|
|
23
|
+
avgSetMs: number;
|
|
24
|
+
peakSetMs: number;
|
|
25
|
+
subscriberCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HotListener {
|
|
29
|
+
path: string;
|
|
30
|
+
fires: number;
|
|
31
|
+
firesPerSec: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActiveSub {
|
|
35
|
+
id: string;
|
|
36
|
+
path: string;
|
|
37
|
+
ts: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TimelineEvent {
|
|
41
|
+
type: 'set' | 'get' | 'fire' | 'subscribe' | 'unsubscribe' | 'batch' | 'setMany';
|
|
42
|
+
path?: string;
|
|
43
|
+
paths?: string[];
|
|
44
|
+
dur?: number;
|
|
45
|
+
ts: number;
|
|
46
|
+
subId?: string;
|
|
47
|
+
_seq: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PerfReport {
|
|
51
|
+
sessionId: string;
|
|
52
|
+
elapsedMs: number;
|
|
53
|
+
totalEvents: number;
|
|
54
|
+
dropped: number;
|
|
55
|
+
summary: {
|
|
56
|
+
totalSets: number;
|
|
57
|
+
totalGets: number;
|
|
58
|
+
totalFires: number;
|
|
59
|
+
uniquePaths: number;
|
|
60
|
+
totalSubscribes: number;
|
|
61
|
+
totalUnsubscribes: number;
|
|
62
|
+
activeSubscribers: number;
|
|
63
|
+
setsPerSec: number;
|
|
64
|
+
};
|
|
65
|
+
batches: {
|
|
66
|
+
count: number;
|
|
67
|
+
totalPaths: number;
|
|
68
|
+
totalCoalesced: number;
|
|
69
|
+
};
|
|
70
|
+
hotPaths: PathStat[];
|
|
71
|
+
hotListeners: HotListener[];
|
|
72
|
+
activeSubs: ActiveSub[];
|
|
73
|
+
timeline: TimelineEvent[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PerfMonitor {
|
|
77
|
+
/** Generate a performance report snapshot */
|
|
78
|
+
report(): PerfReport;
|
|
79
|
+
/** Download the report as a JSON file */
|
|
80
|
+
download(filename?: string): PerfReport;
|
|
81
|
+
/** Reset all collected stats */
|
|
82
|
+
reset(): void;
|
|
83
|
+
/** Unwrap the store (restore original methods) */
|
|
84
|
+
destroy(): void;
|
|
85
|
+
/** Raw timeline array */
|
|
86
|
+
readonly timeline: TimelineEvent[];
|
|
87
|
+
/** Raw per-path stats map */
|
|
88
|
+
readonly pathStats: Map<string, any>;
|
|
89
|
+
/** Session identifier */
|
|
90
|
+
readonly sessionId: string;
|
|
91
|
+
/** Reference to the wrapped store (used by State Tree tab) */
|
|
92
|
+
readonly store: EveryStateStore;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a performance monitor that wraps an EveryState store.
|
|
97
|
+
* Must be called BEFORE any subscriptions for full visibility.
|
|
98
|
+
*/
|
|
99
|
+
export function createPerfMonitor(store: EveryStateStore, options?: PerfMonitorOptions): PerfMonitor;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mount a floating overlay in the browser that displays live perf stats.
|
|
103
|
+
* Uses Shadow DOM for style encapsulation.
|
|
104
|
+
* @returns Unmount function
|
|
105
|
+
*/
|
|
106
|
+
export function mountOverlay(monitor: PerfMonitor, container: HTMLElement): () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystate/perf",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "EveryState Performance Monitor: Non-invasive performance monitoring with method wrapping, path heatmaps, timeline recording, browser overlay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -31,9 +31,8 @@
|
|
|
31
31
|
"test": "node self-test.js"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"README.md"
|
|
34
|
+
"*.js",
|
|
35
|
+
"*.d.ts",
|
|
36
|
+
"*.md"
|
|
38
37
|
]
|
|
39
38
|
}
|
package/self-test.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/perf: self-test.js
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency self-test. Runs on postinstall.
|
|
5
|
+
* Tests the perfMonitor in Node (no DOM, no overlay).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createPerfMonitor } from './perfMonitor.js';
|
|
9
|
+
|
|
10
|
+
// Minimal EveryState mock (matches the API surface we wrap)
|
|
11
|
+
function createMockStore() {
|
|
12
|
+
const state = {};
|
|
13
|
+
const listeners = new Map();
|
|
14
|
+
|
|
15
|
+
const store = {
|
|
16
|
+
get(path) {
|
|
17
|
+
const parts = path.split('.');
|
|
18
|
+
let cur = state;
|
|
19
|
+
for (const p of parts) {
|
|
20
|
+
if (cur == null) return undefined;
|
|
21
|
+
cur = cur[p];
|
|
22
|
+
}
|
|
23
|
+
return cur;
|
|
24
|
+
},
|
|
25
|
+
set(path, value) {
|
|
26
|
+
const parts = path.split('.');
|
|
27
|
+
const key = parts.pop();
|
|
28
|
+
let cur = state;
|
|
29
|
+
for (const p of parts) {
|
|
30
|
+
if (!cur[p]) cur[p] = {};
|
|
31
|
+
cur = cur[p];
|
|
32
|
+
}
|
|
33
|
+
cur[key] = value;
|
|
34
|
+
|
|
35
|
+
// Notify exact listeners
|
|
36
|
+
const exact = listeners.get(path);
|
|
37
|
+
if (exact) exact.forEach(cb => cb(value, { path, value }));
|
|
38
|
+
|
|
39
|
+
// Notify wildcard *
|
|
40
|
+
const global = listeners.get('*');
|
|
41
|
+
if (global) global.forEach(cb => cb({ path, value }));
|
|
42
|
+
|
|
43
|
+
return value;
|
|
44
|
+
},
|
|
45
|
+
subscribe(path, handler) {
|
|
46
|
+
if (!listeners.has(path)) listeners.set(path, new Set());
|
|
47
|
+
listeners.get(path).add(handler);
|
|
48
|
+
return () => {
|
|
49
|
+
const set = listeners.get(path);
|
|
50
|
+
if (set) {
|
|
51
|
+
set.delete(handler);
|
|
52
|
+
if (set.size === 0) listeners.delete(path);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
batch(fn) {
|
|
57
|
+
fn();
|
|
58
|
+
},
|
|
59
|
+
setMany(entries) {
|
|
60
|
+
if (entries && typeof entries === 'object' && !Array.isArray(entries) && !(entries instanceof Map)) {
|
|
61
|
+
for (const [p, v] of Object.entries(entries)) {
|
|
62
|
+
store.set(p, v);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
return store;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let passed = 0;
|
|
71
|
+
let failed = 0;
|
|
72
|
+
|
|
73
|
+
function assert(label, condition) {
|
|
74
|
+
if (condition) {
|
|
75
|
+
passed++;
|
|
76
|
+
console.log(` ✓ ${label}`);
|
|
77
|
+
} else {
|
|
78
|
+
failed++;
|
|
79
|
+
console.error(` ✗ ${label}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// == Tests ================================================================
|
|
84
|
+
|
|
85
|
+
console.log('\n1. createPerfMonitor wraps store methods');
|
|
86
|
+
{
|
|
87
|
+
const store = createMockStore();
|
|
88
|
+
const origSet = store.set;
|
|
89
|
+
const origSubscribe = store.subscribe;
|
|
90
|
+
const origBatch = store.batch;
|
|
91
|
+
const origSetMany = store.setMany;
|
|
92
|
+
|
|
93
|
+
const perf = createPerfMonitor(store);
|
|
94
|
+
|
|
95
|
+
assert('set is wrapped', store.set !== origSet);
|
|
96
|
+
assert('subscribe is wrapped', store.subscribe !== origSubscribe);
|
|
97
|
+
assert('batch is wrapped', store.batch !== origBatch);
|
|
98
|
+
assert('setMany is wrapped', store.setMany !== origSetMany);
|
|
99
|
+
|
|
100
|
+
perf.destroy();
|
|
101
|
+
assert('set is restored after destroy', store.set === origSet);
|
|
102
|
+
assert('subscribe is restored after destroy', store.subscribe === origSubscribe);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log('\n2. tracks set events');
|
|
106
|
+
{
|
|
107
|
+
const store = createMockStore();
|
|
108
|
+
const perf = createPerfMonitor(store);
|
|
109
|
+
|
|
110
|
+
store.set('count', 1);
|
|
111
|
+
store.set('count', 2);
|
|
112
|
+
store.set('name', 'Alice');
|
|
113
|
+
|
|
114
|
+
const r = perf.report();
|
|
115
|
+
assert('totalSets = 3', r.summary.totalSets === 3);
|
|
116
|
+
assert('uniquePaths = 2', r.summary.uniquePaths === 2);
|
|
117
|
+
assert('hotPaths[0] is count (2 sets)', r.hotPaths[0].path === 'count' && r.hotPaths[0].sets === 2);
|
|
118
|
+
assert('hotPaths[1] is name (1 set)', r.hotPaths[1].path === 'name' && r.hotPaths[1].sets === 1);
|
|
119
|
+
|
|
120
|
+
perf.destroy();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('\n3. tracks subscribe and fire events');
|
|
124
|
+
{
|
|
125
|
+
const store = createMockStore();
|
|
126
|
+
const perf = createPerfMonitor(store);
|
|
127
|
+
|
|
128
|
+
let fires = 0;
|
|
129
|
+
const unsub = store.subscribe('count', () => { fires++; });
|
|
130
|
+
|
|
131
|
+
store.set('count', 1);
|
|
132
|
+
store.set('count', 2);
|
|
133
|
+
|
|
134
|
+
const r = perf.report();
|
|
135
|
+
assert('totalSubscribes = 1', r.summary.totalSubscribes === 1);
|
|
136
|
+
assert('activeSubscribers = 1', r.summary.activeSubscribers === 1);
|
|
137
|
+
assert('totalFires = 2', r.summary.totalFires === 2);
|
|
138
|
+
assert('handler actually fired', fires === 2);
|
|
139
|
+
|
|
140
|
+
unsub();
|
|
141
|
+
const r2 = perf.report();
|
|
142
|
+
assert('totalUnsubscribes = 1 after unsub', r2.summary.totalUnsubscribes === 1);
|
|
143
|
+
assert('activeSubscribers = 0 after unsub', r2.summary.activeSubscribers === 0);
|
|
144
|
+
|
|
145
|
+
perf.destroy();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log('\n4. tracks batch events');
|
|
149
|
+
{
|
|
150
|
+
const store = createMockStore();
|
|
151
|
+
const perf = createPerfMonitor(store);
|
|
152
|
+
|
|
153
|
+
store.batch(() => {
|
|
154
|
+
store.set('a', 1);
|
|
155
|
+
store.set('b', 2);
|
|
156
|
+
store.set('a', 3); // duplicate path
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const r = perf.report();
|
|
160
|
+
assert('batch count = 1', r.batches.count === 1);
|
|
161
|
+
assert('batch totalPaths = 3', r.batches.totalPaths === 3);
|
|
162
|
+
assert('batch coalesced = 1 (duplicate a)', r.batches.totalCoalesced === 1);
|
|
163
|
+
|
|
164
|
+
perf.destroy();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log('\n5. tracks setMany events');
|
|
168
|
+
{
|
|
169
|
+
const store = createMockStore();
|
|
170
|
+
const perf = createPerfMonitor(store);
|
|
171
|
+
|
|
172
|
+
store.setMany({ x: 1, y: 2, z: 3 });
|
|
173
|
+
|
|
174
|
+
const r = perf.report();
|
|
175
|
+
const setManyEvents = r.timeline.filter(e => e.type === 'setMany');
|
|
176
|
+
assert('setMany event recorded', setManyEvents.length === 1);
|
|
177
|
+
assert('setMany pathCount = 3', setManyEvents[0].pathCount === 3);
|
|
178
|
+
|
|
179
|
+
perf.destroy();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log('\n6. report structure');
|
|
183
|
+
{
|
|
184
|
+
const store = createMockStore();
|
|
185
|
+
const perf = createPerfMonitor(store);
|
|
186
|
+
|
|
187
|
+
store.set('x', 1);
|
|
188
|
+
|
|
189
|
+
const r = perf.report();
|
|
190
|
+
assert('has sessionId', typeof r.sessionId === 'string' && r.sessionId.length > 0);
|
|
191
|
+
assert('has elapsedMs', typeof r.elapsedMs === 'number');
|
|
192
|
+
assert('has summary', typeof r.summary === 'object');
|
|
193
|
+
assert('has batches', typeof r.batches === 'object');
|
|
194
|
+
assert('has hotPaths', Array.isArray(r.hotPaths));
|
|
195
|
+
assert('has hotListeners', Array.isArray(r.hotListeners));
|
|
196
|
+
assert('has activeSubs', Array.isArray(r.activeSubs));
|
|
197
|
+
assert('has timeline', Array.isArray(r.timeline));
|
|
198
|
+
|
|
199
|
+
perf.destroy();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log('\n7. reset clears all data');
|
|
203
|
+
{
|
|
204
|
+
const store = createMockStore();
|
|
205
|
+
const perf = createPerfMonitor(store);
|
|
206
|
+
|
|
207
|
+
store.set('x', 1);
|
|
208
|
+
store.set('x', 2);
|
|
209
|
+
|
|
210
|
+
perf.reset();
|
|
211
|
+
const r = perf.report();
|
|
212
|
+
assert('totalEvents = 0 after reset', r.totalEvents === 0);
|
|
213
|
+
assert('totalSets = 0 after reset', r.summary.totalSets === 0);
|
|
214
|
+
assert('uniquePaths = 0 after reset', r.summary.uniquePaths === 0);
|
|
215
|
+
assert('timeline empty after reset', r.timeline.length === 0);
|
|
216
|
+
|
|
217
|
+
perf.destroy();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log('\n8. ring buffer drops old events');
|
|
221
|
+
{
|
|
222
|
+
const store = createMockStore();
|
|
223
|
+
const perf = createPerfMonitor(store, { maxEvents: 5 });
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < 10; i++) {
|
|
226
|
+
store.set('x', i);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const r = perf.report();
|
|
230
|
+
assert('totalEvents = 10', r.totalEvents === 10);
|
|
231
|
+
assert('timeline length capped at 5', perf.timeline.length === 5);
|
|
232
|
+
assert('dropped = 5', r.dropped === 5);
|
|
233
|
+
assert('oldest event is seq 5', perf.timeline[0]._seq === 5);
|
|
234
|
+
|
|
235
|
+
perf.destroy();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log('\n9. trackValues option');
|
|
239
|
+
{
|
|
240
|
+
const store = createMockStore();
|
|
241
|
+
const perfNoVals = createPerfMonitor(store);
|
|
242
|
+
store.set('x', 42);
|
|
243
|
+
const evtNoVal = perfNoVals.timeline.find(e => e.type === 'set');
|
|
244
|
+
assert('value not tracked by default', evtNoVal.value === undefined);
|
|
245
|
+
perfNoVals.destroy();
|
|
246
|
+
|
|
247
|
+
const store2 = createMockStore();
|
|
248
|
+
const perfWithVals = createPerfMonitor(store2, { trackValues: true });
|
|
249
|
+
store2.set('x', 42);
|
|
250
|
+
const evtWithVal = perfWithVals.timeline.find(e => e.type === 'set');
|
|
251
|
+
assert('value tracked when enabled', evtWithVal.value === 42);
|
|
252
|
+
perfWithVals.destroy();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log('\n10. trackGets option');
|
|
256
|
+
{
|
|
257
|
+
const store = createMockStore();
|
|
258
|
+
const perf = createPerfMonitor(store, { trackGets: true });
|
|
259
|
+
store.set('x', 1);
|
|
260
|
+
store.get('x');
|
|
261
|
+
store.get('x');
|
|
262
|
+
|
|
263
|
+
const r = perf.report();
|
|
264
|
+
const xStats = r.hotPaths.find(p => p.path === 'x');
|
|
265
|
+
assert('gets tracked when enabled', xStats.gets === 2);
|
|
266
|
+
|
|
267
|
+
perf.destroy();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log('\n11. per-path timing');
|
|
271
|
+
{
|
|
272
|
+
const store = createMockStore();
|
|
273
|
+
const perf = createPerfMonitor(store);
|
|
274
|
+
|
|
275
|
+
store.set('a', 1);
|
|
276
|
+
store.set('a', 2);
|
|
277
|
+
store.set('a', 3);
|
|
278
|
+
|
|
279
|
+
const r = perf.report();
|
|
280
|
+
const aStats = r.hotPaths.find(p => p.path === 'a');
|
|
281
|
+
assert('avgSetMs is a number', typeof aStats.avgSetMs === 'number');
|
|
282
|
+
assert('peakSetMs >= avgSetMs', aStats.peakSetMs >= aStats.avgSetMs);
|
|
283
|
+
|
|
284
|
+
perf.destroy();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log('\n12. download returns report object');
|
|
288
|
+
{
|
|
289
|
+
const store = createMockStore();
|
|
290
|
+
const perf = createPerfMonitor(store);
|
|
291
|
+
store.set('x', 1);
|
|
292
|
+
|
|
293
|
+
// In Node there's no document, so download just returns data
|
|
294
|
+
const data = perf.download('test.json');
|
|
295
|
+
assert('download returns data', data !== undefined);
|
|
296
|
+
assert('download data has sessionId', typeof data.sessionId === 'string');
|
|
297
|
+
assert('download data has full timeline', Array.isArray(data.timeline));
|
|
298
|
+
|
|
299
|
+
perf.destroy();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// == Summary ==============================================================
|
|
303
|
+
|
|
304
|
+
console.log(`\n@everystate/perf v1.0.0 self-test`);
|
|
305
|
+
console.log(`✓ ${passed} assertions passed${failed ? `, ✗ ${failed} failed` : ''}\n`);
|
|
306
|
+
|
|
307
|
+
if (failed > 0) process.exit(1);
|