@hearthlog/runtime 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- package/README.md +0 -3
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance Monitoring Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides structured logging for timing critical operations.
|
|
5
|
+
* Persists recent timings locally so the developer console can show
|
|
6
|
+
* a measurable performance baseline across reloads.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export type PerfCategory =
|
|
14
|
+
| 'sync'
|
|
15
|
+
| 'projection'
|
|
16
|
+
| 'rebuild'
|
|
17
|
+
| 'realtime'
|
|
18
|
+
| 'db'
|
|
19
|
+
| 'network'
|
|
20
|
+
| 'ui'
|
|
21
|
+
| 'startup';
|
|
22
|
+
|
|
23
|
+
export type PerfBaselineMetric =
|
|
24
|
+
| 'firstContentfulPaint'
|
|
25
|
+
| 'shellInteractive'
|
|
26
|
+
| 'dataReady'
|
|
27
|
+
| 'pageContentReady'
|
|
28
|
+
| 'firstSyncComplete'
|
|
29
|
+
| 'realtimeEventHandlingTime'
|
|
30
|
+
| 'invalidationCost'
|
|
31
|
+
| 'entityPageLoadTime';
|
|
32
|
+
|
|
33
|
+
export type PerfMetadata = Record<string, unknown> & {
|
|
34
|
+
baselineMetric?: PerfBaselineMetric;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type PerfMetadataInput = PerfMetadata | (() => PerfMetadata | undefined);
|
|
38
|
+
|
|
39
|
+
export interface PerfEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
category: PerfCategory;
|
|
42
|
+
operation: string;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
metadata?: PerfMetadata;
|
|
46
|
+
baselineMetric?: PerfBaselineMetric;
|
|
47
|
+
isSlow: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TimerResult {
|
|
51
|
+
durationMs: number;
|
|
52
|
+
isSlow: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PerfMetricSummary {
|
|
56
|
+
metric: PerfBaselineMetric;
|
|
57
|
+
group?: string;
|
|
58
|
+
label: string;
|
|
59
|
+
description: string;
|
|
60
|
+
sampleCount: number;
|
|
61
|
+
latestMs: number | null;
|
|
62
|
+
averageMs: number | null;
|
|
63
|
+
p95Ms: number | null;
|
|
64
|
+
slowCount: number;
|
|
65
|
+
latestTimestamp: number | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Configuration
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
const PERF_STORAGE_KEY = 'perf:entries';
|
|
73
|
+
const MAX_PERSISTED_ENTRIES = 250;
|
|
74
|
+
|
|
75
|
+
export const BASELINE_METRICS: readonly PerfBaselineMetric[] = [
|
|
76
|
+
'firstContentfulPaint',
|
|
77
|
+
'shellInteractive',
|
|
78
|
+
'dataReady',
|
|
79
|
+
'pageContentReady',
|
|
80
|
+
'firstSyncComplete',
|
|
81
|
+
'realtimeEventHandlingTime',
|
|
82
|
+
'invalidationCost',
|
|
83
|
+
'entityPageLoadTime',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const BASELINE_METRIC_DETAILS: Record<
|
|
87
|
+
PerfBaselineMetric,
|
|
88
|
+
{ label: string; description: string }
|
|
89
|
+
> = {
|
|
90
|
+
firstContentfulPaint: {
|
|
91
|
+
label: 'First Contentful Paint',
|
|
92
|
+
description: 'Browser-reported first visible content, including the initial branded frame.',
|
|
93
|
+
},
|
|
94
|
+
shellInteractive: {
|
|
95
|
+
label: 'Shell Interactive',
|
|
96
|
+
description: 'Elapsed from document navigation until real navigation is mounted and painted.',
|
|
97
|
+
},
|
|
98
|
+
dataReady: {
|
|
99
|
+
label: 'Local Data Ready',
|
|
100
|
+
description: 'Elapsed from document navigation until local schema and the selected read view are ready.',
|
|
101
|
+
},
|
|
102
|
+
pageContentReady: {
|
|
103
|
+
label: 'Page data rendered',
|
|
104
|
+
description: 'Required page-loader data has rendered. Initial loads include app startup; navigation and refresh samples start with that action. Streamed regions can appear earlier.',
|
|
105
|
+
},
|
|
106
|
+
firstSyncComplete: {
|
|
107
|
+
label: 'First Sync Complete',
|
|
108
|
+
description: 'Captured when the initial sync attempt finishes during app startup.',
|
|
109
|
+
},
|
|
110
|
+
realtimeEventHandlingTime: {
|
|
111
|
+
label: 'Realtime Handling',
|
|
112
|
+
description: 'Measures per-event processing cost for live WebSocket updates.',
|
|
113
|
+
},
|
|
114
|
+
invalidationCost: {
|
|
115
|
+
label: 'Invalidation',
|
|
116
|
+
description: 'Time from a refresh request through deferred queries and their rendered update.',
|
|
117
|
+
},
|
|
118
|
+
entityPageLoadTime: {
|
|
119
|
+
label: 'Entity Page Load',
|
|
120
|
+
description: 'Measures the client-side entity detail route data load.',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Thresholds for slow operation detection (in milliseconds).
|
|
126
|
+
* Operations exceeding these thresholds will be logged as warnings.
|
|
127
|
+
*/
|
|
128
|
+
const SLOW_THRESHOLDS: Record<PerfCategory, number> = {
|
|
129
|
+
sync: 1000,
|
|
130
|
+
projection: 200,
|
|
131
|
+
rebuild: 2000,
|
|
132
|
+
realtime: 100,
|
|
133
|
+
db: 500,
|
|
134
|
+
network: 3000,
|
|
135
|
+
ui: 300,
|
|
136
|
+
startup: 3000,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const perfSubscribers = new Set<(entries: PerfEntry[]) => void>();
|
|
140
|
+
|
|
141
|
+
let persistedEntries = loadPersistedEntries();
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if performance logging is enabled.
|
|
145
|
+
* Enabled by default. Disable with: localStorage.setItem('perf', 'false')
|
|
146
|
+
*/
|
|
147
|
+
function isPerfEnabled(): boolean {
|
|
148
|
+
if (typeof window === 'undefined') return true;
|
|
149
|
+
try {
|
|
150
|
+
return localStorage.getItem('perf') !== 'false';
|
|
151
|
+
} catch {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isPerfCategory(value: unknown): value is PerfCategory {
|
|
157
|
+
return (
|
|
158
|
+
value === 'sync' ||
|
|
159
|
+
value === 'projection' ||
|
|
160
|
+
value === 'rebuild' ||
|
|
161
|
+
value === 'realtime' ||
|
|
162
|
+
value === 'db' ||
|
|
163
|
+
value === 'network' ||
|
|
164
|
+
value === 'ui' ||
|
|
165
|
+
value === 'startup'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isPerfBaselineMetric(value: unknown): value is PerfBaselineMetric {
|
|
170
|
+
return (
|
|
171
|
+
value === 'firstContentfulPaint' ||
|
|
172
|
+
value === 'shellInteractive' ||
|
|
173
|
+
value === 'dataReady' ||
|
|
174
|
+
value === 'pageContentReady' ||
|
|
175
|
+
value === 'firstSyncComplete' ||
|
|
176
|
+
value === 'realtimeEventHandlingTime' ||
|
|
177
|
+
value === 'invalidationCost' ||
|
|
178
|
+
value === 'entityPageLoadTime'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function asPerfMetadata(value: unknown): PerfMetadata | undefined {
|
|
183
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return value as PerfMetadata;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toJsonSafeValue(
|
|
191
|
+
value: unknown,
|
|
192
|
+
seen = new WeakSet<object>()
|
|
193
|
+
): unknown {
|
|
194
|
+
if (typeof value === 'bigint') {
|
|
195
|
+
return value.toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
value === null ||
|
|
200
|
+
typeof value === 'string' ||
|
|
201
|
+
typeof value === 'number' ||
|
|
202
|
+
typeof value === 'boolean'
|
|
203
|
+
) {
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (typeof value === 'undefined' || typeof value === 'function') {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (typeof value === 'symbol') {
|
|
212
|
+
return value.toString();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (value instanceof Date) {
|
|
216
|
+
return value.toISOString();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (value instanceof Error) {
|
|
220
|
+
return {
|
|
221
|
+
name: value.name,
|
|
222
|
+
message: value.message,
|
|
223
|
+
stack: value.stack,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
if (seen.has(value)) {
|
|
229
|
+
return '[Circular]';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
seen.add(value);
|
|
233
|
+
const safeArray = value.map(
|
|
234
|
+
(item) => toJsonSafeValue(item, seen) ?? null
|
|
235
|
+
);
|
|
236
|
+
seen.delete(value);
|
|
237
|
+
return safeArray;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof value === 'object') {
|
|
241
|
+
if (seen.has(value)) {
|
|
242
|
+
return '[Circular]';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
seen.add(value);
|
|
246
|
+
const safeRecord: Record<string, unknown> = {};
|
|
247
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
248
|
+
const safeValue = toJsonSafeValue(item, seen);
|
|
249
|
+
if (typeof safeValue !== 'undefined') {
|
|
250
|
+
safeRecord[key] = safeValue;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
seen.delete(value);
|
|
254
|
+
return safeRecord;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return String(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sanitizeMetadata(
|
|
261
|
+
metadata: PerfMetadata | undefined
|
|
262
|
+
): PerfMetadata | undefined {
|
|
263
|
+
return asPerfMetadata(toJsonSafeValue(metadata));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeEntry(value: unknown): PerfEntry | null {
|
|
267
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const record = value as Record<string, unknown>;
|
|
272
|
+
if (
|
|
273
|
+
typeof record.id !== 'string' ||
|
|
274
|
+
typeof record.operation !== 'string' ||
|
|
275
|
+
typeof record.durationMs !== 'number' ||
|
|
276
|
+
typeof record.timestamp !== 'number' ||
|
|
277
|
+
typeof record.isSlow !== 'boolean'
|
|
278
|
+
) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const category = record.category;
|
|
283
|
+
if (!isPerfCategory(category)) return null;
|
|
284
|
+
const metadata = asPerfMetadata(record.metadata);
|
|
285
|
+
const baselineMetric = isPerfBaselineMetric(record.baselineMetric)
|
|
286
|
+
? record.baselineMetric
|
|
287
|
+
: metadata?.baselineMetric;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
id: record.id,
|
|
291
|
+
category,
|
|
292
|
+
operation: record.operation,
|
|
293
|
+
durationMs: record.durationMs,
|
|
294
|
+
timestamp: record.timestamp,
|
|
295
|
+
metadata,
|
|
296
|
+
baselineMetric,
|
|
297
|
+
isSlow: record.isSlow,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function loadPersistedEntries(): PerfEntry[] {
|
|
302
|
+
if (typeof window === 'undefined') {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const raw = localStorage.getItem(PERF_STORAGE_KEY);
|
|
308
|
+
if (!raw) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const parsed = JSON.parse(raw);
|
|
313
|
+
if (!Array.isArray(parsed)) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return parsed
|
|
318
|
+
.map((entry) => normalizeEntry(entry))
|
|
319
|
+
.filter((entry): entry is PerfEntry => entry !== null)
|
|
320
|
+
.slice(-MAX_PERSISTED_ENTRIES);
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function persistEntries(): void {
|
|
327
|
+
if (typeof window === 'undefined') {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
localStorage.setItem(PERF_STORAGE_KEY, JSON.stringify(persistedEntries));
|
|
333
|
+
} catch {
|
|
334
|
+
// Ignore storage errors; logging still works in-memory.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resolveMetadata(metadata?: PerfMetadataInput): PerfMetadata | undefined {
|
|
339
|
+
if (!metadata) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
return typeof metadata === 'function' ? metadata() : metadata;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.warn('[Perf] Failed to resolve metadata:', error);
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function emitPerfEntries(): void {
|
|
352
|
+
const snapshot = getPerfEntries();
|
|
353
|
+
perfSubscribers.forEach((subscriber) => subscriber(snapshot));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function createPerfEntry(
|
|
357
|
+
category: PerfCategory,
|
|
358
|
+
operation: string,
|
|
359
|
+
durationMs: number,
|
|
360
|
+
metadata?: PerfMetadataInput
|
|
361
|
+
): PerfEntry {
|
|
362
|
+
const resolvedMetadata = sanitizeMetadata(resolveMetadata(metadata));
|
|
363
|
+
if (resolvedMetadata?.baselineMetric) resolvedMetadata.build = __BUILD_TIMESTAMP__;
|
|
364
|
+
const normalizedDuration = Number.isFinite(durationMs)
|
|
365
|
+
? Math.max(durationMs, 0)
|
|
366
|
+
: 0;
|
|
367
|
+
const isSlow = normalizedDuration > SLOW_THRESHOLDS[category];
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
id: crypto.randomUUID(),
|
|
371
|
+
category,
|
|
372
|
+
operation,
|
|
373
|
+
durationMs: normalizedDuration,
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
metadata: resolvedMetadata,
|
|
376
|
+
baselineMetric: resolvedMetadata?.baselineMetric,
|
|
377
|
+
isSlow,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// Logging Utilities
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
export function formatDuration(ms: number): string {
|
|
386
|
+
if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
|
|
387
|
+
if (ms < 1000) return `${ms.toFixed(1)}ms`;
|
|
388
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function logPerf(entry: PerfEntry): void {
|
|
392
|
+
const prefix = `[Perf:${entry.category}]`;
|
|
393
|
+
const duration = formatDuration(entry.durationMs);
|
|
394
|
+
const metaStr = entry.metadata
|
|
395
|
+
? ` ${JSON.stringify(entry.metadata)}`
|
|
396
|
+
: '';
|
|
397
|
+
|
|
398
|
+
if (entry.isSlow) {
|
|
399
|
+
console.warn(`${prefix} ⚠ SLOW: ${entry.operation} took ${duration}${metaStr}`);
|
|
400
|
+
} else if (isPerfEnabled()) {
|
|
401
|
+
console.log(`${prefix} ${entry.operation} completed in ${duration}${metaStr}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function appendPerfEntry(entry: PerfEntry): PerfEntry {
|
|
406
|
+
persistedEntries = [...persistedEntries, entry].slice(-MAX_PERSISTED_ENTRIES);
|
|
407
|
+
persistEntries();
|
|
408
|
+
emitPerfEntries();
|
|
409
|
+
logPerf(entry);
|
|
410
|
+
return entry;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ============================================================================
|
|
414
|
+
// Baseline Summaries
|
|
415
|
+
// ============================================================================
|
|
416
|
+
|
|
417
|
+
function calculatePercentile(
|
|
418
|
+
sortedDurations: number[],
|
|
419
|
+
percentile: number
|
|
420
|
+
): number | null {
|
|
421
|
+
if (sortedDurations.length === 0) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const index = Math.max(
|
|
426
|
+
0,
|
|
427
|
+
Math.ceil(sortedDurations.length * percentile) - 1
|
|
428
|
+
);
|
|
429
|
+
return sortedDurations[index] ?? null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function getBaselineMetricLabel(metric: PerfBaselineMetric): string {
|
|
433
|
+
return BASELINE_METRIC_DETAILS[metric].label;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function getBaselineMetricDescription(metric: PerfBaselineMetric): string {
|
|
437
|
+
return BASELINE_METRIC_DETAILS[metric].description;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function summarizeBaselineMetrics(
|
|
441
|
+
entries: PerfEntry[]
|
|
442
|
+
): PerfMetricSummary[] {
|
|
443
|
+
const groups = new Map<string, PerfEntry[]>();
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
if (!entry.baselineMetric) continue;
|
|
446
|
+
const group = `${entry.metadata?.measurementVersion ?? 'legacy'}|${entry.metadata?.kind ?? 'legacy'}|${entry.metadata?.route ?? 'all routes'}`;
|
|
447
|
+
const key = `${entry.baselineMetric}|${group}`;
|
|
448
|
+
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
|
449
|
+
}
|
|
450
|
+
return BASELINE_METRICS.flatMap((metric) => {
|
|
451
|
+
const matching = [...groups.entries()].filter(([key]) => key.startsWith(`${metric}|`));
|
|
452
|
+
if (!matching.length) matching.push([`${metric}|no samples`, []]);
|
|
453
|
+
return matching.map(([key, groupEntries]) => {
|
|
454
|
+
const samples = groupEntries
|
|
455
|
+
.filter((entry) => !entry.metadata?.outcome || entry.metadata.outcome === 'success')
|
|
456
|
+
.sort((left, right) => right.timestamp - left.timestamp);
|
|
457
|
+
const durations = samples
|
|
458
|
+
.map((entry) => entry.durationMs)
|
|
459
|
+
.sort((left, right) => left - right);
|
|
460
|
+
const totalDuration = durations.reduce(
|
|
461
|
+
(sum, duration) => sum + duration,
|
|
462
|
+
0
|
|
463
|
+
);
|
|
464
|
+
const latest = samples[0];
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
metric,
|
|
468
|
+
group: key.slice(metric.length + 1).replaceAll('|', ' · '),
|
|
469
|
+
label: getBaselineMetricLabel(metric),
|
|
470
|
+
description: getBaselineMetricDescription(metric),
|
|
471
|
+
sampleCount: samples.length,
|
|
472
|
+
latestMs: latest?.durationMs ?? null,
|
|
473
|
+
averageMs:
|
|
474
|
+
durations.length > 0 ? totalDuration / durations.length : null,
|
|
475
|
+
p95Ms: calculatePercentile(durations, 0.95),
|
|
476
|
+
slowCount: samples.filter((entry) => entry.isSlow).length,
|
|
477
|
+
latestTimestamp: latest?.timestamp ?? null,
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ============================================================================
|
|
484
|
+
// Public Store API
|
|
485
|
+
// ============================================================================
|
|
486
|
+
|
|
487
|
+
export function recordTiming(
|
|
488
|
+
category: PerfCategory,
|
|
489
|
+
operation: string,
|
|
490
|
+
durationMs: number,
|
|
491
|
+
metadata?: PerfMetadataInput
|
|
492
|
+
): PerfEntry {
|
|
493
|
+
return appendPerfEntry(
|
|
494
|
+
createPerfEntry(category, operation, durationMs, metadata)
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function getPerfEntries(): PerfEntry[] {
|
|
499
|
+
return [...persistedEntries].sort(
|
|
500
|
+
(left, right) => right.timestamp - left.timestamp
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function clearPerfEntries(): void {
|
|
505
|
+
persistedEntries = [];
|
|
506
|
+
persistEntries();
|
|
507
|
+
emitPerfEntries();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function subscribeToPerfEntries(
|
|
511
|
+
subscriber: (entries: PerfEntry[]) => void
|
|
512
|
+
): () => void {
|
|
513
|
+
perfSubscribers.add(subscriber);
|
|
514
|
+
subscriber(getPerfEntries());
|
|
515
|
+
|
|
516
|
+
return () => {
|
|
517
|
+
perfSubscribers.delete(subscriber);
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ============================================================================
|
|
522
|
+
// Core API
|
|
523
|
+
// ============================================================================
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Times an async operation and logs performance metrics.
|
|
527
|
+
*/
|
|
528
|
+
export async function withTiming<T>(
|
|
529
|
+
category: PerfCategory,
|
|
530
|
+
operation: string,
|
|
531
|
+
fn: () => Promise<T>,
|
|
532
|
+
metadata?: PerfMetadataInput
|
|
533
|
+
): Promise<T> {
|
|
534
|
+
const start = performance.now();
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const result = await fn();
|
|
538
|
+
recordTiming(category, operation, performance.now() - start, metadata);
|
|
539
|
+
return result;
|
|
540
|
+
} catch (error) {
|
|
541
|
+
const durationMs = performance.now() - start;
|
|
542
|
+
console.error(
|
|
543
|
+
`[Perf:${category}] ❌ ${operation} failed after ${formatDuration(durationMs)}:`,
|
|
544
|
+
error
|
|
545
|
+
);
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Times a synchronous operation and logs performance metrics.
|
|
552
|
+
* Use this for synchronous operations that don't return a promise.
|
|
553
|
+
*/
|
|
554
|
+
export function withTimingSync<T>(
|
|
555
|
+
category: PerfCategory,
|
|
556
|
+
operation: string,
|
|
557
|
+
fn: () => T,
|
|
558
|
+
metadata?: PerfMetadataInput
|
|
559
|
+
): T {
|
|
560
|
+
const start = performance.now();
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const result = fn();
|
|
564
|
+
recordTiming(category, operation, performance.now() - start, metadata);
|
|
565
|
+
return result;
|
|
566
|
+
} catch (error) {
|
|
567
|
+
const durationMs = performance.now() - start;
|
|
568
|
+
console.error(
|
|
569
|
+
`[Perf:${category}] ❌ ${operation} failed after ${formatDuration(durationMs)}:`,
|
|
570
|
+
error
|
|
571
|
+
);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Creates a manual timer for operations that span multiple calls.
|
|
578
|
+
*/
|
|
579
|
+
export function createTimer(category: PerfCategory, operation: string) {
|
|
580
|
+
const start = performance.now();
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
/**
|
|
584
|
+
* Stops the timer and logs the result.
|
|
585
|
+
* @param metadata - Optional metadata to include in the log
|
|
586
|
+
* @returns The duration and whether it was slow
|
|
587
|
+
*/
|
|
588
|
+
stop(metadata?: PerfMetadataInput): TimerResult {
|
|
589
|
+
const entry = recordTiming(
|
|
590
|
+
category,
|
|
591
|
+
operation,
|
|
592
|
+
performance.now() - start,
|
|
593
|
+
metadata
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
return { durationMs: entry.durationMs, isSlow: entry.isSlow };
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Gets the elapsed time without stopping the timer.
|
|
601
|
+
*/
|
|
602
|
+
elapsed(): number {
|
|
603
|
+
return performance.now() - start;
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Get the slow threshold for a category (useful for custom checks).
|
|
610
|
+
*/
|
|
611
|
+
export function getSlowThreshold(category: PerfCategory): number {
|
|
612
|
+
return SLOW_THRESHOLDS[category];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Stable across client navigation; renewed on each document load. */
|
|
616
|
+
const documentId = crypto.randomUUID();
|
|
617
|
+
let navigationTiming = { id: documentId, start: 0 };
|
|
618
|
+
if (typeof document !== 'undefined') document.documentElement.dataset.performanceDocument = documentId;
|
|
619
|
+
export function beginNavigationTiming(): void {
|
|
620
|
+
navigationTiming = { id: crypto.randomUUID(), start: performance.now() };
|
|
621
|
+
}
|
|
622
|
+
export function getNavigationTiming(): { id: string; start: number } {
|
|
623
|
+
return navigationTiming;
|
|
624
|
+
}
|
|
625
|
+
export function measurementMetadata(): PerfMetadata {
|
|
626
|
+
return { measurementVersion: 2, documentId, navigationId: navigationTiming.id, kind: 'startup', outcome: 'success' };
|
|
627
|
+
}
|
package/README.md
DELETED