UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

297 lines 11.3 kB
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