@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
109 lines • 4.64 kB
JavaScript
// packages/core/src/query/n-plus-one-pattern.ts
import { EntryType } from '../entry/entry.js';
function sqlOf(entry) {
const record = typeof entry.content === 'object' && entry.content !== null
? entry.content
: null;
return record !== null && typeof record.sql === 'string' ? record.sql : '';
}
/**
* Detect N+1 loop patterns in a batch's query entries. For each query family that
* repeats `>= threshold` times, we emit a pattern weighted by the loop's total
* duration and attribute the likely driving parent (the distinct query that ran
* just before the loop began). Pure; ordered by total wasted duration desc.
*
* DESIGN: the flat {@link detectNPlusOne} only answers "did family X repeat N
* times". This adds the two things that make an N+1 actionable — *which* query
* caused it, and *how much time* it cost — modelled on how Sentry/Laravel
* surface N+1: the loop body plus the originating parent span, ranked by cost.
*/
export function detectNPlusOnePatterns(entries, options) {
const queries = entries.filter((entry) => entry.type === EntryType.Query && entry.familyHash !== null);
const groups = new Map();
queries.forEach((entry, index) => {
const familyHash = entry.familyHash;
const duration = typeof entry.durationMs === 'number' ? entry.durationMs : 0;
const existing = groups.get(familyHash);
if (existing) {
existing.count += 1;
existing.totalDurationMs += duration;
}
else {
groups.set(familyHash, {
count: 1,
totalDurationMs: duration,
representativeId: entry.id,
sql: sqlOf(entry),
firstIndex: index,
});
}
});
const patterns = [];
for (const [childFamilyHash, group] of groups) {
if (group.count < options.threshold)
continue;
const parent = findParent(queries, group.firstIndex, childFamilyHash);
patterns.push({
childFamilyHash,
childSql: group.sql,
count: group.count,
totalDurationMs: group.totalDurationMs,
parentFamilyHash: parent?.familyHash ?? null,
parentSql: parent === null ? null : sqlOf(parent),
representativeId: group.representativeId,
batchId: queries[group.firstIndex]?.batchId ?? '',
});
}
return patterns.sort((a, b) => b.totalDurationMs - a.totalDurationMs ||
b.count - a.count ||
a.childFamilyHash.localeCompare(b.childFamilyHash));
}
/**
* The likely driving parent for a loop whose first child is at `firstIndex`:
* walking BACKWARDS from just before the loop, the first query of a DIFFERENT
* family. Returns `null` when none precedes it (the loop is the batch's start).
*/
function findParent(queries, firstIndex, childFamilyHash) {
for (let i = firstIndex - 1; i >= 0; i--) {
const candidate = queries[i];
if (candidate !== undefined && candidate.familyHash !== childFamilyHash) {
return candidate;
}
}
return null;
}
/**
* Convert a detected pattern into a SYNTHETIC insight entry so the loop surfaces
* in the same entry stream as everything else (deep-linked to its batch). It is
* NOT a captured event — `id` is derived deterministically from the batch +
* child family so re-running detection over the same batch yields a stable id
* (idempotent ingestion). Carries the `n-plus-one` + `insight` tags for filtering.
*/
export function toSyntheticInsightEntry(pattern, at = new Date()) {
const message = pattern.parentSql !== null
? `N+1: ${pattern.count} repeats of a child query driven by "${pattern.parentSql}" (~${Math.round(pattern.totalDurationMs)}ms)`
: `N+1: ${pattern.count} repeats of a similar query (~${Math.round(pattern.totalDurationMs)}ms)`;
return {
id: `insight:n+1:${pattern.batchId}:${pattern.childFamilyHash}`,
batchId: pattern.batchId,
type: 'insight',
familyHash: `n-plus-one:${pattern.childFamilyHash}`,
content: {
kind: 'n-plus-one',
childSql: pattern.childSql,
parentSql: pattern.parentSql,
count: pattern.count,
totalDurationMs: pattern.totalDurationMs,
message,
},
tags: ['insight', 'n-plus-one'],
sequence: 0,
durationMs: pattern.totalDurationMs,
origin: 'manual',
instanceId: '',
traceId: null,
spanId: null,
createdAt: at,
};
}
//# sourceMappingURL=n-plus-one-pattern.js.map