@stacknext/diagnostics 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/classify.d.ts.map +1 -1
- package/dist/classify.js +2 -0
- package/dist/classify.js.map +1 -1
- package/dist/engine.d.ts +3 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +44 -10
- package/dist/engine.js.map +1 -1
- package/dist/fingerprints.d.ts +5 -0
- package/dist/fingerprints.d.ts.map +1 -1
- package/dist/fingerprints.js +95 -14
- package/dist/fingerprints.js.map +1 -1
- package/dist/rules.d.ts +7 -2
- package/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +771 -177
- package/dist/rules.js.map +1 -1
- package/dist/trace.d.ts +68 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +352 -0
- package/dist/trace.js.map +1 -0
- package/package.json +2 -2
package/dist/rules.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import { DIAGNOSTIC_RULES, fingerprint, redactText, stripUrlQueryInText, } from '@stacknext/core';
|
|
2
|
-
import { diagnosticSpanCategory, safeRoute, safeSpanName,
|
|
3
|
-
import { diagnosticErrorFingerprint, diagnosticFindingId, errorOperationCategory, sqlFingerprint, sqlPreview, } from './fingerprints.js';
|
|
4
|
-
|
|
1
|
+
import { DIAGNOSTIC_RULES, STACKNEXT_DIAGNOSTICS_RULE_SET_VERSION, fingerprint, redactText, stripUrlQueryInText, } from '@stacknext/core';
|
|
2
|
+
import { diagnosticSpanCategory, safeRoute, safeSpanName, spanHttpEndpoint, spanHttpMethod, spanThresholdMs, } from './classify.js';
|
|
3
|
+
import { diagnosticErrorFingerprint, diagnosticFindingId, errorOperationCategory, isControlFlowError, sqlFingerprint, sqlPreview, sqlTableHint, } from './fingerprints.js';
|
|
4
|
+
import { buildTraceIndex, contributorForNode, contributorsForTrace, isRequestRootNode, maxValue, median, minValue, normalizeRoute, roundMs, spanEvidenceForNode, } from './trace.js';
|
|
5
|
+
const RULE_VERSION = STACKNEXT_DIAGNOSTICS_RULE_SET_VERSION;
|
|
5
6
|
const SAMPLE_LIMITATION = 'Only the current in-memory retention window is analyzed; findings disappear when evidence ages out.';
|
|
6
7
|
const NULL_DURATION_LIMITATION = 'Database durations can be null when an adapter does not measure them. Null is never treated as zero.';
|
|
8
|
+
const REQUEST_ROOT_LIMITATION = 'Request-level rules only analyze traces with a captured request root. Spans without an attributable root are reported as a sample note instead of a finding.';
|
|
9
|
+
const TRACE_ID_LIMIT = 50;
|
|
10
|
+
const TRACE_EVIDENCE_LIMIT = 20;
|
|
11
|
+
const SPAN_EVIDENCE_LIMIT = 10;
|
|
12
|
+
const QUERY_EVIDENCE_LIMIT = 10;
|
|
13
|
+
const ERROR_EVIDENCE_LIMIT = 10;
|
|
14
|
+
const CONTRIBUTOR_LIMIT = 10;
|
|
7
15
|
function ruleSummary(ruleId) {
|
|
8
16
|
const rule = DIAGNOSTIC_RULES.find((entry) => entry.id === ruleId);
|
|
9
17
|
if (!rule)
|
|
10
18
|
throw new Error(`Unknown diagnostic rule: ${ruleId}`);
|
|
11
19
|
return rule;
|
|
12
20
|
}
|
|
13
|
-
function roundMs(value) {
|
|
14
|
-
if (!Number.isFinite(value))
|
|
15
|
-
return 0;
|
|
16
|
-
return Math.round(value * 10) / 10;
|
|
17
|
-
}
|
|
18
21
|
function formatTimestamp(value) {
|
|
19
22
|
const date = new Date(value);
|
|
20
23
|
return Number.isNaN(date.getTime()) ? 'unknown time' : date.toISOString();
|
|
@@ -24,6 +27,94 @@ function shortText(value, maxLength) {
|
|
|
24
27
|
return value;
|
|
25
28
|
return `${value.slice(0, maxLength)}…`;
|
|
26
29
|
}
|
|
30
|
+
function sanitizedText(value, maxLength) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return null;
|
|
33
|
+
const safe = stripUrlQueryInText(redactText(value, maxLength));
|
|
34
|
+
return safe === '' ? null : safe;
|
|
35
|
+
}
|
|
36
|
+
function canonicalRoute(value) {
|
|
37
|
+
const safe = safeRoute(value);
|
|
38
|
+
if (!safe)
|
|
39
|
+
return null;
|
|
40
|
+
return safe.startsWith('/') ? normalizeRoute(safe) : safe;
|
|
41
|
+
}
|
|
42
|
+
function stringAttribute(attributes, ...keys) {
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const value = attributes[key];
|
|
45
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
46
|
+
return value.trim();
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function uniqueStrings(values) {
|
|
51
|
+
return [...new Set(values.filter((value) => Boolean(value)))];
|
|
52
|
+
}
|
|
53
|
+
function commonString(values) {
|
|
54
|
+
const unique = uniqueStrings(values);
|
|
55
|
+
return unique.length === 1 ? unique[0] ?? null : null;
|
|
56
|
+
}
|
|
57
|
+
function methodFromRequest(trace) {
|
|
58
|
+
const nameMethod = /^([A-Z]+)\s+/.exec(trace.request?.name ?? '')?.[1];
|
|
59
|
+
return trace.method ?? nameMethod?.toUpperCase() ?? null;
|
|
60
|
+
}
|
|
61
|
+
function routeGroups(traces) {
|
|
62
|
+
const groups = new Map();
|
|
63
|
+
for (const trace of traces) {
|
|
64
|
+
const key = trace.routeKey ?? methodFromRequest(trace) ?? 'unknown-route';
|
|
65
|
+
const existing = groups.get(key);
|
|
66
|
+
if (existing)
|
|
67
|
+
existing.traces.push(trace);
|
|
68
|
+
else {
|
|
69
|
+
groups.set(key, {
|
|
70
|
+
key,
|
|
71
|
+
route: trace.route,
|
|
72
|
+
method: methodFromRequest(trace),
|
|
73
|
+
traces: [trace],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const group of groups.values()) {
|
|
78
|
+
group.traces.sort((a, b) => a.startTime - b.startTime || a.traceId.localeCompare(b.traceId));
|
|
79
|
+
group.method = commonString(group.traces.map((trace) => methodFromRequest(trace)));
|
|
80
|
+
}
|
|
81
|
+
return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key) || a.traces[0].traceId.localeCompare(b.traces[0].traceId));
|
|
82
|
+
}
|
|
83
|
+
const traceMembership = new WeakMap();
|
|
84
|
+
export function findingMatchesTrace(finding, traceId) {
|
|
85
|
+
return traceMembership.get(finding.traceIds)?.has(traceId) ?? finding.traceIds.includes(traceId);
|
|
86
|
+
}
|
|
87
|
+
function limitTraceIds(traceIds) {
|
|
88
|
+
const all = uniqueStrings(traceIds);
|
|
89
|
+
const limited = all.slice(0, TRACE_ID_LIMIT);
|
|
90
|
+
traceMembership.set(limited, new Set(all));
|
|
91
|
+
return limited;
|
|
92
|
+
}
|
|
93
|
+
function traceOccurrence(trace, occurrenceCount, options = {}) {
|
|
94
|
+
const duration = options.durationMs;
|
|
95
|
+
const contributor = options.contributor ? safeSpanName(options.contributor) : null;
|
|
96
|
+
return {
|
|
97
|
+
traceId: trace.traceId,
|
|
98
|
+
timestamp: options.timestamp ?? trace.request?.timestamp ?? trace.startTime,
|
|
99
|
+
occurrences: occurrenceCount,
|
|
100
|
+
...(duration !== null && duration !== undefined ? { durationMs: roundMs(duration) } : {}),
|
|
101
|
+
...(contributor ? { contributor } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function boundedContributors(contributors) {
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
const result = [];
|
|
107
|
+
for (const contributor of contributors) {
|
|
108
|
+
const key = `${contributor.traceId ?? ''}\u0000${contributor.spanId ?? ''}\u0000${contributor.label}`;
|
|
109
|
+
if (seen.has(key))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
result.push(contributor);
|
|
113
|
+
if (result.length >= CONTRIBUTOR_LIMIT)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
27
118
|
function queryEvidence(query) {
|
|
28
119
|
return {
|
|
29
120
|
id: query.id,
|
|
@@ -38,8 +129,15 @@ function queryEvidence(query) {
|
|
|
38
129
|
timestamp: query.timestamp,
|
|
39
130
|
};
|
|
40
131
|
}
|
|
41
|
-
function errorEvidence(error, groupFingerprint) {
|
|
42
|
-
const route =
|
|
132
|
+
function errorEvidence(error, groupFingerprint, trace, deepest) {
|
|
133
|
+
const route = canonicalRoute(error.route ?? error.requestPath) ?? trace?.route ?? null;
|
|
134
|
+
const routeType = sanitizedText(error.routeType, 100);
|
|
135
|
+
const renderSource = sanitizedText(error.renderSource, 100);
|
|
136
|
+
const renderType = sanitizedText(error.renderType, 100);
|
|
137
|
+
const revalidateReason = sanitizedText(stringAttribute(error.attributes, 'next.revalidate_reason', 'revalidateReason'), 100);
|
|
138
|
+
const routerKind = sanitizedText(error.routerKind, 100);
|
|
139
|
+
const digest = sanitizedText(error.digest, 1_000);
|
|
140
|
+
const spanId = deepest?.span.spanId ?? error.spanId;
|
|
43
141
|
return {
|
|
44
142
|
id: error.id,
|
|
45
143
|
name: shortText(stripUrlQueryInText(redactText(error.name, 200)), 200),
|
|
@@ -49,13 +147,25 @@ function errorEvidence(error, groupFingerprint) {
|
|
|
49
147
|
...(error.method ? { method: error.method } : {}),
|
|
50
148
|
traceId: error.resolvedTraceId ?? error.traceId ?? null,
|
|
51
149
|
fingerprint: groupFingerprint,
|
|
150
|
+
...(routeType ? { routeType } : {}),
|
|
151
|
+
...(renderSource ? { renderSource } : {}),
|
|
152
|
+
...(renderType ? { renderType } : {}),
|
|
153
|
+
...(revalidateReason ? { revalidateReason } : {}),
|
|
154
|
+
...(routerKind ? { routerKind } : {}),
|
|
155
|
+
...(digest ? { digest } : {}),
|
|
156
|
+
...(trace?.statusCode !== null && trace?.statusCode !== undefined
|
|
157
|
+
? { statusCode: trace.statusCode }
|
|
158
|
+
: {}),
|
|
159
|
+
...(spanId ? { spanId } : {}),
|
|
52
160
|
};
|
|
53
161
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
162
|
+
function measuredDuration(queries) {
|
|
163
|
+
const measured = queries
|
|
164
|
+
.map((query) => query.durationMs)
|
|
165
|
+
.filter((duration) => duration !== null && Number.isFinite(duration));
|
|
166
|
+
if (measured.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
return measured.reduce((sum, duration) => sum + duration, 0);
|
|
59
169
|
}
|
|
60
170
|
function nPlusOneFindings(context) {
|
|
61
171
|
const groups = new Map();
|
|
@@ -63,66 +173,128 @@ function nPlusOneFindings(context) {
|
|
|
63
173
|
const traceId = query.resolvedTraceId;
|
|
64
174
|
if (!traceId)
|
|
65
175
|
continue;
|
|
176
|
+
const trace = context.index.traces.get(traceId);
|
|
177
|
+
if (!trace || trace.root === null)
|
|
178
|
+
continue;
|
|
179
|
+
// A trace ID alone does not prove request attribution. Adapters attach the
|
|
180
|
+
// active OTel span ID; absent or detached span evidence stays unverified.
|
|
181
|
+
const node = query.spanId ? trace.bySpanId.get(query.spanId) : undefined;
|
|
182
|
+
if (!node?.attributed)
|
|
183
|
+
continue;
|
|
184
|
+
const route = canonicalRoute(query.route) ?? trace.route;
|
|
185
|
+
const routeKey = trace.routeKey ?? route;
|
|
186
|
+
if (!route || !routeKey)
|
|
187
|
+
continue;
|
|
66
188
|
const shape = sqlFingerprint(query.sql);
|
|
67
189
|
if (shape === '')
|
|
68
190
|
continue;
|
|
69
|
-
const key = `${
|
|
191
|
+
const key = `${routeKey}\u0000${query.databaseSystem.toLowerCase()}\u0000${shape}`;
|
|
70
192
|
const existing = groups.get(key);
|
|
71
|
-
if (existing)
|
|
72
|
-
existing.
|
|
193
|
+
if (existing) {
|
|
194
|
+
const existingQueries = existing.byTrace.get(traceId);
|
|
195
|
+
if (existingQueries)
|
|
196
|
+
existingQueries.push(query);
|
|
197
|
+
else
|
|
198
|
+
existing.byTrace.set(traceId, [query]);
|
|
199
|
+
existing.methods.push(methodFromRequest(trace));
|
|
200
|
+
}
|
|
73
201
|
else {
|
|
74
202
|
groups.set(key, {
|
|
75
|
-
|
|
203
|
+
key,
|
|
204
|
+
route,
|
|
205
|
+
routeKey,
|
|
76
206
|
databaseSystem: query.databaseSystem,
|
|
77
207
|
shape,
|
|
78
|
-
|
|
208
|
+
tableHint: sqlTableHint(shape),
|
|
209
|
+
byTrace: new Map([[traceId, [query]]]),
|
|
210
|
+
methods: [methodFromRequest(trace)],
|
|
79
211
|
});
|
|
80
212
|
}
|
|
81
213
|
}
|
|
82
214
|
const findings = [];
|
|
215
|
+
const rule = ruleSummary('n-plus-one-queries');
|
|
83
216
|
for (const group of groups.values()) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
217
|
+
const matchingTraces = [];
|
|
218
|
+
for (const [traceId, queries] of group.byTrace.entries()) {
|
|
219
|
+
if (queries.length < context.config.repeatedQueryMin)
|
|
220
|
+
continue;
|
|
221
|
+
const trace = context.index.traces.get(traceId);
|
|
222
|
+
if (!trace)
|
|
223
|
+
continue;
|
|
224
|
+
matchingTraces.push({
|
|
225
|
+
trace,
|
|
226
|
+
queries: [...queries].sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id)),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (matchingTraces.length === 0)
|
|
90
230
|
continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
231
|
+
matchingTraces.sort((a, b) => b.queries.length - a.queries.length ||
|
|
232
|
+
b.trace.startTime - a.trace.startTime ||
|
|
233
|
+
a.trace.traceId.localeCompare(b.trace.traceId));
|
|
234
|
+
const representative = matchingTraces[0];
|
|
235
|
+
const allMatchingQueries = matchingTraces.flatMap((entry) => entry.queries);
|
|
236
|
+
const totalOccurrences = allMatchingQueries.length;
|
|
237
|
+
const measuredTotal = measuredDuration(allMatchingQueries);
|
|
238
|
+
const methods = uniqueStrings(group.methods);
|
|
239
|
+
const method = methods.length === 1 ? methods[0] ?? null : null;
|
|
94
240
|
const findingFingerprint = fingerprint([
|
|
95
|
-
|
|
241
|
+
rule.id,
|
|
242
|
+
group.route,
|
|
96
243
|
group.databaseSystem.toLowerCase(),
|
|
97
244
|
group.shape,
|
|
98
245
|
]);
|
|
99
246
|
const preview = shortText(group.shape, 180);
|
|
247
|
+
const tableHint = group.tableHint ? ` Table hint: ${group.tableHint}.` : '';
|
|
248
|
+
const measuredSummary = measuredTotal === null
|
|
249
|
+
? ' No query duration was measured.'
|
|
250
|
+
: ` Measured query duration totals ${roundMs(measuredTotal)} ms (inclusive, not additive with span time).`;
|
|
251
|
+
const representativeDuration = measuredDuration(representative.queries);
|
|
100
252
|
findings.push({
|
|
101
|
-
id: diagnosticFindingId(rule.id, findingFingerprint, group.
|
|
253
|
+
id: diagnosticFindingId(rule.id, findingFingerprint, group.routeKey),
|
|
102
254
|
ruleId: rule.id,
|
|
103
255
|
ruleVersion: RULE_VERSION,
|
|
104
256
|
category: rule.category,
|
|
105
257
|
severity: rule.severity,
|
|
106
258
|
status: 'possible',
|
|
107
|
-
title: `Possible N+1 queries: ${
|
|
108
|
-
summary: `${
|
|
109
|
-
detail: `The same normalized SQL shape ran ${queries.length} times in trace ${
|
|
110
|
-
route,
|
|
259
|
+
title: `Possible N+1 queries: ${totalOccurrences} similar ${group.databaseSystem} queries on ${group.routeKey}`,
|
|
260
|
+
summary: `${totalOccurrences} queries with the same normalized SQL shape ran across ${matchingTraces.length} trace${matchingTraces.length === 1 ? '' : 's'} on ${group.routeKey}.`,
|
|
261
|
+
detail: `The same normalized SQL shape ran ${representative.queries.length} times in the busiest trace ${representative.trace.traceId}${group.route ? ` (${group.route})` : ''}. This is consistent with a per-item query loop, but StackNext cannot confirm an N+1 bug without reviewing the calling code.${tableHint}${measuredSummary}`,
|
|
262
|
+
route: group.route,
|
|
111
263
|
method,
|
|
112
|
-
traceIds:
|
|
113
|
-
|
|
114
|
-
|
|
264
|
+
traceIds: limitTraceIds(matchingTraces.map((entry) => entry.trace.traceId)),
|
|
265
|
+
affectedTraceCount: matchingTraces.length,
|
|
266
|
+
firstSeen: Math.min(...allMatchingQueries.map((query) => query.timestamp)),
|
|
267
|
+
lastSeen: Math.max(...allMatchingQueries.map((query) => query.timestamp)),
|
|
115
268
|
fingerprint: findingFingerprint,
|
|
116
269
|
evidence: {
|
|
117
|
-
traceIds:
|
|
270
|
+
traceIds: limitTraceIds(matchingTraces.map((entry) => entry.trace.traceId)),
|
|
118
271
|
spans: [],
|
|
119
|
-
queries: queries.slice(0,
|
|
272
|
+
queries: representative.queries.slice(0, QUERY_EVIDENCE_LIMIT).map(queryEvidence),
|
|
120
273
|
errors: [],
|
|
274
|
+
contributors: [],
|
|
275
|
+
traces: matchingTraces
|
|
276
|
+
.slice(0, TRACE_EVIDENCE_LIMIT)
|
|
277
|
+
.map((entry) => traceOccurrence(entry.trace, entry.queries.length, {
|
|
278
|
+
timestamp: entry.queries[0]?.timestamp ?? entry.trace.startTime,
|
|
279
|
+
durationMs: measuredDuration(entry.queries),
|
|
280
|
+
})),
|
|
121
281
|
measurements: [
|
|
122
|
-
{ label: 'matching queries', value:
|
|
282
|
+
{ label: 'matching queries', value: totalOccurrences },
|
|
283
|
+
{ label: 'matching traces', value: matchingTraces.length },
|
|
123
284
|
{ label: 'threshold', value: context.config.repeatedQueryMin },
|
|
124
285
|
{ label: 'database system', value: group.databaseSystem },
|
|
125
286
|
{ label: 'normalized SQL shape', value: preview },
|
|
287
|
+
...(group.tableHint ? [{ label: 'table hint', value: group.tableHint }] : []),
|
|
288
|
+
{
|
|
289
|
+
label: 'measured query duration',
|
|
290
|
+
value: measuredTotal === null ? null : roundMs(measuredTotal),
|
|
291
|
+
...(measuredTotal === null ? {} : { unit: 'ms' }),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
label: 'busiest trace measured query duration',
|
|
295
|
+
value: representativeDuration === null ? null : roundMs(representativeDuration),
|
|
296
|
+
...(representativeDuration === null ? {} : { unit: 'ms' }),
|
|
297
|
+
},
|
|
126
298
|
],
|
|
127
299
|
},
|
|
128
300
|
verification: [
|
|
@@ -133,6 +305,7 @@ function nPlusOneFindings(context) {
|
|
|
133
305
|
limitations: [
|
|
134
306
|
SAMPLE_LIMITATION,
|
|
135
307
|
NULL_DURATION_LIMITATION,
|
|
308
|
+
REQUEST_ROOT_LIMITATION,
|
|
136
309
|
'SQL bind values are never captured; the fingerprint replaces literals and parameters.',
|
|
137
310
|
'Repeated query shapes can be intentional, for example when fetching independent resources.',
|
|
138
311
|
],
|
|
@@ -140,134 +313,375 @@ function nPlusOneFindings(context) {
|
|
|
140
313
|
}
|
|
141
314
|
return findings;
|
|
142
315
|
}
|
|
143
|
-
function
|
|
316
|
+
function slowRequestFindings(context) {
|
|
317
|
+
const rule = ruleSummary('slow-request');
|
|
144
318
|
const findings = [];
|
|
145
|
-
for (const
|
|
146
|
-
|
|
319
|
+
for (const group of routeGroups(context.index.requestTraces)) {
|
|
320
|
+
const measured = group.traces.filter((trace) => trace.requestDurationMs !== null && trace.requestDurationMs >= context.slowRequestMs);
|
|
321
|
+
if (measured.length === 0)
|
|
147
322
|
continue;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
323
|
+
measured.sort((a, b) => (b.requestDurationMs ?? 0) - (a.requestDurationMs ?? 0) ||
|
|
324
|
+
b.startTime - a.startTime ||
|
|
325
|
+
a.traceId.localeCompare(b.traceId));
|
|
326
|
+
const representative = measured[0];
|
|
327
|
+
const contributors = contributorsForTrace(representative, 5);
|
|
328
|
+
const dominant = contributors[0] ?? null;
|
|
329
|
+
const durations = measured.map((trace) => trace.requestDurationMs ?? 0);
|
|
330
|
+
const slowMedian = median(durations);
|
|
331
|
+
const slowMaximum = maxValue(durations);
|
|
332
|
+
const slowMinimum = minValue(durations);
|
|
333
|
+
const findingFingerprint = fingerprint([rule.id, group.method ?? '', group.route ?? '']);
|
|
334
|
+
const routeLabel = group.key;
|
|
335
|
+
const dominantText = dominant
|
|
336
|
+
? `${dominant.span.nextSpanName ?? dominant.span.name} (${roundMs(dominant.selfMs)} ms self time, ${dominant.phase})`
|
|
337
|
+
: 'no measured child span with self time';
|
|
338
|
+
findings.push({
|
|
339
|
+
id: diagnosticFindingId(rule.id, findingFingerprint, group.key),
|
|
340
|
+
ruleId: rule.id,
|
|
341
|
+
ruleVersion: RULE_VERSION,
|
|
342
|
+
category: rule.category,
|
|
343
|
+
severity: rule.severity,
|
|
344
|
+
status: 'observed',
|
|
345
|
+
title: `Slow requests: ${routeLabel}`,
|
|
346
|
+
summary: `${measured.length} of ${group.traces.length} ${routeLabel} requests exceeded the ${context.slowRequestMs} ms request threshold${slowMedian === null ? '' : ` (median ${roundMs(slowMedian)} ms)`}.`,
|
|
347
|
+
detail: `Request duration is measured from the request root${representative.root ? ` (${safeSpanName(representative.root.span.nextSpanName ?? representative.root.span.name)})` : ''}. In the slowest trace ${representative.traceId}, the largest self-time contributor is ${dominantText}. Self time subtracts the union of direct child span intervals, so it is an attribution aid rather than proof of the cause.`,
|
|
348
|
+
route: group.route,
|
|
349
|
+
method: group.method,
|
|
350
|
+
traceIds: limitTraceIds(measured.map((trace) => trace.traceId)),
|
|
351
|
+
affectedTraceCount: measured.length,
|
|
352
|
+
firstSeen: Math.min(...measured.map((trace) => trace.request?.timestamp ?? trace.startTime)),
|
|
353
|
+
lastSeen: Math.max(...measured.map((trace) => trace.request?.timestamp ?? trace.startTime)),
|
|
354
|
+
fingerprint: findingFingerprint,
|
|
355
|
+
evidence: {
|
|
356
|
+
traceIds: limitTraceIds(measured.map((trace) => trace.traceId)),
|
|
357
|
+
spans: representative
|
|
358
|
+
? [
|
|
359
|
+
...(representative.root ? [spanEvidenceForNode(representative.root)] : []),
|
|
360
|
+
...contributors.map(spanEvidenceForNode),
|
|
361
|
+
]
|
|
362
|
+
.filter((span, index, list) => list.findIndex((entry) => entry.spanId === span.spanId) === index)
|
|
363
|
+
.slice(0, SPAN_EVIDENCE_LIMIT)
|
|
364
|
+
: [],
|
|
365
|
+
queries: [],
|
|
366
|
+
errors: [],
|
|
367
|
+
contributors: boundedContributors(contributors.map(contributorForNode)),
|
|
368
|
+
traces: measured.slice(0, TRACE_EVIDENCE_LIMIT).map((trace) => traceOccurrence(trace, 1, {
|
|
369
|
+
durationMs: trace.requestDurationMs,
|
|
370
|
+
contributor: contributorsForTrace(trace, 1)[0]?.span.nextSpanName ??
|
|
371
|
+
contributorsForTrace(trace, 1)[0]?.span.name ??
|
|
372
|
+
null,
|
|
373
|
+
})),
|
|
374
|
+
measurements: [
|
|
375
|
+
{ label: 'slow requests', value: measured.length },
|
|
376
|
+
{ label: 'requests on route', value: group.traces.length },
|
|
377
|
+
{
|
|
378
|
+
label: 'slow request rate',
|
|
379
|
+
value: Math.round((measured.length / Math.max(1, group.traces.length)) * 1000) / 10,
|
|
380
|
+
unit: '%',
|
|
381
|
+
},
|
|
382
|
+
{ label: 'threshold', value: context.slowRequestMs, unit: 'ms' },
|
|
383
|
+
...(slowMedian === null
|
|
384
|
+
? []
|
|
385
|
+
: [{ label: 'median slow duration', value: roundMs(slowMedian), unit: 'ms' }]),
|
|
386
|
+
...(slowMinimum === null
|
|
387
|
+
? []
|
|
388
|
+
: [{ label: 'minimum slow duration', value: roundMs(slowMinimum), unit: 'ms' }]),
|
|
389
|
+
...(slowMaximum === null
|
|
390
|
+
? []
|
|
391
|
+
: [{ label: 'maximum duration', value: roundMs(slowMaximum), unit: 'ms' }]),
|
|
392
|
+
{
|
|
393
|
+
label: 'dominant contributor',
|
|
394
|
+
value: dominant ? safeSpanName(dominant.span.nextSpanName ?? dominant.span.name) : null,
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
verification: [
|
|
399
|
+
'Reproduce the request under the same conditions and confirm the duration is measured, not estimated.',
|
|
400
|
+
'Inspect the dominant self-time contributor and its Next.js phase before changing code.',
|
|
401
|
+
'Check cold starts, route-module loading, queueing and database latency separately.',
|
|
402
|
+
],
|
|
403
|
+
limitations: [
|
|
404
|
+
SAMPLE_LIMITATION,
|
|
405
|
+
'A threshold crossing is evidence of slow work, not proof of the cause.',
|
|
406
|
+
'Self time is approximate when clocks disagree or child spans overlap the parent interval.',
|
|
407
|
+
'Cold-start and route-module loading can dominate the first request for a route.',
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return findings;
|
|
412
|
+
}
|
|
413
|
+
function slowSpanFindings(context) {
|
|
414
|
+
const rule = ruleSummary('slow-span');
|
|
415
|
+
const groups = new Map();
|
|
416
|
+
for (const trace of context.index.requestTraces) {
|
|
417
|
+
if (trace.requestDurationMs !== null &&
|
|
418
|
+
trace.requestDurationMs >= context.slowRequestMs) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
for (const node of trace.nodes) {
|
|
422
|
+
if (!node.attributed || isRequestRootNode(node))
|
|
423
|
+
continue;
|
|
424
|
+
if (!Number.isFinite(node.selfMs) || node.selfMs <= 0)
|
|
425
|
+
continue;
|
|
426
|
+
const category = diagnosticSpanCategory(node.span);
|
|
427
|
+
const threshold = spanThresholdMs(category, context.config);
|
|
428
|
+
if (node.selfMs < threshold)
|
|
429
|
+
continue;
|
|
430
|
+
const routeKey = trace.routeKey ?? methodFromRequest(trace) ?? 'unknown-route';
|
|
431
|
+
const label = safeSpanName(node.span.nextSpanName ?? node.span.name);
|
|
432
|
+
const nextSpanType = node.span.nextSpanType ?? null;
|
|
433
|
+
const key = `${routeKey}\u0000${category}\u0000${label}\u0000${nextSpanType ?? ''}`;
|
|
434
|
+
const existing = groups.get(key);
|
|
435
|
+
if (existing) {
|
|
436
|
+
const nodes = existing.byTrace.get(trace.traceId);
|
|
437
|
+
if (nodes)
|
|
438
|
+
nodes.push(node);
|
|
439
|
+
else
|
|
440
|
+
existing.byTrace.set(trace.traceId, [node]);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
groups.set(key, {
|
|
444
|
+
key,
|
|
445
|
+
routeKey,
|
|
446
|
+
route: trace.route,
|
|
447
|
+
category,
|
|
448
|
+
label,
|
|
449
|
+
nextSpanType,
|
|
450
|
+
threshold,
|
|
451
|
+
byTrace: new Map([[trace.traceId, [node]]]),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const findings = [];
|
|
457
|
+
for (const group of groups.values()) {
|
|
458
|
+
const matching = [];
|
|
459
|
+
for (const [traceId, nodes] of group.byTrace.entries()) {
|
|
460
|
+
const trace = context.index.traces.get(traceId);
|
|
461
|
+
if (!trace)
|
|
462
|
+
continue;
|
|
463
|
+
matching.push({
|
|
464
|
+
trace,
|
|
465
|
+
nodes: [...nodes].sort((a, b) => b.selfMs - a.selfMs ||
|
|
466
|
+
a.span.startTime - b.span.startTime ||
|
|
467
|
+
a.span.spanId.localeCompare(b.span.spanId)),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (matching.length === 0)
|
|
151
471
|
continue;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
472
|
+
matching.sort((a, b) => (b.nodes[0]?.selfMs ?? 0) - (a.nodes[0]?.selfMs ?? 0) ||
|
|
473
|
+
b.trace.startTime - a.trace.startTime ||
|
|
474
|
+
a.trace.traceId.localeCompare(b.trace.traceId));
|
|
475
|
+
const representative = matching[0];
|
|
476
|
+
const representativeNode = representative.nodes[0];
|
|
477
|
+
const totalOccurrences = matching.reduce((sum, entry) => sum + entry.nodes.length, 0);
|
|
478
|
+
const bestSelfMs = representativeNode.selfMs;
|
|
479
|
+
const methods = uniqueStrings(matching.map((entry) => methodFromRequest(entry.trace)));
|
|
480
|
+
const method = methods.length === 1 ? methods[0] ?? null : null;
|
|
481
|
+
const measuredQueries = measuredDuration(representative.nodes.flatMap((node) => context.snapshot.databaseQueries.filter((query) => query.spanId === node.span.spanId && query.traceId === node.span.traceId)));
|
|
156
482
|
const findingFingerprint = fingerprint([
|
|
157
|
-
|
|
158
|
-
category,
|
|
159
|
-
|
|
160
|
-
|
|
483
|
+
rule.id,
|
|
484
|
+
group.category,
|
|
485
|
+
group.label,
|
|
486
|
+
group.nextSpanType ?? '',
|
|
161
487
|
]);
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const evidenceSpan = spanEvidence(span, category);
|
|
165
|
-
const relatedQueries = context.snapshot.databaseQueries
|
|
166
|
-
.filter((query) => query.spanId === span.spanId && query.traceId === span.traceId)
|
|
167
|
-
.slice(0, 5)
|
|
168
|
-
.map(queryEvidence);
|
|
488
|
+
const phase = representativeNode.phase;
|
|
489
|
+
const matchingNodes = matching.flatMap((entry) => entry.nodes);
|
|
169
490
|
findings.push({
|
|
170
|
-
id: diagnosticFindingId(rule.id, findingFingerprint,
|
|
491
|
+
id: diagnosticFindingId(rule.id, findingFingerprint, group.routeKey),
|
|
171
492
|
ruleId: rule.id,
|
|
172
493
|
ruleVersion: RULE_VERSION,
|
|
173
494
|
category: rule.category,
|
|
174
495
|
severity: rule.severity,
|
|
175
496
|
status: 'observed',
|
|
176
|
-
title: `Slow ${category}
|
|
177
|
-
summary: `${
|
|
178
|
-
detail: `Span ${
|
|
179
|
-
route,
|
|
497
|
+
title: `Slow ${group.category} operation: ${shortText(group.label, 120)}`,
|
|
498
|
+
summary: `${shortText(group.label, 120)} used ${roundMs(bestSelfMs)} ms of self time across ${matching.length} trace${matching.length === 1 ? '' : 's'} on ${group.routeKey}, above the ${group.threshold} ms ${group.category} threshold.`,
|
|
499
|
+
detail: `Span ${shortText(group.label, 160)} (${representativeNode.span.spanId}) has ${roundMs(representativeNode.selfMs)} ms self time in trace ${representative.trace.traceId}. Inclusive duration is ${roundMs(representativeNode.span.durationMs)} ms and the Next.js phase is ${phase}. The enclosing request was below the ${context.slowRequestMs} ms request threshold, so this operation is reported separately.${measuredQueries === null ? '' : ` Associated database queries measured ${roundMs(measuredQueries)} ms in total.`}`,
|
|
500
|
+
route: group.route,
|
|
180
501
|
method,
|
|
181
|
-
traceIds:
|
|
182
|
-
|
|
183
|
-
|
|
502
|
+
traceIds: limitTraceIds(matching.map((entry) => entry.trace.traceId)),
|
|
503
|
+
affectedTraceCount: matching.length,
|
|
504
|
+
firstSeen: Math.min(...matchingNodes.map((node) => node.span.startTime)),
|
|
505
|
+
lastSeen: Math.max(...matchingNodes.map((node) => node.span.startTime)),
|
|
184
506
|
fingerprint: findingFingerprint,
|
|
185
507
|
evidence: {
|
|
186
|
-
traceIds:
|
|
187
|
-
spans: [
|
|
188
|
-
|
|
508
|
+
traceIds: limitTraceIds(matching.map((entry) => entry.trace.traceId)),
|
|
509
|
+
spans: matching.map((entry) => entry.nodes[0])
|
|
510
|
+
.slice(0, SPAN_EVIDENCE_LIMIT)
|
|
511
|
+
.map(spanEvidenceForNode),
|
|
512
|
+
queries: representative.nodes
|
|
513
|
+
.flatMap((node) => context.snapshot.databaseQueries.filter((query) => query.spanId === node.span.spanId && query.traceId === node.span.traceId))
|
|
514
|
+
.slice(0, QUERY_EVIDENCE_LIMIT)
|
|
515
|
+
.map(queryEvidence),
|
|
189
516
|
errors: [],
|
|
517
|
+
contributors: boundedContributors(representative.nodes.slice(0, 5).map(contributorForNode)),
|
|
518
|
+
traces: matching.slice(0, TRACE_EVIDENCE_LIMIT).map((entry) => traceOccurrence(entry.trace, entry.nodes.length, {
|
|
519
|
+
timestamp: entry.nodes[0].span.startTime,
|
|
520
|
+
durationMs: entry.nodes.reduce((sum, node) => sum + node.selfMs, 0),
|
|
521
|
+
contributor: entry.nodes[0].span.nextSpanName ?? entry.nodes[0].span.name,
|
|
522
|
+
})),
|
|
190
523
|
measurements: [
|
|
191
|
-
{ label: '
|
|
192
|
-
{ label: '
|
|
193
|
-
{ label: '
|
|
524
|
+
{ label: 'matching spans', value: totalOccurrences },
|
|
525
|
+
{ label: 'matching traces', value: matching.length },
|
|
526
|
+
{ label: 'self time', value: roundMs(bestSelfMs), unit: 'ms' },
|
|
527
|
+
{
|
|
528
|
+
label: 'inclusive duration',
|
|
529
|
+
value: roundMs(representativeNode.span.durationMs),
|
|
530
|
+
unit: 'ms',
|
|
531
|
+
},
|
|
532
|
+
{ label: 'threshold', value: group.threshold, unit: 'ms' },
|
|
533
|
+
{ label: 'span category', value: group.category },
|
|
534
|
+
{ label: 'next phase', value: phase },
|
|
535
|
+
...(group.nextSpanType
|
|
536
|
+
? [{ label: 'Next.js span type', value: group.nextSpanType }]
|
|
537
|
+
: []),
|
|
538
|
+
...(measuredQueries === null
|
|
539
|
+
? []
|
|
540
|
+
: [
|
|
541
|
+
{
|
|
542
|
+
label: 'associated query duration',
|
|
543
|
+
value: roundMs(measuredQueries),
|
|
544
|
+
unit: 'ms',
|
|
545
|
+
},
|
|
546
|
+
]),
|
|
194
547
|
],
|
|
195
548
|
},
|
|
196
549
|
verification: [
|
|
197
|
-
'Confirm the
|
|
198
|
-
'Check whether child spans, queueing or cold starts explain the
|
|
550
|
+
'Confirm the self time was measured rather than inferred from the inclusive duration.',
|
|
551
|
+
'Check the span phase and whether child spans, queueing or cold starts explain the time.',
|
|
199
552
|
'Reproduce under the same conditions before changing code or infrastructure.',
|
|
200
553
|
],
|
|
201
554
|
limitations: [
|
|
202
555
|
SAMPLE_LIMITATION,
|
|
203
|
-
'
|
|
556
|
+
'Self time is approximate when clocks disagree or child spans overlap the parent interval.',
|
|
557
|
+
'Slow-span findings are suppressed for requests already reported as slow requests.',
|
|
204
558
|
'A threshold crossing is evidence of slow work, not proof of the cause.',
|
|
205
|
-
'Clock skew between processes can distort span timing.',
|
|
206
559
|
],
|
|
207
560
|
});
|
|
208
561
|
}
|
|
209
562
|
return findings;
|
|
210
563
|
}
|
|
211
564
|
function repeatedHttpFindings(context) {
|
|
565
|
+
const rule = ruleSummary('repeated-external-http');
|
|
212
566
|
const groups = new Map();
|
|
213
|
-
for (const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
groups.
|
|
567
|
+
for (const trace of context.index.requestTraces) {
|
|
568
|
+
for (const node of trace.nodes) {
|
|
569
|
+
if (!node.attributed)
|
|
570
|
+
continue;
|
|
571
|
+
if (diagnosticSpanCategory(node.span) !== 'http')
|
|
572
|
+
continue;
|
|
573
|
+
const endpoint = spanHttpEndpoint(node.span);
|
|
574
|
+
if (!endpoint)
|
|
575
|
+
continue;
|
|
576
|
+
const httpMethod = spanHttpMethod(node.span) ?? 'UNKNOWN';
|
|
577
|
+
const routeKey = trace.routeKey ?? methodFromRequest(trace) ?? 'unknown-route';
|
|
578
|
+
const key = `${routeKey}\u0000${httpMethod}\u0000${endpoint}`;
|
|
579
|
+
const existing = groups.get(key);
|
|
580
|
+
if (existing) {
|
|
581
|
+
const nodes = existing.byTrace.get(trace.traceId);
|
|
582
|
+
if (nodes)
|
|
583
|
+
nodes.push(node);
|
|
584
|
+
else
|
|
585
|
+
existing.byTrace.set(trace.traceId, [node]);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
groups.set(key, {
|
|
589
|
+
key,
|
|
590
|
+
routeKey,
|
|
591
|
+
route: trace.route,
|
|
592
|
+
method: httpMethod,
|
|
593
|
+
endpoint,
|
|
594
|
+
byTrace: new Map([[trace.traceId, [node]]]),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
226
598
|
}
|
|
227
599
|
const findings = [];
|
|
228
600
|
for (const group of groups.values()) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
601
|
+
const matching = [];
|
|
602
|
+
for (const [traceId, nodes] of group.byTrace.entries()) {
|
|
603
|
+
if (nodes.length < context.config.repeatedHttpMin)
|
|
604
|
+
continue;
|
|
605
|
+
const trace = context.index.traces.get(traceId);
|
|
606
|
+
if (!trace)
|
|
607
|
+
continue;
|
|
608
|
+
matching.push({
|
|
609
|
+
trace,
|
|
610
|
+
nodes: [...nodes].sort((a, b) => a.span.startTime - b.span.startTime || a.span.spanId.localeCompare(b.span.spanId)),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (matching.length === 0)
|
|
235
614
|
continue;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
615
|
+
matching.sort((a, b) => b.nodes.length - a.nodes.length ||
|
|
616
|
+
b.trace.startTime - a.trace.startTime ||
|
|
617
|
+
a.trace.traceId.localeCompare(b.trace.traceId));
|
|
618
|
+
const representative = matching[0];
|
|
619
|
+
const totalCalls = matching.reduce((sum, entry) => sum + entry.nodes.length, 0);
|
|
620
|
+
const requestMethods = uniqueStrings(matching.map((entry) => methodFromRequest(entry.trace)));
|
|
621
|
+
const requestMethod = requestMethods.length === 1 ? requestMethods[0] ?? null : null;
|
|
622
|
+
const cacheStatuses = new Map();
|
|
623
|
+
let loopbackCalls = 0;
|
|
624
|
+
for (const entry of matching) {
|
|
625
|
+
for (const node of entry.nodes) {
|
|
626
|
+
const cacheStatus = stringAttribute(node.span.attributes, 'next.fetch.cache_status', 'http.response.cache_status') ?? 'unknown';
|
|
627
|
+
cacheStatuses.set(cacheStatus, (cacheStatuses.get(cacheStatus) ?? 0) + 1);
|
|
628
|
+
if (group.endpoint.startsWith('127.0.0.1') ||
|
|
629
|
+
group.endpoint.startsWith('localhost') ||
|
|
630
|
+
group.endpoint.startsWith('[::1]')) {
|
|
631
|
+
loopbackCalls += 1;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const cacheSummary = [...cacheStatuses.entries()]
|
|
636
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
637
|
+
.map(([status, count]) => `${status}: ${count}`)
|
|
638
|
+
.join(', ');
|
|
240
639
|
const findingFingerprint = fingerprint([
|
|
241
|
-
|
|
640
|
+
rule.id,
|
|
641
|
+
requestMethod ?? '',
|
|
242
642
|
group.method,
|
|
243
643
|
group.endpoint,
|
|
644
|
+
group.route ?? '',
|
|
244
645
|
]);
|
|
646
|
+
const matchingNodes = matching.flatMap((entry) => entry.nodes);
|
|
245
647
|
findings.push({
|
|
246
|
-
id: diagnosticFindingId(rule.id, findingFingerprint, group.
|
|
648
|
+
id: diagnosticFindingId(rule.id, findingFingerprint, group.routeKey),
|
|
247
649
|
ruleId: rule.id,
|
|
248
650
|
ruleVersion: RULE_VERSION,
|
|
249
651
|
category: rule.category,
|
|
250
652
|
severity: rule.severity,
|
|
251
653
|
status: 'possible',
|
|
252
654
|
title: `Repeated HTTP calls: ${group.method} ${shortText(group.endpoint, 140)}`,
|
|
253
|
-
summary: `${
|
|
254
|
-
detail: `The same outbound endpoint was called ${
|
|
255
|
-
route,
|
|
256
|
-
method,
|
|
257
|
-
traceIds:
|
|
258
|
-
|
|
259
|
-
|
|
655
|
+
summary: `${totalCalls} outbound ${group.method} calls to ${shortText(group.endpoint, 180)} across ${matching.length} trace${matching.length === 1 ? '' : 's'} on ${group.routeKey}.`,
|
|
656
|
+
detail: `The same outbound endpoint was called ${totalCalls} times from ${group.routeKey}. Observed cache statuses: ${cacheSummary}. ${loopbackCalls > 0 ? `${loopbackCalls} call${loopbackCalls === 1 ? '' : 's'} targeted a loopback host, which may be the same application. ` : ''}This may indicate missing caching or request deduplication, but repeated calls can be intentional.`,
|
|
657
|
+
route: group.route,
|
|
658
|
+
method: requestMethod,
|
|
659
|
+
traceIds: limitTraceIds(matching.map((entry) => entry.trace.traceId)),
|
|
660
|
+
affectedTraceCount: matching.length,
|
|
661
|
+
firstSeen: Math.min(...matchingNodes.map((node) => node.span.startTime)),
|
|
662
|
+
lastSeen: Math.max(...matchingNodes.map((node) => node.span.startTime)),
|
|
260
663
|
fingerprint: findingFingerprint,
|
|
261
664
|
evidence: {
|
|
262
|
-
traceIds:
|
|
263
|
-
spans:
|
|
665
|
+
traceIds: limitTraceIds(matching.map((entry) => entry.trace.traceId)),
|
|
666
|
+
spans: representative.nodes
|
|
667
|
+
.slice(0, SPAN_EVIDENCE_LIMIT)
|
|
668
|
+
.map(spanEvidenceForNode),
|
|
264
669
|
queries: [],
|
|
265
670
|
errors: [],
|
|
671
|
+
contributors: boundedContributors(representative.nodes.slice(0, 5).map(contributorForNode)),
|
|
672
|
+
traces: matching.slice(0, TRACE_EVIDENCE_LIMIT).map((entry) => traceOccurrence(entry.trace, entry.nodes.length, {
|
|
673
|
+
timestamp: entry.nodes[0].span.startTime,
|
|
674
|
+
durationMs: entry.nodes.reduce((sum, node) => sum + node.span.durationMs, 0),
|
|
675
|
+
contributor: entry.nodes[0].span.nextSpanName ?? entry.nodes[0].span.name,
|
|
676
|
+
})),
|
|
266
677
|
measurements: [
|
|
267
|
-
{ label: 'matching calls', value:
|
|
678
|
+
{ label: 'matching calls', value: totalCalls },
|
|
679
|
+
{ label: 'matching traces', value: matching.length },
|
|
268
680
|
{ label: 'threshold', value: context.config.repeatedHttpMin },
|
|
269
|
-
{ label: 'method', value: group.method },
|
|
681
|
+
{ label: 'fetch method', value: group.method },
|
|
270
682
|
{ label: 'endpoint', value: group.endpoint },
|
|
683
|
+
{ label: 'cache statuses', value: cacheSummary || 'unknown' },
|
|
684
|
+
{ label: 'loopback calls', value: loopbackCalls },
|
|
271
685
|
],
|
|
272
686
|
},
|
|
273
687
|
verification: [
|
|
@@ -277,6 +691,7 @@ function repeatedHttpFindings(context) {
|
|
|
277
691
|
],
|
|
278
692
|
limitations: [
|
|
279
693
|
SAMPLE_LIMITATION,
|
|
694
|
+
REQUEST_ROOT_LIMITATION,
|
|
280
695
|
'Only fetch spans that StackNext receives can be counted; missing instrumentation hides calls.',
|
|
281
696
|
'Query strings and credentials are removed, so different query values share one endpoint.',
|
|
282
697
|
'Repeated calls may be intentional, for example retries or independent resources.',
|
|
@@ -285,86 +700,142 @@ function repeatedHttpFindings(context) {
|
|
|
285
700
|
}
|
|
286
701
|
return findings;
|
|
287
702
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
703
|
+
function errorFindings(context) {
|
|
704
|
+
const rule = ruleSummary('error-fingerprint');
|
|
705
|
+
const entries = [];
|
|
706
|
+
for (const error of context.snapshot.errors) {
|
|
707
|
+
if (isControlFlowError(error))
|
|
708
|
+
continue;
|
|
709
|
+
const traceId = error.resolvedTraceId ?? error.traceId ?? null;
|
|
710
|
+
const trace = traceId ? context.index.traces.get(traceId) ?? null : null;
|
|
711
|
+
const route = canonicalRoute(error.route ?? error.requestPath) ?? trace?.route ?? null;
|
|
712
|
+
const method = error.method ?? (trace ? methodFromRequest(trace) : null) ?? null;
|
|
713
|
+
entries.push({
|
|
714
|
+
error,
|
|
715
|
+
trace,
|
|
716
|
+
deepest: trace?.deepestErrorSpan ?? null,
|
|
717
|
+
route,
|
|
718
|
+
method,
|
|
719
|
+
errorFingerprint: diagnosticErrorFingerprint(error),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (entries.length === 0)
|
|
290
723
|
return [];
|
|
291
724
|
const groups = new Map();
|
|
292
|
-
for (const
|
|
293
|
-
const key =
|
|
725
|
+
for (const entry of entries) {
|
|
726
|
+
const key = context.config.errorGrouping
|
|
727
|
+
? `${entry.route ?? ''}\u0000${entry.errorFingerprint}`
|
|
728
|
+
: entry.error.id;
|
|
294
729
|
const existing = groups.get(key);
|
|
295
730
|
if (existing)
|
|
296
|
-
existing.push(
|
|
731
|
+
existing.push(entry);
|
|
297
732
|
else
|
|
298
|
-
groups.set(key, [
|
|
733
|
+
groups.set(key, [entry]);
|
|
299
734
|
}
|
|
300
735
|
const findings = [];
|
|
301
|
-
for (const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
736
|
+
for (const groupEntries of groups.values()) {
|
|
737
|
+
const sorted = [...groupEntries].sort((a, b) => a.error.timestamp - b.error.timestamp || a.error.id.localeCompare(b.error.id));
|
|
738
|
+
const latest = sorted[sorted.length - 1];
|
|
739
|
+
const route = commonString(sorted.map((entry) => entry.route));
|
|
740
|
+
const method = commonString(sorted.map((entry) => entry.method));
|
|
741
|
+
const traceIds = limitTraceIds(sorted.map((entry) => entry.error.resolvedTraceId ?? entry.error.traceId ?? null));
|
|
742
|
+
const affectedTraceCount = uniqueStrings(sorted.map((entry) => entry.error.resolvedTraceId ?? entry.error.traceId ?? null)).length;
|
|
743
|
+
const deepestSpans = sorted
|
|
744
|
+
.map((entry) => entry.deepest)
|
|
745
|
+
.filter((node) => node !== null)
|
|
746
|
+
.slice(0, 5);
|
|
747
|
+
const traceForContributors = latest.trace;
|
|
748
|
+
const operation = errorOperationCategory(latest.error);
|
|
749
|
+
const safeName = shortText(stripUrlQueryInText(redactText(latest.error.name, 200)), 120);
|
|
750
|
+
const groupFingerprint = diagnosticErrorFingerprint(latest.error);
|
|
751
|
+
const findingFingerprint = context.config.errorGrouping
|
|
752
|
+
? fingerprint([rule.id, route ?? '', groupFingerprint])
|
|
753
|
+
: fingerprint([rule.id, latest.error.id]);
|
|
754
|
+
const repeated = sorted.length >= context.config.errorGroupMin;
|
|
755
|
+
const routeLabel = route ?? 'an unknown route';
|
|
756
|
+
const firstSeen = Math.min(...sorted.map((entry) => entry.error.timestamp));
|
|
757
|
+
const lastSeen = latest.error.timestamp;
|
|
758
|
+
const statusCode = traceForContributors?.statusCode ?? null;
|
|
759
|
+
const revalidateReason = sanitizedText(stringAttribute(latest.error.attributes, 'next.revalidate_reason', 'revalidateReason'), 100);
|
|
760
|
+
const digest = sanitizedText(latest.error.digest, 1_000);
|
|
761
|
+
const nextContext = [
|
|
762
|
+
latest.error.routeType ? `routeType=${latest.error.routeType}` : null,
|
|
763
|
+
latest.error.renderType ? `renderType=${latest.error.renderType}` : null,
|
|
764
|
+
latest.error.renderSource ? `renderSource=${latest.error.renderSource}` : null,
|
|
765
|
+
latest.error.routerKind ? `routerKind=${latest.error.routerKind}` : null,
|
|
766
|
+
revalidateReason ? `revalidateReason=${revalidateReason}` : null,
|
|
767
|
+
digest ? `digest=${digest}` : null,
|
|
768
|
+
]
|
|
769
|
+
.filter((entry) => Boolean(entry))
|
|
770
|
+
.join(', ');
|
|
771
|
+
const deepestText = latest.deepest
|
|
772
|
+
? ` The deepest error span is ${safeSpanName(latest.deepest.span.nextSpanName ?? latest.deepest.span.name)} (${roundMs(latest.deepest.selfMs)} ms self time, ${latest.deepest.phase}).`
|
|
773
|
+
: ' No error span was found in the correlated trace.';
|
|
774
|
+
const traceEvidenceById = new Map();
|
|
775
|
+
for (const entry of sorted) {
|
|
776
|
+
const traceId = entry.error.resolvedTraceId ?? entry.error.traceId ?? null;
|
|
331
777
|
if (!traceId)
|
|
332
778
|
continue;
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
|
|
779
|
+
const existing = traceEvidenceById.get(traceId);
|
|
780
|
+
if (existing)
|
|
781
|
+
existing.count += 1;
|
|
782
|
+
if (!existing || entry.error.timestamp > existing.timestamp) {
|
|
783
|
+
traceEvidenceById.set(traceId, {
|
|
784
|
+
trace: entry.trace,
|
|
785
|
+
timestamp: entry.error.timestamp,
|
|
786
|
+
count: existing?.count ?? 1,
|
|
787
|
+
});
|
|
336
788
|
}
|
|
337
789
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
790
|
+
const errorTraces = [...traceEvidenceById.entries()]
|
|
791
|
+
.sort((a, b) => a[1].timestamp - b[1].timestamp || a[0].localeCompare(b[0]))
|
|
792
|
+
.slice(-TRACE_EVIDENCE_LIMIT)
|
|
793
|
+
.map(([traceId, entry]) => entry.trace
|
|
794
|
+
? traceOccurrence(entry.trace, entry.count, { timestamp: entry.timestamp })
|
|
795
|
+
: { traceId, timestamp: entry.timestamp, occurrences: entry.count });
|
|
341
796
|
findings.push({
|
|
342
|
-
id: diagnosticFindingId(rule.id,
|
|
797
|
+
id: diagnosticFindingId(rule.id, findingFingerprint),
|
|
343
798
|
ruleId: rule.id,
|
|
344
799
|
ruleVersion: RULE_VERSION,
|
|
345
800
|
category: rule.category,
|
|
346
801
|
severity: rule.severity,
|
|
347
802
|
status: 'observed',
|
|
348
|
-
title:
|
|
349
|
-
|
|
350
|
-
|
|
803
|
+
title: repeated
|
|
804
|
+
? `Repeated request error: ${safeName}`
|
|
805
|
+
: `Request error: ${safeName}`,
|
|
806
|
+
summary: repeated
|
|
807
|
+
? `${sorted.length} occurrences across ${affectedTraceCount} trace${affectedTraceCount === 1 ? '' : 's'} on ${routeLabel} share a route-aware error signature.`
|
|
808
|
+
: `${sorted.length} occurrence${sorted.length === 1 ? '' : 's'} on ${routeLabel} at ${formatTimestamp(latest.error.timestamp)}.`,
|
|
809
|
+
detail: `The latest occurrence is ${latest.error.id} at ${formatTimestamp(latest.error.timestamp)}. Grouping uses the normalized error name, message, stack shape and operation category (${operation}); grouping is heuristic and does not establish a root cause.${nextContext ? ` Next.js context: ${nextContext}.` : ''}${deepestText}${statusCode === null ? '' : ` The correlated request returned ${statusCode}.`}`,
|
|
351
810
|
route,
|
|
352
811
|
method,
|
|
353
812
|
traceIds,
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
813
|
+
affectedTraceCount,
|
|
814
|
+
firstSeen,
|
|
815
|
+
lastSeen,
|
|
816
|
+
fingerprint: findingFingerprint,
|
|
357
817
|
evidence: {
|
|
358
818
|
traceIds,
|
|
359
|
-
spans:
|
|
819
|
+
spans: deepestSpans.map(spanEvidenceForNode),
|
|
360
820
|
queries: [],
|
|
361
|
-
errors: sorted
|
|
821
|
+
errors: sorted
|
|
822
|
+
.slice(-ERROR_EVIDENCE_LIMIT)
|
|
823
|
+
.reverse()
|
|
824
|
+
.map((entry) => errorEvidence(entry.error, groupFingerprint, entry.trace, entry.deepest)),
|
|
825
|
+
contributors: traceForContributors
|
|
826
|
+
? boundedContributors(contributorsForTrace(traceForContributors, 5).map(contributorForNode))
|
|
827
|
+
: [],
|
|
828
|
+
traces: errorTraces,
|
|
362
829
|
measurements: [
|
|
363
|
-
{ label: 'occurrences', value:
|
|
364
|
-
{ label: '
|
|
365
|
-
{ label: '
|
|
830
|
+
{ label: 'occurrences', value: sorted.length },
|
|
831
|
+
{ label: 'traces', value: affectedTraceCount },
|
|
832
|
+
{ label: 'repeated threshold', value: context.config.errorGroupMin },
|
|
833
|
+
{ label: 'grouped', value: context.config.errorGrouping },
|
|
366
834
|
{ label: 'operation category', value: operation },
|
|
367
|
-
{ label: 'latest error id', value: latest.id },
|
|
835
|
+
{ label: 'latest error id', value: latest.error.id },
|
|
836
|
+
{ label: 'route type', value: latest.error.routeType ?? null },
|
|
837
|
+
{ label: 'render type', value: latest.error.renderType ?? null },
|
|
838
|
+
{ label: 'status code', value: statusCode },
|
|
368
839
|
],
|
|
369
840
|
},
|
|
370
841
|
verification: [
|
|
@@ -376,18 +847,141 @@ function errorGroupFindings(context) {
|
|
|
376
847
|
SAMPLE_LIMITATION,
|
|
377
848
|
'Grouping is heuristic; different root causes can share a normalized message or stack shape.',
|
|
378
849
|
'Redaction and truncation can reduce the specificity of error signatures.',
|
|
379
|
-
'
|
|
850
|
+
'Next.js not-found and redirect control-flow errors are excluded from this rule.',
|
|
851
|
+
],
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return findings;
|
|
855
|
+
}
|
|
856
|
+
function serverErrorFindings(context) {
|
|
857
|
+
const rule = ruleSummary('server-error-response');
|
|
858
|
+
const errorsByTrace = new Map();
|
|
859
|
+
for (const error of context.snapshot.errors) {
|
|
860
|
+
if (isControlFlowError(error))
|
|
861
|
+
continue;
|
|
862
|
+
const traceId = error.resolvedTraceId ?? error.traceId ?? null;
|
|
863
|
+
if (!traceId)
|
|
864
|
+
continue;
|
|
865
|
+
const existing = errorsByTrace.get(traceId);
|
|
866
|
+
if (existing)
|
|
867
|
+
existing.push(error);
|
|
868
|
+
else
|
|
869
|
+
errorsByTrace.set(traceId, [error]);
|
|
870
|
+
}
|
|
871
|
+
const groups = new Map();
|
|
872
|
+
for (const group of routeGroups(context.index.requestTraces)) {
|
|
873
|
+
for (const trace of group.traces) {
|
|
874
|
+
const statusCode = trace.statusCode;
|
|
875
|
+
if (statusCode === null || statusCode < 500 || statusCode > 599)
|
|
876
|
+
continue;
|
|
877
|
+
if ((errorsByTrace.get(trace.traceId)?.length ?? 0) > 0)
|
|
878
|
+
continue;
|
|
879
|
+
const key = `${group.key}\u0000${statusCode}`;
|
|
880
|
+
const existing = groups.get(key);
|
|
881
|
+
if (existing)
|
|
882
|
+
existing.traces.push(trace);
|
|
883
|
+
else {
|
|
884
|
+
groups.set(key, {
|
|
885
|
+
key,
|
|
886
|
+
routeKey: group.key,
|
|
887
|
+
route: group.route,
|
|
888
|
+
method: group.method,
|
|
889
|
+
statusCode,
|
|
890
|
+
traces: [trace],
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const findings = [];
|
|
896
|
+
for (const group of groups.values()) {
|
|
897
|
+
const routeTraces = context.index.requestTraces.filter((trace) => (trace.routeKey ?? methodFromRequest(trace) ?? 'unknown-route') === group.routeKey);
|
|
898
|
+
group.traces.sort((a, b) => a.startTime - b.startTime || a.traceId.localeCompare(b.traceId));
|
|
899
|
+
const representative = group.traces[group.traces.length - 1];
|
|
900
|
+
const contributors = contributorsForTrace(representative, 5);
|
|
901
|
+
const representativeSpans = [
|
|
902
|
+
...(representative.root ? [representative.root] : []),
|
|
903
|
+
...contributors,
|
|
904
|
+
]
|
|
905
|
+
.filter((node, index, list) => list.findIndex((entry) => entry.span.spanId === node.span.spanId) === index)
|
|
906
|
+
.slice(0, SPAN_EVIDENCE_LIMIT);
|
|
907
|
+
const findingFingerprint = fingerprint([
|
|
908
|
+
rule.id,
|
|
909
|
+
group.method ?? '',
|
|
910
|
+
group.route ?? '',
|
|
911
|
+
String(group.statusCode),
|
|
912
|
+
]);
|
|
913
|
+
const routeLabel = group.routeKey;
|
|
914
|
+
findings.push({
|
|
915
|
+
id: diagnosticFindingId(rule.id, findingFingerprint, group.routeKey),
|
|
916
|
+
ruleId: rule.id,
|
|
917
|
+
ruleVersion: RULE_VERSION,
|
|
918
|
+
category: rule.category,
|
|
919
|
+
severity: rule.severity,
|
|
920
|
+
status: 'observed',
|
|
921
|
+
title: `Server error responses: ${routeLabel} returned ${group.statusCode}`,
|
|
922
|
+
summary: `${group.traces.length} of ${routeTraces.length} ${routeLabel} requests returned ${group.statusCode} without a correlated error record.`,
|
|
923
|
+
detail: `The request root reported HTTP ${group.statusCode}, but no non-control-flow error record was correlated with ${group.traces.length === 1 ? 'the trace' : 'these traces'}. The status is observed; the cause is unknown. The application may have returned the status without throwing, or the error hook may not have run.`,
|
|
924
|
+
route: group.route,
|
|
925
|
+
method: group.method,
|
|
926
|
+
traceIds: limitTraceIds(group.traces.map((trace) => trace.traceId)),
|
|
927
|
+
affectedTraceCount: group.traces.length,
|
|
928
|
+
firstSeen: Math.min(...group.traces.map((trace) => trace.request?.timestamp ?? trace.startTime)),
|
|
929
|
+
lastSeen: Math.max(...group.traces.map((trace) => trace.request?.timestamp ?? trace.startTime)),
|
|
930
|
+
fingerprint: findingFingerprint,
|
|
931
|
+
evidence: {
|
|
932
|
+
traceIds: limitTraceIds(group.traces.map((trace) => trace.traceId)),
|
|
933
|
+
spans: representativeSpans.map(spanEvidenceForNode),
|
|
934
|
+
queries: [],
|
|
935
|
+
errors: [],
|
|
936
|
+
contributors: boundedContributors(contributors.map(contributorForNode)),
|
|
937
|
+
traces: group.traces.slice(-TRACE_EVIDENCE_LIMIT).map((trace) => traceOccurrence(trace, 1, {
|
|
938
|
+
durationMs: trace.requestDurationMs,
|
|
939
|
+
contributor: contributorsForTrace(trace, 1)[0]?.span.nextSpanName ??
|
|
940
|
+
contributorsForTrace(trace, 1)[0]?.span.name ??
|
|
941
|
+
null,
|
|
942
|
+
})),
|
|
943
|
+
measurements: [
|
|
944
|
+
{ label: 'server error responses', value: group.traces.length },
|
|
945
|
+
{ label: 'requests on route', value: routeTraces.length },
|
|
946
|
+
{
|
|
947
|
+
label: 'response rate',
|
|
948
|
+
value: Math.round((group.traces.length / Math.max(1, routeTraces.length)) * 1000) / 10,
|
|
949
|
+
unit: '%',
|
|
950
|
+
},
|
|
951
|
+
{ label: 'status code', value: group.statusCode },
|
|
952
|
+
{ label: 'correlated error records', value: 0 },
|
|
953
|
+
],
|
|
954
|
+
},
|
|
955
|
+
verification: [
|
|
956
|
+
'Inspect the request trace and response status before changing code.',
|
|
957
|
+
'Check whether the route intentionally returns this status without throwing.',
|
|
958
|
+
'Verify the error hook runs for the same route in a fresh reproduction.',
|
|
959
|
+
],
|
|
960
|
+
limitations: [
|
|
961
|
+
SAMPLE_LIMITATION,
|
|
962
|
+
'Only the response status is observed; StackNext does not capture response bodies or headers.',
|
|
963
|
+
'A missing error record can mean the app did not throw, the error hook did not run, or the record aged out.',
|
|
964
|
+
'Request-level rules only analyze traces with a captured request root.',
|
|
380
965
|
],
|
|
381
966
|
});
|
|
382
967
|
}
|
|
383
968
|
return findings;
|
|
384
969
|
}
|
|
385
970
|
export function evaluateDiagnosticRules(context) {
|
|
971
|
+
const index = context.traceIndex ?? buildTraceIndex(context.snapshot);
|
|
972
|
+
const ruleContext = {
|
|
973
|
+
snapshot: context.snapshot,
|
|
974
|
+
config: context.config,
|
|
975
|
+
slowRequestMs: context.slowRequestMs,
|
|
976
|
+
index,
|
|
977
|
+
};
|
|
386
978
|
const findings = [
|
|
387
|
-
...nPlusOneFindings(
|
|
388
|
-
...
|
|
389
|
-
...
|
|
390
|
-
...
|
|
979
|
+
...nPlusOneFindings(ruleContext),
|
|
980
|
+
...slowRequestFindings(ruleContext),
|
|
981
|
+
...slowSpanFindings(ruleContext),
|
|
982
|
+
...repeatedHttpFindings(ruleContext),
|
|
983
|
+
...errorFindings(ruleContext),
|
|
984
|
+
...serverErrorFindings(ruleContext),
|
|
391
985
|
];
|
|
392
986
|
const severityRank = {
|
|
393
987
|
error: 0,
|