Line data Source code
1 : import type { HyperQueryEnd, HyperResourceSample } from './hyper_log_metrics.js';
2 :
3 : /// A time window of the connection metrics, ending now.
4 : export interface MetricsWindow {
5 : /// The key of the window
6 : key: string;
7 : /// The duration of the window in milliseconds
8 : durationMs: number;
9 : /// The duration of a bucket of the charts in milliseconds, 60 buckets per window.
10 : /// A chart draws a bucket and not every query or sample, its marks do not grow with the queries.
11 : bucketMs: number;
12 : /// The time before the end of a bucket whose queries its p95 latency covers, 12 buckets.
13 : /// A bucket alone holds too few queries for a p95, its p95 would mostly be its slowest query.
14 : p95WindowMs: number;
15 : }
16 :
17 : /// The time windows that the metrics can show
18 1 : export const METRICS_WINDOWS: readonly MetricsWindow[] = [
19 1 : { key: '5m', durationMs: 5 * 60_000, bucketMs: 5_000, p95WindowMs: 60_000 },
20 1 : { key: '15m', durationMs: 15 * 60_000, bucketMs: 15_000, p95WindowMs: 3 * 60_000 },
21 1 : { key: '1h', durationMs: 60 * 60_000, bucketMs: 60_000, p95WindowMs: 12 * 60_000 },
22 1 : ];
23 :
24 : /// A time range in milliseconds since the epoch.
25 : export interface TimeRange {
26 : /// The start of the range
27 : start: number;
28 : /// The end of the range, exclusive
29 : end: number;
30 : }
31 :
32 : /// The queries that the metrics show
33 1 : export enum QueryScope {
34 : /// The queries of the user, e.g. of scripts
35 1 : User = 'user',
36 : /// The queries that the app runs, e.g. the SQLFrame queries of computations
37 1 : App = 'app',
38 : /// All queries
39 1 : All = 'all',
40 1 : }
41 :
42 : /// The query scopes in the order of the picker
43 1 : export const QUERY_SCOPES: readonly QueryScope[] = [QueryScope.User, QueryScope.App, QueryScope.All];
44 :
45 : /// The queries of a scope, keeps the array of all queries
46 1 : export function queriesInScope(queries: readonly HyperQueryEnd[], scope: QueryScope): readonly HyperQueryEnd[] {
47 1 : switch (scope) {
48 1 : case QueryScope.User: return queries.filter(query => !query.sentByApp);
49 1 : case QueryScope.App: return queries.filter(query => query.sentByApp);
50 1 : case QueryScope.All: return queries;
51 1 : }
52 1 : }
53 :
54 : /// The metrics of the queries that ended in a window.
55 : export interface QueryMetrics extends TimeRange {
56 : /// The queries that ended in the window, ordered by their end
57 : queries: HyperQueryEnd[];
58 : /// The queries that succeeded
59 : succeeded: number;
60 : /// The queries that failed
61 : failed: number;
62 : /// The thread time of the queries in seconds
63 : threadTime: number;
64 : /// The rows that the queries processed
65 : processedRows: number;
66 : /// The mean CPU time of a query in seconds, null without queries
67 : meanCpuTime: number | null;
68 : /// The mean time that a query waited in seconds, null without queries
69 : meanWaitTime: number | null;
70 : /// The highest peak memory of the queries in MB, null without a recorded peak
71 : peakMemoryMb: number | null;
72 : /// The 95th percentile of the elapsed times in seconds, null without a recorded one
73 : elapsedP95: number | null;
74 : }
75 :
76 : /// The resource samples of the Hyper process in a window.
77 : export interface ResourceMetrics extends TimeRange {
78 : /// The samples in the window, ordered by time
79 : samples: HyperResourceSample[];
80 : /// The newest sample in the window
81 : latest: HyperResourceSample | null;
82 : }
83 :
84 : /// The nearest-rank percentile of values, null for no values
85 1 : export function percentile(values: number[], p: number): number | null {
86 1 : if (values.length === 0) return null;
87 1 : const sorted = [...values].sort((l, r) => l - r);
88 1 : return sorted[Math.max(Math.ceil(p * sorted.length) - 1, 0)];
89 1 : }
90 :
91 : /// The recorded elapsed times of queries
92 1 : const elapsedTimes = (queries: readonly HyperQueryEnd[]) => queries.flatMap(query => query.elapsed == null ? [] : [query.elapsed]);
93 :
94 1 : function maxOrNull(values: (number | null)[]): number | null {
95 1 : let max: number | null = null;
96 1 : for (const value of values) {
97 1 : if (value != null && (max == null || value > max)) max = value;
98 : }
99 1 : return max;
100 1 : }
101 :
102 : /// The window that ends now
103 1 : export function windowRange(window: MetricsWindow, now: number): TimeRange {
104 1 : return { start: now - window.durationMs, end: now };
105 1 : }
106 :
107 : /// The items in a time range, the items are ordered by time
108 1 : function inRange<T>(items: readonly T[], time: (item: T) => number, range: TimeRange): T[] {
109 1 : const first = items.findIndex(item => time(item) >= range.start);
110 1 : if (first < 0) return [];
111 1 : const result: T[] = [];
112 1 : for (let i = first; i < items.length && time(items[i]) < range.end; ++i) result.push(items[i]);
113 1 : return result;
114 1 : }
115 :
116 : /// The metrics of the queries that ended in the window before now
117 1 : export function computeQueryMetrics(queries: readonly HyperQueryEnd[], window: MetricsWindow, now: number): QueryMetrics {
118 1 : const range = windowRange(window, now);
119 1 : const inWindow = inRange(queries, query => query.endedAt, range);
120 1 : let succeeded = 0;
121 1 : let threadTime = 0;
122 1 : let processedRows = 0;
123 1 : let cpuTime = 0;
124 1 : let waitTime = 0;
125 1 : for (const query of inWindow) {
126 1 : if (query.succeeded) ++succeeded;
127 1 : threadTime += query.threadTime;
128 1 : processedRows += query.processedRows;
129 1 : cpuTime += query.cpuTime;
130 1 : waitTime += query.waitTime;
131 1 : }
132 1 : return {
133 1 : ...range,
134 1 : queries: inWindow,
135 1 : succeeded,
136 1 : failed: inWindow.length - succeeded,
137 1 : threadTime,
138 1 : processedRows,
139 1 : meanCpuTime: inWindow.length === 0 ? null : cpuTime / inWindow.length,
140 1 : meanWaitTime: inWindow.length === 0 ? null : waitTime / inWindow.length,
141 1 : peakMemoryMb: maxOrNull(inWindow.map(query => query.peakMemoryMb)),
142 1 : elapsedP95: percentile(elapsedTimes(inWindow), 0.95),
143 1 : };
144 1 : }
145 :
146 : /// The resource samples of the window before now
147 1 : export function computeResourceMetrics(samples: readonly HyperResourceSample[], window: MetricsWindow, now: number): ResourceMetrics {
148 1 : const range = windowRange(window, now);
149 1 : const inWindow = inRange(samples, sample => sample.sampledAt, range);
150 1 : return { ...range, samples: inWindow, latest: inWindow.length > 0 ? inWindow[inWindow.length - 1] : null };
151 1 : }
152 :
153 : /// The queries that ended in a bucket of time
154 : export interface QueryBucket {
155 : /// The start of the bucket in milliseconds since the epoch
156 : start: number;
157 : /// The end of the bucket, exclusive
158 : end: number;
159 : /// The queries that ended in the bucket
160 : count: number;
161 : /// The queries that failed
162 : failed: number;
163 : /// The 95th percentile of the elapsed times of the queries that ended in the p95 window before the end of the
164 : /// bucket, see MetricsWindow, null without a recorded one
165 : elapsedP95: number | null;
166 : /// The rows that the queries processed
167 : processedRows: number;
168 : /// The mean CPU time of a query in seconds
169 : meanCpuTime: number;
170 : /// The mean time that a query waited in seconds
171 : meanWaitTime: number;
172 : /// The highest peak memory in MB, null without a recorded one
173 : peakMemoryMb: number | null;
174 : }
175 :
176 : /// Iterate the buckets of items that are ordered by time, aligned to multiples of their duration
177 1 : function forEachBucket<T>(items: readonly T[], time: (item: T) => number, bucketMs: number, visit: (start: number, items: T[]) => void): void {
178 1 : let start: number | null = null;
179 1 : let bucket: T[] = [];
180 1 : for (const item of items) {
181 1 : const itemStart = Math.floor(time(item) / bucketMs) * bucketMs;
182 1 : if (itemStart !== start) {
183 1 : if (start != null) visit(start, bucket);
184 1 : start = itemStart;
185 1 : bucket = [];
186 1 : }
187 1 : bucket.push(item);
188 1 : }
189 1 : if (start != null) visit(start, bucket);
190 1 : }
191 :
192 : /// Aggregate the queries of a window per bucket, the buckets are aligned to multiples of their duration and empty ones
193 : /// are left out. The p95 of a bucket covers the queries of the history in the p95 window before its end, also those
194 : /// before the window. The queries and the history, e.g. all queries of a scope, are ordered by their end.
195 : /// The cache keeps the p95 of the p95 windows of the last call: the history only grows at its end, a later call only
196 : /// computes the p95 of the windows whose queries changed, e.g. of the newest bucket.
197 1 : export function bucketQueries(
198 1 : queries: readonly HyperQueryEnd[],
199 1 : window: MetricsWindow,
200 1 : history: readonly HyperQueryEnd[],
201 1 : cache: Map<string, number | null> = new Map(),
202 1 : ): QueryBucket[] {
203 1 : const used = new Map<string, number | null>();
204 : /// The p95 of history[from, to), identified by its bounds, its first and last query and its size
205 1 : const p95 = (end: number, from: number, to: number) => {
206 1 : const key = from === to ? `${end}` : `${end}:${history[from].endedAt}:${history[to - 1].endedAt}:${to - from}`;
207 1 : const value = cache.has(key) ? cache.get(key)! : percentile(elapsedTimes(history.slice(from, to)), 0.95);
208 1 : used.set(key, value);
209 1 : return value;
210 1 : };
211 1 : const { bucketMs, p95WindowMs } = window;
212 1 : const buckets: QueryBucket[] = [];
213 : // The queries in the p95 window of the current bucket are history[from, to)
214 1 : let from = 0;
215 1 : let to = 0;
216 1 : forEachBucket(queries, query => query.endedAt, bucketMs, (start, inBucket) => {
217 1 : const end = start + bucketMs;
218 1 : while (from < history.length && history[from].endedAt < end - p95WindowMs) ++from;
219 1 : to = Math.max(to, from);
220 1 : while (to < history.length && history[to].endedAt < end) ++to;
221 1 : const bucket: QueryBucket = {
222 1 : start,
223 1 : end,
224 1 : count: inBucket.length,
225 1 : failed: 0,
226 1 : elapsedP95: p95(end, from, to),
227 1 : processedRows: 0,
228 1 : meanCpuTime: 0,
229 1 : meanWaitTime: 0,
230 1 : peakMemoryMb: maxOrNull(inBucket.map(query => query.peakMemoryMb)),
231 1 : };
232 1 : for (const query of inBucket) {
233 1 : bucket.failed += query.succeeded ? 0 : 1;
234 1 : bucket.processedRows += query.processedRows;
235 1 : bucket.meanCpuTime += query.cpuTime / inBucket.length;
236 1 : bucket.meanWaitTime += query.waitTime / inBucket.length;
237 1 : }
238 1 : buckets.push(bucket);
239 1 : });
240 1 : cache.clear();
241 1 : for (const [key, value] of used) cache.set(key, value);
242 1 : return buckets;
243 1 : }
244 :
245 : /// A value of the resource samples in a bucket of time
246 : export interface SampleBucket {
247 : /// The start of the bucket in milliseconds since the epoch
248 : start: number;
249 : /// The end of the bucket, exclusive
250 : end: number;
251 : /// The value of the bucket
252 : value: number;
253 : }
254 :
255 : /// How a bucket combines the values of its samples
256 : export type SampleAggregate = 'max' | 'mean';
257 :
258 : /// Aggregate a value of the resource samples per bucket, the buckets without a recorded value are left out.
259 : /// The samples are ordered by time.
260 1 : export function bucketSamples(
261 1 : samples: readonly HyperResourceSample[],
262 1 : bucketMs: number,
263 1 : value: (sample: HyperResourceSample) => number | null,
264 1 : aggregate: SampleAggregate,
265 1 : ): SampleBucket[] {
266 1 : const buckets: SampleBucket[] = [];
267 1 : forEachBucket(samples, sample => sample.sampledAt, bucketMs, (start, inBucket) => {
268 1 : const values = inBucket.flatMap(sample => {
269 1 : const v = value(sample);
270 1 : return v == null ? [] : [v];
271 1 : });
272 1 : if (values.length === 0) return;
273 1 : const combined = aggregate === 'max'
274 1 : ? Math.max(...values)
275 1 : : values.reduce((sum, v) => sum + v, 0) / values.length;
276 1 : buckets.push({ start, end: start + bucketMs, value: combined });
277 1 : });
278 1 : return buckets;
279 1 : }
280 :
281 : /// Format a duration in seconds, e.g. 850 µs, 12.3 ms, 1.24 s, 2m 03s
282 1 : export function formatDuration(seconds: number): string {
283 1 : if (seconds === 0) return '0 s';
284 1 : if (seconds < 1e-3) return `${Math.round(seconds * 1e6)} µs`;
285 1 : if (seconds < 1) return `${Number((seconds * 1e3).toPrecision(3))} ms`;
286 1 : if (seconds < 60) return `${Number(seconds.toPrecision(3))} s`;
287 1 : const minutes = Math.floor(seconds / 60);
288 1 : return `${minutes}m ${Math.round(seconds - minutes * 60).toString().padStart(2, '0')}s`;
289 1 : }
290 :
291 : /// Format a memory size in MB, e.g. 0.25 MB, 512 MB, 1.5 GB
292 1 : export function formatMemory(mb: number): string {
293 1 : if (mb >= 1024) return `${Number((mb / 1024).toPrecision(3))} GB`;
294 1 : return `${Number(mb.toPrecision(3))} MB`;
295 1 : }
296 :
297 : /// Format a count compactly, e.g. 12, 1.3K, 2.1M
298 1 : export function formatCount(count: number): string {
299 1 : if (count < 1000) return Math.round(count).toString();
300 0 : if (count < 1e6) return `${Number((count / 1e3).toPrecision(2))}K`;
301 0 : return `${Number((count / 1e6).toPrecision(2))}M`;
302 1 : }
303 :
304 : /// Format a load between 0 and 1 as a percentage, e.g. 42%
305 1 : export function formatLoad(load: number): string {
306 1 : return `${Math.round(load * 100)}%`;
307 1 : }
308 :
309 : /// Format the time of day, e.g. 14:03:20
310 1 : export function formatClock(epochMs: number): string {
311 1 : return new Date(epochMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
312 1 : }
|