LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper - hyper_log_stats.ts Coverage Total Hit
Test: DashQL 915828b Lines: 98.9 % 175 173
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1