UNPKG

@dudousxd/nestjs-telescope

Version:

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

338 lines 14.6 kB
// packages/core/src/pulse/pulse-summary.ts import { EntryType } from '../entry/entry.js'; import { percentile } from '../metrics/stats.js'; import { detectNPlusOne } from '../query/n-plus-one.js'; const MAX_LABEL_LENGTH = 500; /** Bound a label/sql string so the health snapshot payload stays small. */ function truncate(value) { return value.length > MAX_LABEL_LENGTH ? `${value.slice(0, MAX_LABEL_LENGTH)}…` : value; } /** * Aggregate per-family durations into ranked {@link SlowRouteHotspot}s. The map * key IS the `route` (familyHash, also the label); stats are p99/p50 over the * family's durations. Shared by request slow-routes and outgoing-HTTP hotspots. */ function toHotspots(durationsByFamily, options) { return [...durationsByFamily.entries()] .map(([route, durations]) => { const sorted = [...durations].sort((a, b) => a - b); return { route, count: sorted.length, p99: percentile(sorted, 0.99), p50: percentile(sorted, 0.5), }; }) .filter((hotspot) => hotspot.count >= options.slowRouteMinCount && hotspot.p99 >= options.slowRouteMs) .sort((a, b) => b.p99 - a.p99 || b.count - a.count || a.route.localeCompare(b.route)) .slice(0, options.topN); } function asRecord(content) { return typeof content === 'object' && content !== null ? content : null; } /** A human label for a slow entry, derived from hydrated content by type. */ function labelFrom(type, content) { const record = asRecord(content); if (record === null) return type; if (typeof record.uri === 'string') { return typeof record.method === 'string' ? `${record.method} ${record.uri}` : record.uri; } if (typeof record.sql === 'string') return record.sql; if (typeof record.queue === 'string' && typeof record.name === 'string') { return `${record.queue}:${record.name}`; } return type; } /** * Pass 1: aggregate the windowed entries over their content-less columns only. * Reads `type`, `durationMs`, `familyHash`, `batchId`, `createdAt`, `sequence` * — never `content`. Produces the ranked/sliced aggregates plus the exact ids * whose content the final output needs. */ export function aggregatePulse(entries, windowStart, windowEnd, options) { const counts = {}; const slowCandidates = []; const exceptionGroups = new Map(); const batches = new Map(); // Request durations grouped by route family — the slow-route hotspot source. const routeDurations = new Map(); // Outgoing http_client durations grouped by target family — slow-outgoing source. const outgoingDurations = new Map(); // Job durations grouped by job family — slow-jobs source. const jobDurations = new Map(); // Per-user request load (count + total duration) from the user:<id> tag. const userLoad = new Map(); for (const entry of entries) { counts[entry.type] = (counts[entry.type] ?? 0) + 1; if (entry.type === EntryType.Job && entry.familyHash !== null && typeof entry.durationMs === 'number') { const existing = jobDurations.get(entry.familyHash); if (existing) existing.push(entry.durationMs); else jobDurations.set(entry.familyHash, [entry.durationMs]); } if (entry.type === EntryType.Request) { const user = userFromTags(entry.tags); if (user !== null) { const existing = userLoad.get(user); const duration = typeof entry.durationMs === 'number' ? entry.durationMs : 0; if (existing) { existing.count += 1; existing.totalDurationMs += duration; } else { userLoad.set(user, { count: 1, totalDurationMs: duration }); } } } if (entry.type === EntryType.Request && entry.familyHash !== null && typeof entry.durationMs === 'number') { const existing = routeDurations.get(entry.familyHash); if (existing) existing.push(entry.durationMs); else routeDurations.set(entry.familyHash, [entry.durationMs]); } if (entry.type === EntryType.HttpClient && entry.familyHash !== null && typeof entry.durationMs === 'number') { const existing = outgoingDurations.get(entry.familyHash); if (existing) existing.push(entry.durationMs); else outgoingDurations.set(entry.familyHash, [entry.durationMs]); } if (typeof entry.durationMs === 'number') { slowCandidates.push({ id: entry.id, type: entry.type, durationMs: entry.durationMs, batchId: entry.batchId, }); } if (entry.type === EntryType.Exception && entry.familyHash !== null) { const existing = exceptionGroups.get(entry.familyHash); if (existing) { existing.count += 1; if (entry.createdAt > existing.lastSeen) { existing.lastSeen = entry.createdAt; existing.representativeId = entry.id; } } else { exceptionGroups.set(entry.familyHash, { representativeId: entry.id, count: 1, lastSeen: entry.createdAt, }); } } const batch = batches.get(entry.batchId); if (batch) batch.push(entry); else batches.set(entry.batchId, [entry]); } const slowest = slowCandidates .sort((a, b) => b.durationMs - a.durationMs || a.id.localeCompare(b.id)) .slice(0, options.topN); const exceptions = [...exceptionGroups.entries()] .map(([familyHash, group]) => ({ familyHash, group })) .sort((a, b) => b.group.count - a.group.count || a.familyHash.localeCompare(b.familyHash)) .slice(0, options.topN) .map(({ familyHash, group }) => ({ familyHash, ...group })); // N+1 detection only needs familyHash counts per batch; sql is hydrated later. // We track one representative query-entry id per family for the sql label. const hotspots = new Map(); const familyRepresentative = new Map(); for (const [batchId, batchEntries] of batches) { // Sum query durations per family WITHIN this batch (content-less column) so a // tripped N+1 family can be weighted by the time it actually cost. const batchFamilyDuration = new Map(); for (const entry of batchEntries) { if (entry.type === EntryType.Query && entry.familyHash !== null) { if (!familyRepresentative.has(entry.familyHash)) { familyRepresentative.set(entry.familyHash, entry.id); } if (typeof entry.durationMs === 'number') { batchFamilyDuration.set(entry.familyHash, (batchFamilyDuration.get(entry.familyHash) ?? 0) + entry.durationMs); } } } for (const insight of detectNPlusOne(batchEntries, options.nPlusOneThreshold)) { const loopDuration = batchFamilyDuration.get(insight.familyHash) ?? 0; const existing = hotspots.get(insight.familyHash); if (existing) { existing.requests += 1; existing.total += insight.count; existing.totalDurationMs += loopDuration; if (insight.count > existing.perRequest) { existing.perRequest = insight.count; existing.sampleBatchId = batchId; } } else { hotspots.set(insight.familyHash, { familyHash: insight.familyHash, perRequest: insight.count, requests: 1, total: insight.count, totalDurationMs: loopDuration, sampleBatchId: batchId, representativeId: familyRepresentative.get(insight.familyHash) ?? '', }); } } } // Rank by total time WASTED in the loop first (cost-weighted, the actionable // signal), then by repetition totals as the tie-break. const nPlusOne = [...hotspots.values()] .sort((a, b) => b.totalDurationMs - a.totalDurationMs || b.total - a.total || b.requests - a.requests || a.familyHash.localeCompare(b.familyHash)) .slice(0, options.topN); // Slow-route hotspots: aggregate request durations per route family entirely // from content-less columns. The route IS the familyHash (also the label). const slowRoutes = toHotspots(routeDurations, options); // Slow outgoing-HTTP hotspots: same aggregation over http_client durations. const slowOutgoing = toHotspots(outgoingDurations, options); // Slow-job hotspots: rank job families by p99. Unlike routes/outgoing, jobs // are NOT gated by the slow-request p99 threshold — a queue's slowest jobs are // always worth surfacing — so they rank by p99 over the min-count gate only. const slowJobs = toJobHotspots(jobDurations, options); // Load-by-user: top users by total request time spent serving them. const loadByUser = [...userLoad.entries()] .map(([user, load]) => ({ user, count: load.count, totalDurationMs: load.totalDurationMs })) .sort((a, b) => b.totalDurationMs - a.totalDurationMs || b.count - a.count || a.user.localeCompare(b.user)) .slice(0, options.topN); return { windowStart, windowEnd, options, counts, slowest, exceptions, nPlusOne, slowRoutes, slowOutgoing, slowJobs, loadByUser, hydrationIds: { slowest: slowest.map((candidate) => candidate.id), exceptions: exceptions.map((group) => group.representativeId), nPlusOne: nPlusOne.map((hotspot) => hotspot.representativeId), }, }; } /** * Rank job families by p99 over a min-count gate (no slow-ms threshold — the * slowest jobs are always worth showing). Shares the {@link SlowRouteHotspot} * shape so the dashboard renders it with the same hotspot card. Pure. */ function toJobHotspots(durationsByFamily, options) { return [...durationsByFamily.entries()] .map(([route, durations]) => { const sorted = [...durations].sort((a, b) => a - b); return { route, count: sorted.length, p99: percentile(sorted, 0.99), p50: percentile(sorted, 0.5), }; }) .filter((hotspot) => hotspot.count >= options.slowRouteMinCount) .sort((a, b) => b.p99 - a.p99 || b.count - a.count || a.route.localeCompare(b.route)) .slice(0, options.topN); } /** Extract the user id from a `user:<id>` tag, or `null` when none is present. */ function userFromTags(tags) { for (const tag of tags) { if (tag.startsWith('user:')) return tag.slice('user:'.length); } return null; } /** detectNPlusOne re-derives sql from a hydrated representative's content. */ function sqlFromContent(content) { const record = asRecord(content); return record !== null && typeof record.sql === 'string' ? record.sql : ''; } function exceptionFieldsFromContent(content) { const record = asRecord(content); return { class: typeof record?.class === 'string' ? record.class : 'Error', message: typeof record?.message === 'string' ? record.message : '', }; } /** * Pass 2: build the final {@link PulseSummary}, reading content for ONLY the few * displayed rows via the `hydrate` lookup. `hydrate(id)` returns the entry's * content (or undefined if it could not be re-read). */ export function finalizePulse(aggregates, hydrate) { const slowest = aggregates.slowest.map((candidate) => ({ id: candidate.id, type: candidate.type, durationMs: candidate.durationMs, label: truncate(labelFrom(candidate.type, hydrate(candidate.id))), batchId: candidate.batchId, })); const topExceptions = aggregates.exceptions.map((group) => { const { class: className, message } = exceptionFieldsFromContent(hydrate(group.representativeId)); return { familyHash: group.familyHash, class: className, message, count: group.count, lastSeen: group.lastSeen.toISOString(), }; }); const nPlusOne = aggregates.nPlusOne.map((hotspot) => ({ familyHash: hotspot.familyHash, sql: truncate(sqlFromContent(hydrate(hotspot.representativeId))), perRequest: hotspot.perRequest, requests: hotspot.requests, total: hotspot.total, totalDurationMs: hotspot.totalDurationMs, sampleBatchId: hotspot.sampleBatchId, })); return { windowStart: aggregates.windowStart.toISOString(), windowEnd: aggregates.windowEnd.toISOString(), windowMs: Math.max(0, aggregates.windowEnd.getTime() - aggregates.windowStart.getTime()), counts: aggregates.counts, slowest, topExceptions, nPlusOne, // Slow routes are already final (familyHash is the label) — pass through. slowRoutes: aggregates.slowRoutes, slowOutgoing: aggregates.slowOutgoing, slowJobs: aggregates.slowJobs, loadByUser: aggregates.loadByUser, }; } /** * Summarize stored entries into a health snapshot: per-type counts, slowest * entries, top exceptions, and N+1 hotspots aggregated by query family. Pure: * callers fetch the windowed entries (createdAt is not re-checked here). * * When the entries carry their `content` (the in-process / single-pass path), * labels/class/message/sql resolve directly from each entry. The two-pass * content-less path uses {@link aggregatePulse} + {@link finalizePulse} instead. */ export function summarizePulse(entries, windowStart, windowEnd, options) { const aggregates = aggregatePulse(entries, windowStart, windowEnd, options); const byId = new Map(); for (const entry of entries) byId.set(entry.id, entry.content); return finalizePulse(aggregates, (id) => byId.get(id)); } //# sourceMappingURL=pulse-summary.js.map