@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
297 lines • 11.3 kB
JavaScript
import { EntryType } from '../entry/entry.js';
import { bucketTimeseries } from './timeseries.js';
const DEFAULT_TOP_FAMILIES = 8;
const DEFAULT_TOP_KEYS = 8;
const DEFAULT_TOP_EXCEPTIONS = 8;
const MAX_FAMILY_LABEL_LENGTH = 60;
/** Nearest-rank percentile over a NON-EMPTY ascending array; 0 for empty.
* `q` in [0,1]; `idx = clamp(ceil(q*n)-1, 0, n-1)`. */
export function percentile(sortedAscending, q) {
const n = sortedAscending.length;
if (n === 0)
return 0;
const rawIndex = Math.ceil(q * n) - 1;
const index = Math.min(n - 1, Math.max(0, rawIndex));
const value = sortedAscending[index];
return value ?? 0;
}
function isRecord(value) {
return typeof value === 'object' && value !== null;
}
// Only `sql` is required: real-world query content varies by ORM logger (e.g.
// the MikroORM logger emits `{ sql, bindings, took }` with no `slow`/`connection`),
// and the family label only needs the SQL text. Missing optional fields degrade
// gracefully rather than rejecting the whole content.
function asQueryContent(content) {
if (!isRecord(content))
return null;
if (typeof content.sql !== 'string')
return null;
return {
sql: content.sql,
bindings: Array.isArray(content.bindings) ? content.bindings : [],
connection: typeof content.connection === 'string' ? content.connection : null,
slow: typeof content.slow === 'boolean' ? content.slow : false,
};
}
const CACHE_OPERATIONS = new Set(['get', 'set', 'delete', 'clear']);
function asCacheContent(content) {
if (!isRecord(content))
return null;
if (!CACHE_OPERATIONS.has(content.operation))
return null;
if (typeof content.key !== 'string')
return null;
const hit = content.hit;
if (hit !== true && hit !== false && hit !== null)
return null;
const result = {
operation: content.operation,
key: content.key,
hit,
};
if (typeof content.tier === 'string')
result.tier = content.tier;
if (typeof content.stale === 'boolean')
result.stale = content.stale;
return result;
}
function asExceptionContent(content) {
if (!isRecord(content))
return null;
const className = typeof content.class === 'string' ? content.class : 'Error';
const message = typeof content.message === 'string' ? content.message : '';
return { class: className, message };
}
function asRequestContent(content) {
if (!isRecord(content))
return null;
const statusCode = content.statusCode;
if (typeof statusCode === 'number')
return { statusCode };
if (statusCode === null)
return { statusCode: null };
return null;
}
function computeLatency(entries, slowMs) {
const durations = [];
let slow = 0;
for (const entry of entries) {
if (typeof entry.durationMs === 'number') {
durations.push(entry.durationMs);
if (entry.durationMs >= slowMs)
slow += 1;
}
}
if (durations.length === 0)
return undefined;
durations.sort((a, b) => a - b);
return {
count: durations.length,
p50: percentile(durations, 0.5),
p95: percentile(durations, 0.95),
p99: percentile(durations, 0.99),
max: durations[durations.length - 1] ?? 0,
slow,
};
}
function computeFamilies(entries, topFamilies) {
const groups = new Map();
for (const entry of entries) {
if (entry.familyHash === null)
continue;
const query = asQueryContent(entry.content);
const existing = groups.get(entry.familyHash);
if (existing) {
if (typeof entry.durationMs === 'number')
existing.durations.push(entry.durationMs);
}
else {
groups.set(entry.familyHash, {
durations: typeof entry.durationMs === 'number' ? [entry.durationMs] : [],
label: query !== null ? query.sql.slice(0, MAX_FAMILY_LABEL_LENGTH) : '',
});
}
}
const families = [];
for (const [familyHash, group] of groups) {
const durations = [...group.durations].sort((a, b) => a - b);
families.push({
familyHash,
label: group.label,
count: durations.length,
p50: percentile(durations, 0.5),
p99: percentile(durations, 0.99),
});
}
families.sort((a, b) => b.p99 - a.p99 || b.count - a.count || a.familyHash.localeCompare(b.familyHash));
return families.slice(0, topFamilies);
}
/** Get-or-create the mutable hit/miss counter for a tier. */
function tierCounts(byTier, tier) {
const existing = byTier[tier];
if (existing !== undefined)
return existing;
const created = { hits: 0, misses: 0 };
byTier[tier] = created;
return created;
}
function computeCache(entries, topKeys) {
let hits = 0;
let misses = 0;
let sets = 0;
let deletes = 0;
let staleHits = 0;
const keyCounts = new Map();
const byTier = {};
for (const entry of entries) {
const cache = asCacheContent(entry.content);
if (cache === null)
continue;
keyCounts.set(cache.key, (keyCounts.get(cache.key) ?? 0) + 1);
if (cache.operation === 'set') {
sets += 1;
}
else if (cache.operation === 'delete' || cache.operation === 'clear') {
deletes += 1;
}
else if (cache.hit === true) {
hits += 1;
if (cache.stale === true)
staleHits += 1;
if (cache.tier !== undefined)
tierCounts(byTier, cache.tier).hits += 1;
}
else if (cache.hit === false) {
misses += 1;
if (cache.tier !== undefined)
tierCounts(byTier, cache.tier).misses += 1;
}
}
const denominator = hits + misses;
const ranked = [...keyCounts.entries()]
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, topKeys);
return {
hits,
misses,
sets,
deletes,
hitRatio: denominator === 0 ? 0 : hits / denominator,
topKeys: ranked,
staleHits,
byTier,
};
}
function computeStatus(entries) {
const breakdown = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, other: 0 };
for (const entry of entries) {
const request = asRequestContent(entry.content);
const code = request?.statusCode ?? null;
if (code !== null && code >= 200 && code < 300)
breakdown['2xx'] += 1;
else if (code !== null && code >= 300 && code < 400)
breakdown['3xx'] += 1;
else if (code !== null && code >= 400 && code < 500)
breakdown['4xx'] += 1;
else if (code !== null && code >= 500 && code < 600)
breakdown['5xx'] += 1;
else
breakdown.other += 1;
}
return breakdown;
}
/** The family key for an exception entry: its `familyHash` when present (the
* interceptor sets `${class}:${message}`), else a `${class}: ${message}`
* fallback derived from content. */
function exceptionKey(entry, fields) {
return entry.familyHash ?? `${fields.class}: ${fields.message}`;
}
/** The bucket index a timestamp falls into for the report's `overTime` buckets;
* mirrors {@link bucketTimeseries}'s clamping so the per-group series aligns. */
function bucketIndexFor(createdAt, windowStart, windowEnd, bucketCount) {
const count = Math.max(1, Math.floor(bucketCount));
const startMs = windowStart.getTime();
const spanMs = Math.max(1, windowEnd.getTime() - startMs);
const bucketMs = Math.max(1, Math.floor(spanMs / count));
const rawIndex = Math.floor((createdAt.getTime() - startMs) / bucketMs);
return Math.min(count - 1, Math.max(0, rawIndex));
}
function computeExceptions(entries, windowStart, windowEnd, buckets, topExceptions) {
const bucketCount = Math.max(1, Math.floor(buckets));
const groups = new Map();
for (const entry of entries) {
const fields = asExceptionContent(entry.content);
if (fields === null)
continue;
const key = exceptionKey(entry, fields);
const index = bucketIndexFor(entry.createdAt, windowStart, windowEnd, bucketCount);
const existing = groups.get(key);
if (existing) {
existing.count += 1;
if (entry.createdAt > existing.lastAt)
existing.lastAt = entry.createdAt;
const current = existing.overTime[index] ?? 0;
existing.overTime[index] = current + 1;
}
else {
const overTime = new Array(bucketCount).fill(0);
overTime[index] = 1;
groups.set(key, {
class: fields.class,
message: fields.message,
count: 1,
lastAt: entry.createdAt,
overTime,
});
}
}
return [...groups.entries()]
.map(([key, group]) => ({ key, ...group }))
.sort((a, b) => b.count - a.count || b.lastAt.getTime() - a.lastAt.getTime() || a.key.localeCompare(b.key))
.slice(0, topExceptions);
}
/** Aggregate a window of entries into per-type analytics: latency percentiles,
* query-family breakdown, cache hit/miss, request status breakdown, and a
* throughput time-series. Pure: callers fetch the windowed entries and supply
* the window bounds + `truncated` flag. */
export function summarizeStats(input) {
const { entries, type, windowStart, windowEnd, windowMs, buckets, slowMs, truncated, topFamilies = DEFAULT_TOP_FAMILIES, topKeys = DEFAULT_TOP_KEYS, topExceptions = DEFAULT_TOP_EXCEPTIONS, latencyPercentiles, } = input;
const overTime = bucketTimeseries(entries, windowStart, windowEnd, buckets);
const latency = computeLatency(entries, slowMs);
const result = {
type,
windowMs,
total: entries.length,
overTime,
truncated,
};
// When the caller supplied rollup-histogram percentile estimates, substitute
// p50/p95/p99 (count/max/slow stay raw-derived). This only fires when the raw
// scan also found latency samples, keeping the "no durations ⇒ no latency"
// shape identical to the pure raw path.
if (latency !== undefined && latencyPercentiles !== undefined) {
latency.p50 = latencyPercentiles.p50;
latency.p95 = latencyPercentiles.p95;
latency.p99 = latencyPercentiles.p99;
}
if (latency !== undefined)
result.latency = latency;
if (type === EntryType.Query) {
const families = computeFamilies(entries, topFamilies);
if (families.length > 0)
result.families = families;
}
if (type === EntryType.Cache)
result.cache = computeCache(entries, topKeys);
if (type === EntryType.Request)
result.status = computeStatus(entries);
if (type === EntryType.Exception) {
const exceptions = computeExceptions(entries, windowStart, windowEnd, buckets, topExceptions);
if (exceptions.length > 0)
result.exceptions = exceptions;
}
return result;
}
//# sourceMappingURL=stats.js.map