@stacknext/diagnostics 0.6.1 → 0.7.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/dist/rules.js CHANGED
@@ -1,20 +1,23 @@
1
- import { DIAGNOSTIC_RULES, fingerprint, redactText, stripUrlQueryInText, } from '@stacknext/core';
2
- import { diagnosticSpanCategory, safeRoute, safeSpanName, spanEvidence, spanHttpEndpoint, spanHttpMethod, spanThresholdMs, } from './classify.js';
3
- import { diagnosticErrorFingerprint, diagnosticFindingId, errorOperationCategory, sqlFingerprint, sqlPreview, } from './fingerprints.js';
4
- const RULE_VERSION = 1;
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 = safeRoute(error.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 routeForTrace(context, traceId) {
55
- return safeRoute(context.requestByTrace.get(traceId)?.route);
56
- }
57
- function methodForTrace(context, traceId) {
58
- return context.requestByTrace.get(traceId)?.method ?? null;
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 = `${traceId}\u0000${query.databaseSystem.toLowerCase()}\u0000${shape}`;
191
+ const key = `${routeKey}\u0000${query.databaseSystem.toLowerCase()}\u0000${shape}`;
70
192
  const existing = groups.get(key);
71
- if (existing)
72
- existing.queries.push(query);
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
- traceId,
203
+ key,
204
+ route,
205
+ routeKey,
76
206
  databaseSystem: query.databaseSystem,
77
207
  shape,
78
- queries: [query],
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
- if (group.queries.length < context.config.repeatedQueryMin)
85
- continue;
86
- const queries = [...group.queries].sort((a, b) => a.timestamp - b.timestamp);
87
- const first = queries[0];
88
- const last = queries[queries.length - 1];
89
- if (!first || !last)
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
- const rule = ruleSummary('n-plus-one-queries');
92
- const route = safeRoute(first.route) ?? routeForTrace(context, group.traceId);
93
- const method = methodForTrace(context, group.traceId);
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
- 'n-plus-one-queries',
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.traceId),
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: ${queries.length} similar ${group.databaseSystem} queries`,
108
- summary: `${queries.length} queries with the same normalized SQL shape ran in one trace: ${preview}`,
109
- detail: `The same normalized SQL shape ran ${queries.length} times in trace ${group.traceId}${route ? ` (${route})` : ''}. This is consistent with a per-item query loop, but StackNext cannot confirm an N+1 bug without reviewing the calling code.`,
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: [group.traceId],
113
- firstSeen: first.timestamp,
114
- lastSeen: last.timestamp,
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: [group.traceId],
270
+ traceIds: limitTraceIds(matchingTraces.map((entry) => entry.trace.traceId)),
118
271
  spans: [],
119
- queries: queries.slice(0, 10).map(queryEvidence),
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: queries.length },
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 slowSpanFindings(context) {
316
+ function slowRequestFindings(context) {
317
+ const rule = ruleSummary('slow-request');
144
318
  const findings = [];
145
- for (const span of context.snapshot.spans) {
146
- if (!Number.isFinite(span.durationMs) || span.durationMs <= 0)
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
- const category = diagnosticSpanCategory(span);
149
- const threshold = spanThresholdMs(category, context.config);
150
- if (span.durationMs < threshold)
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
- const rule = ruleSummary('slow-span');
153
- const request = context.requestByTrace.get(span.traceId);
154
- const route = safeRoute(span.route) ?? routeForTrace(context, span.traceId);
155
- const method = span.method ?? request?.method ?? null;
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
- 'slow-span',
158
- category,
159
- span.name,
160
- span.nextSpanType ?? '',
483
+ rule.id,
484
+ group.category,
485
+ group.label,
486
+ group.nextSpanType ?? '',
161
487
  ]);
162
- const duration = roundMs(span.durationMs);
163
- const displayName = safeSpanName(span.name);
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, `${span.traceId}:${span.spanId}`),
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} span: ${shortText(displayName, 120)}`,
177
- summary: `${duration} ms exceeded the ${threshold} ms ${category} threshold in trace ${span.traceId}.`,
178
- detail: `Span ${displayName} (${span.spanId}) took ${duration} ms, above the configured ${threshold} ms threshold for ${category} spans. Span duration includes child work, so it must not be summed with overlapping spans.`,
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: [span.traceId],
182
- firstSeen: span.startTime,
183
- lastSeen: span.startTime,
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: [span.traceId],
187
- spans: [evidenceSpan],
188
- queries: relatedQueries,
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: 'duration', value: duration, unit: 'ms' },
192
- { label: 'threshold', value: threshold, unit: 'ms' },
193
- { label: 'span category', value: category },
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 duration was measured rather than estimated.',
198
- 'Check whether child spans, queueing or cold starts explain the duration.',
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
- 'Span duration includes child work and is not additive across overlapping spans.',
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 span of context.snapshot.spans) {
214
- if (diagnosticSpanCategory(span) !== 'http')
215
- continue;
216
- const endpoint = spanHttpEndpoint(span);
217
- if (!endpoint)
218
- continue;
219
- const method = spanHttpMethod(span) ?? 'UNKNOWN';
220
- const key = `${span.traceId}\u0000${method}\u0000${endpoint}`;
221
- const existing = groups.get(key);
222
- if (existing)
223
- existing.spans.push(span);
224
- else
225
- groups.set(key, { traceId: span.traceId, method, endpoint, spans: [span] });
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
- if (group.spans.length < context.config.repeatedHttpMin)
230
- continue;
231
- const spans = [...group.spans].sort((a, b) => a.startTime - b.startTime);
232
- const first = spans[0];
233
- const last = spans[spans.length - 1];
234
- if (!first || !last)
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
- const rule = ruleSummary('repeated-external-http');
237
- const request = context.requestByTrace.get(group.traceId);
238
- const route = routeForTrace(context, group.traceId);
239
- const method = request?.method ?? null;
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
- 'repeated-external-http',
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.traceId),
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: `${spans.length} outbound ${group.method} calls to ${group.endpoint} ran in trace ${group.traceId}.`,
254
- detail: `The same outbound endpoint was called ${spans.length} times in one trace. This may indicate missing caching or request deduplication, but repeated calls can be intentional.`,
255
- route,
256
- method,
257
- traceIds: [group.traceId],
258
- firstSeen: first.startTime,
259
- lastSeen: last.startTime,
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: [group.traceId],
263
- spans: spans.slice(0, 10).map((span) => spanEvidence(span, 'http')),
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: spans.length },
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 errorGroupFindings(context) {
289
- if (!context.config.errorGrouping)
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 error of context.snapshot.errors) {
293
- const key = diagnosticErrorFingerprint(error);
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(error);
731
+ existing.push(entry);
297
732
  else
298
- groups.set(key, [error]);
733
+ groups.set(key, [entry]);
299
734
  }
300
735
  const findings = [];
301
- for (const [groupFingerprint, errors] of groups.entries()) {
302
- if (errors.length < context.config.errorGroupMin)
303
- continue;
304
- const sorted = [...errors].sort((a, b) => b.timestamp - a.timestamp);
305
- const latest = sorted[0];
306
- if (!latest)
307
- continue;
308
- const rule = ruleSummary('error-fingerprint');
309
- const routes = [
310
- ...new Set(sorted
311
- .map((error) => error.route ?? error.requestPath ?? null)
312
- .filter((route) => Boolean(route))
313
- .map((route) => safeRoute(route))
314
- .filter((route) => Boolean(route))),
315
- ];
316
- const methods = [
317
- ...new Set(sorted
318
- .map((error) => error.method ?? null)
319
- .filter((method) => Boolean(method))),
320
- ];
321
- const route = routes.length === 1 ? routes[0] ?? null : null;
322
- const method = methods.length === 1 ? methods[0] ?? null : null;
323
- const traceIds = [
324
- ...new Set(sorted
325
- .map((error) => error.resolvedTraceId ?? error.traceId ?? null)
326
- .filter((traceId) => Boolean(traceId))),
327
- ];
328
- const traceLastSeen = new Map();
329
- for (const error of sorted) {
330
- const traceId = error.resolvedTraceId ?? error.traceId ?? null;
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 previous = traceLastSeen.get(traceId);
334
- if (previous === undefined || error.timestamp > previous) {
335
- traceLastSeen.set(traceId, error.timestamp);
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
- traceIds.sort((a, b) => (traceLastSeen.get(b) ?? 0) - (traceLastSeen.get(a) ?? 0) || a.localeCompare(b));
339
- const operation = errorOperationCategory(latest);
340
- const safeName = shortText(stripUrlQueryInText(redactText(latest.name, 200)), 120);
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, groupFingerprint),
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: `Repeated error: ${safeName}`,
349
- summary: `${errors.length} occurrences across ${traceIds.length} trace${traceIds.length === 1 ? '' : 's'} share a route-independent error signature.`,
350
- detail: `The latest occurrence is ${latest.id} at ${formatTimestamp(latest.timestamp)}. Grouping uses the normalized error name, message, stack shape and operation category (${operation}), not the route. Grouping is heuristic and does not establish a root cause.`,
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
- firstSeen: Math.min(...errors.map((error) => error.timestamp)),
355
- lastSeen: latest.timestamp,
356
- fingerprint: groupFingerprint,
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.slice(0, 10).map((error) => errorEvidence(error, groupFingerprint)),
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: errors.length },
364
- { label: 'threshold', value: context.config.errorGroupMin },
365
- { label: 'traces', value: traceIds.length },
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
- 'Routes and methods are intentionally excluded from the group fingerprint.',
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(context),
388
- ...slowSpanFindings(context),
389
- ...repeatedHttpFindings(context),
390
- ...errorGroupFindings(context),
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,