@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
338 lines • 14.6 kB
JavaScript
// 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