scanwarp 0.2.0 → 0.3.1
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/commands/dev-mcp.js +8 -0
- package/dist/commands/dev.js +1054 -0
- package/dist/commands/init.js +27 -5
- package/dist/commands/mcp.js +380 -0
- package/dist/commands/server.js +102 -0
- package/dist/dev/analysis-engine.js +145 -0
- package/dist/dev/analyzers/schema-drift.js +230 -0
- package/dist/dev/analyzers.js +230 -0
- package/dist/dev/mcp-dev.js +290 -0
- package/dist/index.js +141 -3
- package/dist/integrations/instrument.js +236 -0
- package/dist/mcp/api.js +86 -0
- package/dist/mcp/index.js +403 -0
- package/dist/mcp/tools.js +487 -0
- package/package.json +60 -57
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Analysis engine for scanwarp dev.
|
|
4
|
+
*
|
|
5
|
+
* Runs analyzers on incoming traces, deduplicates results so each unique
|
|
6
|
+
* issue is shown only once, and prints "resolved" when an issue goes away.
|
|
7
|
+
*/
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.AnalysisEngine = void 0;
|
|
13
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
14
|
+
const analyzers_js_1 = require("./analyzers.js");
|
|
15
|
+
class AnalysisEngine {
|
|
16
|
+
analyzers;
|
|
17
|
+
issues = new Map();
|
|
18
|
+
/** Track which issues fired in the most recent analysis pass per trace */
|
|
19
|
+
lastTraceIssueKeys = new Set();
|
|
20
|
+
constructor(analyzers) {
|
|
21
|
+
this.analyzers = analyzers ?? analyzers_js_1.defaultAnalyzers;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Analyze a complete trace (all spans with the same trace_id).
|
|
25
|
+
* Returns new/changed results that should be printed.
|
|
26
|
+
*/
|
|
27
|
+
analyzeTrace(spans) {
|
|
28
|
+
if (spans.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const currentKeys = new Set();
|
|
32
|
+
// Run all analyzers
|
|
33
|
+
for (const analyzer of this.analyzers) {
|
|
34
|
+
const results = analyzer.analyze(spans);
|
|
35
|
+
for (const result of results) {
|
|
36
|
+
const key = this.makeKey(result);
|
|
37
|
+
currentKeys.add(key);
|
|
38
|
+
const existing = this.issues.get(key);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.hitCount++;
|
|
41
|
+
existing.lastSeen = now;
|
|
42
|
+
// Don't re-print — already shown
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// New issue — track and print
|
|
46
|
+
const tracked = {
|
|
47
|
+
result,
|
|
48
|
+
key,
|
|
49
|
+
hitCount: 1,
|
|
50
|
+
firstSeen: now,
|
|
51
|
+
lastSeen: now,
|
|
52
|
+
printed: true,
|
|
53
|
+
resolved: false,
|
|
54
|
+
};
|
|
55
|
+
this.issues.set(key, tracked);
|
|
56
|
+
this.printResult(result);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Check for resolved issues — issues that were active in the previous trace
|
|
61
|
+
// from the same route but are no longer firing
|
|
62
|
+
for (const [key, issue] of this.issues) {
|
|
63
|
+
if (this.lastTraceIssueKeys.has(key) &&
|
|
64
|
+
!currentKeys.has(key) &&
|
|
65
|
+
!issue.resolved) {
|
|
66
|
+
issue.resolved = true;
|
|
67
|
+
this.printResolved(issue);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.lastTraceIssueKeys = currentKeys;
|
|
71
|
+
}
|
|
72
|
+
/** Generate a dedup key from rule + core message content */
|
|
73
|
+
makeKey(result) {
|
|
74
|
+
// Strip numbers/counts from the message so "executed 23 times" and "executed 24 times"
|
|
75
|
+
// don't create separate entries
|
|
76
|
+
const normalizedMessage = result.message
|
|
77
|
+
.replace(/\d+\s*times/g, 'N times')
|
|
78
|
+
.replace(/\d+ms/g, 'Nms');
|
|
79
|
+
return `${result.rule}:${normalizedMessage}`;
|
|
80
|
+
}
|
|
81
|
+
/** Print a new analysis result inline */
|
|
82
|
+
printResult(result) {
|
|
83
|
+
const icon = result.severity === 'error'
|
|
84
|
+
? chalk_1.default.red('!')
|
|
85
|
+
: result.severity === 'warning'
|
|
86
|
+
? chalk_1.default.yellow('!')
|
|
87
|
+
: chalk_1.default.blue('i');
|
|
88
|
+
const severityColor = result.severity === 'error'
|
|
89
|
+
? chalk_1.default.red
|
|
90
|
+
: result.severity === 'warning'
|
|
91
|
+
? chalk_1.default.yellow
|
|
92
|
+
: chalk_1.default.blue;
|
|
93
|
+
const tag = severityColor(`[${result.rule}]`);
|
|
94
|
+
console.log(`\n ${icon} ${tag} ${result.message}`);
|
|
95
|
+
if (result.suggestion) {
|
|
96
|
+
console.log(chalk_1.default.gray(` → ${result.suggestion}`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Print when an issue is resolved */
|
|
100
|
+
printResolved(issue) {
|
|
101
|
+
const tag = chalk_1.default.green(`[${issue.result.rule}]`);
|
|
102
|
+
// Truncate the message for the resolved line
|
|
103
|
+
const shortMsg = issue.result.message.length > 60
|
|
104
|
+
? issue.result.message.substring(0, 60) + '...'
|
|
105
|
+
: issue.result.message;
|
|
106
|
+
console.log(`\n ${chalk_1.default.green('✓')} ${tag} ${chalk_1.default.green('Resolved:')} ${chalk_1.default.gray(shortMsg)}`);
|
|
107
|
+
}
|
|
108
|
+
/** Get count of currently active (unresolved) issues */
|
|
109
|
+
get activeIssueCount() {
|
|
110
|
+
let count = 0;
|
|
111
|
+
for (const issue of this.issues.values()) {
|
|
112
|
+
if (!issue.resolved)
|
|
113
|
+
count++;
|
|
114
|
+
}
|
|
115
|
+
return count;
|
|
116
|
+
}
|
|
117
|
+
/** Get all active (unresolved) issues with full detail */
|
|
118
|
+
getActiveIssues() {
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const issue of this.issues.values()) {
|
|
121
|
+
if (!issue.resolved) {
|
|
122
|
+
results.push(issue.result);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
/** Get all tracked issues (for session summary) */
|
|
128
|
+
getSummary() {
|
|
129
|
+
let active = 0;
|
|
130
|
+
let resolved = 0;
|
|
131
|
+
const byRule = new Map();
|
|
132
|
+
for (const issue of this.issues.values()) {
|
|
133
|
+
if (issue.resolved) {
|
|
134
|
+
resolved++;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
active++;
|
|
138
|
+
}
|
|
139
|
+
const count = byRule.get(issue.result.rule) || 0;
|
|
140
|
+
byRule.set(issue.result.rule, count + 1);
|
|
141
|
+
}
|
|
142
|
+
return { total: this.issues.size, active, resolved, byRule };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.AnalysisEngine = AnalysisEngine;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Schema drift detection for API routes.
|
|
4
|
+
*
|
|
5
|
+
* Infers a structural schema from JSON responses, stores baselines per
|
|
6
|
+
* route+method, and detects drift: removed fields, type changes, new fields,
|
|
7
|
+
* and null→non-null (or vice-versa) transitions.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.SchemaTracker = void 0;
|
|
14
|
+
exports.inferSchema = inferSchema;
|
|
15
|
+
exports.compareSchemas = compareSchemas;
|
|
16
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
17
|
+
// ─── Schema inference ───
|
|
18
|
+
function inferSchema(value) {
|
|
19
|
+
if (value === null || value === undefined) {
|
|
20
|
+
return { kind: 'null' };
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'boolean')
|
|
23
|
+
return { kind: 'boolean' };
|
|
24
|
+
if (typeof value === 'number')
|
|
25
|
+
return { kind: 'number' };
|
|
26
|
+
if (typeof value === 'string')
|
|
27
|
+
return { kind: 'string' };
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
if (value.length === 0) {
|
|
30
|
+
return { kind: 'array', items: null };
|
|
31
|
+
}
|
|
32
|
+
// Infer from first element (representative sample)
|
|
33
|
+
return { kind: 'array', items: inferSchema(value[0]) };
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'object') {
|
|
36
|
+
const fields = new Map();
|
|
37
|
+
for (const [key, val] of Object.entries(value)) {
|
|
38
|
+
fields.set(key, inferSchema(val));
|
|
39
|
+
}
|
|
40
|
+
return { kind: 'object', fields };
|
|
41
|
+
}
|
|
42
|
+
return { kind: 'string' }; // fallback
|
|
43
|
+
}
|
|
44
|
+
// ─── Schema comparison ───
|
|
45
|
+
function compareSchemas(baseline, current, path = '$') {
|
|
46
|
+
const diffs = [];
|
|
47
|
+
// Handle nullable wrapper
|
|
48
|
+
const baseKind = baseline.kind === 'nullable' ? baseline.inner.kind : baseline.kind;
|
|
49
|
+
const currKind = current.kind === 'nullable' ? current.inner.kind : current.kind;
|
|
50
|
+
const baseNode = baseline.kind === 'nullable' ? baseline.inner : baseline;
|
|
51
|
+
const currNode = current.kind === 'nullable' ? current.inner : current;
|
|
52
|
+
// Null status changes
|
|
53
|
+
const baseIsNullable = baseline.kind === 'null' || baseline.kind === 'nullable';
|
|
54
|
+
const currIsNullable = current.kind === 'null' || current.kind === 'nullable';
|
|
55
|
+
if (baseIsNullable !== currIsNullable && baseKind === currKind) {
|
|
56
|
+
if (baseIsNullable && !currIsNullable) {
|
|
57
|
+
diffs.push({
|
|
58
|
+
type: 'null_changed',
|
|
59
|
+
path,
|
|
60
|
+
detail: `was nullable, now always ${currKind}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else if (!baseIsNullable && currIsNullable) {
|
|
64
|
+
diffs.push({
|
|
65
|
+
type: 'null_changed',
|
|
66
|
+
path,
|
|
67
|
+
detail: `was ${baseKind}, now nullable`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Type change (different base kinds)
|
|
72
|
+
if (baseKind !== currKind && baseline.kind !== 'null' && current.kind !== 'null') {
|
|
73
|
+
diffs.push({
|
|
74
|
+
type: 'type_changed',
|
|
75
|
+
path,
|
|
76
|
+
detail: `was ${baseKind}, now ${currKind}`,
|
|
77
|
+
});
|
|
78
|
+
return diffs; // No point comparing children if types differ
|
|
79
|
+
}
|
|
80
|
+
// Object comparison
|
|
81
|
+
if (baseNode.kind === 'object' && currNode.kind === 'object') {
|
|
82
|
+
// Removed fields
|
|
83
|
+
for (const [key, baseFieldSchema] of baseNode.fields) {
|
|
84
|
+
if (!currNode.fields.has(key)) {
|
|
85
|
+
diffs.push({
|
|
86
|
+
type: 'removed',
|
|
87
|
+
path: `${path}.${key}`,
|
|
88
|
+
detail: `field removed (was ${schemaKindLabel(baseFieldSchema)})`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// Recurse into shared fields
|
|
93
|
+
const currFieldSchema = currNode.fields.get(key);
|
|
94
|
+
diffs.push(...compareSchemas(baseFieldSchema, currFieldSchema, `${path}.${key}`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// New fields
|
|
98
|
+
for (const [key, currFieldSchema] of currNode.fields) {
|
|
99
|
+
if (!baseNode.fields.has(key)) {
|
|
100
|
+
diffs.push({
|
|
101
|
+
type: 'added',
|
|
102
|
+
path: `${path}.${key}`,
|
|
103
|
+
detail: `new field (${schemaKindLabel(currFieldSchema)})`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Array comparison — compare item schemas
|
|
109
|
+
if (baseNode.kind === 'array' && currNode.kind === 'array') {
|
|
110
|
+
if (baseNode.items && currNode.items) {
|
|
111
|
+
diffs.push(...compareSchemas(baseNode.items, currNode.items, `${path}[]`));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return diffs;
|
|
115
|
+
}
|
|
116
|
+
function schemaKindLabel(node) {
|
|
117
|
+
switch (node.kind) {
|
|
118
|
+
case 'object': return 'object';
|
|
119
|
+
case 'array': return node.items ? `${schemaKindLabel(node.items)}[]` : 'array';
|
|
120
|
+
case 'nullable': return `${schemaKindLabel(node.inner)}?`;
|
|
121
|
+
default: return node.kind;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ─── Schema Tracker ───
|
|
125
|
+
class SchemaTracker {
|
|
126
|
+
/** Key: "METHOD route" (e.g. "GET /api/products") */
|
|
127
|
+
baselines = new Map();
|
|
128
|
+
/** Number of consecutive responses with new schema needed to auto-accept */
|
|
129
|
+
static AUTO_ACCEPT_COUNT = 3;
|
|
130
|
+
/**
|
|
131
|
+
* Process a response body from a route check.
|
|
132
|
+
* Only call for 2xx responses on API routes.
|
|
133
|
+
* Returns diffs if schema drift is detected, empty array otherwise.
|
|
134
|
+
*/
|
|
135
|
+
processResponse(route, method, body) {
|
|
136
|
+
const key = `${method} ${route}`;
|
|
137
|
+
const currentSchema = inferSchema(body);
|
|
138
|
+
const existing = this.baselines.get(key);
|
|
139
|
+
if (!existing) {
|
|
140
|
+
// First response — store as baseline
|
|
141
|
+
this.baselines.set(key, {
|
|
142
|
+
schema: currentSchema,
|
|
143
|
+
consecutiveMatches: 1,
|
|
144
|
+
pendingSchema: null,
|
|
145
|
+
pendingMatches: 0,
|
|
146
|
+
});
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
// Compare against baseline
|
|
150
|
+
const diffs = compareSchemas(existing.schema, currentSchema);
|
|
151
|
+
if (diffs.length === 0) {
|
|
152
|
+
// Matches baseline — reset any pending schema
|
|
153
|
+
existing.consecutiveMatches++;
|
|
154
|
+
existing.pendingSchema = null;
|
|
155
|
+
existing.pendingMatches = 0;
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
// Schema differs from baseline
|
|
159
|
+
if (existing.pendingSchema) {
|
|
160
|
+
// Check if it matches the pending schema
|
|
161
|
+
const pendingDiffs = compareSchemas(existing.pendingSchema, currentSchema);
|
|
162
|
+
if (pendingDiffs.length === 0) {
|
|
163
|
+
existing.pendingMatches++;
|
|
164
|
+
if (existing.pendingMatches >= SchemaTracker.AUTO_ACCEPT_COUNT) {
|
|
165
|
+
// Auto-accept: the new schema has been seen enough times
|
|
166
|
+
existing.schema = existing.pendingSchema;
|
|
167
|
+
existing.consecutiveMatches = existing.pendingMatches;
|
|
168
|
+
existing.pendingSchema = null;
|
|
169
|
+
existing.pendingMatches = 0;
|
|
170
|
+
return []; // Silently accept
|
|
171
|
+
}
|
|
172
|
+
// Still pending — return diffs against original baseline
|
|
173
|
+
return diffs;
|
|
174
|
+
}
|
|
175
|
+
// Different from both baseline and pending — start new pending
|
|
176
|
+
existing.pendingSchema = currentSchema;
|
|
177
|
+
existing.pendingMatches = 1;
|
|
178
|
+
return diffs;
|
|
179
|
+
}
|
|
180
|
+
// First time seeing a different schema — start pending
|
|
181
|
+
existing.pendingSchema = currentSchema;
|
|
182
|
+
existing.pendingMatches = 1;
|
|
183
|
+
return diffs;
|
|
184
|
+
}
|
|
185
|
+
/** Reset the baseline for routes associated with a changed file */
|
|
186
|
+
resetForRoutes(routes) {
|
|
187
|
+
for (const route of routes) {
|
|
188
|
+
// Reset all methods for this route
|
|
189
|
+
for (const key of this.baselines.keys()) {
|
|
190
|
+
if (key.endsWith(` ${route}`)) {
|
|
191
|
+
this.baselines.delete(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Get active baselines (for API status) */
|
|
197
|
+
getBaselineCount() {
|
|
198
|
+
return this.baselines.size;
|
|
199
|
+
}
|
|
200
|
+
/** Print schema drift warnings */
|
|
201
|
+
static printDrift(route, method, diffs) {
|
|
202
|
+
if (diffs.length === 0)
|
|
203
|
+
return;
|
|
204
|
+
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
205
|
+
console.log('');
|
|
206
|
+
console.log(chalk_1.default.yellow(` ${time} ⚠ ${method} ${route} — schema changed`));
|
|
207
|
+
let hasBreakingChange = false;
|
|
208
|
+
for (const diff of diffs) {
|
|
209
|
+
if (diff.type === 'removed') {
|
|
210
|
+
console.log(chalk_1.default.red(` Removed field: ${diff.path} (${diff.detail})`));
|
|
211
|
+
hasBreakingChange = true;
|
|
212
|
+
}
|
|
213
|
+
else if (diff.type === 'type_changed') {
|
|
214
|
+
console.log(chalk_1.default.yellow(` Type changed: ${diff.path} (${diff.detail})`));
|
|
215
|
+
hasBreakingChange = true;
|
|
216
|
+
}
|
|
217
|
+
else if (diff.type === 'added') {
|
|
218
|
+
console.log(chalk_1.default.cyan(` New field: ${diff.path} (${diff.detail})`));
|
|
219
|
+
}
|
|
220
|
+
else if (diff.type === 'null_changed') {
|
|
221
|
+
console.log(chalk_1.default.blue(` Null changed: ${diff.path} (${diff.detail})`));
|
|
222
|
+
hasBreakingChange = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (hasBreakingChange) {
|
|
226
|
+
console.log(chalk_1.default.yellow(` ⚠ This may break frontend consumers of this API`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
exports.SchemaTracker = SchemaTracker;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Real-time trace analyzers for scanwarp dev.
|
|
4
|
+
*
|
|
5
|
+
* Each analyzer inspects spans from a single trace and returns
|
|
6
|
+
* zero or more analysis results (warnings, errors, suggestions).
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.defaultAnalyzers = exports.slowExternalCallDetector = exports.missingErrorHandlingDetector = exports.unhandledErrorDetector = exports.slowQueryDetector = exports.nPlusOneDetector = void 0;
|
|
10
|
+
// ─── Helpers ───
|
|
11
|
+
function getParentSpan(span, spans) {
|
|
12
|
+
if (!span.parent_span_id)
|
|
13
|
+
return undefined;
|
|
14
|
+
return spans.find((s) => s.span_id === span.parent_span_id);
|
|
15
|
+
}
|
|
16
|
+
function isDbSpan(span) {
|
|
17
|
+
return span.attributes['db.system'] !== undefined;
|
|
18
|
+
}
|
|
19
|
+
function isHttpClientSpan(span) {
|
|
20
|
+
return (span.kind === 'CLIENT' &&
|
|
21
|
+
(span.attributes['http.url'] !== undefined ||
|
|
22
|
+
span.attributes['url.full'] !== undefined));
|
|
23
|
+
}
|
|
24
|
+
function getHttpUrl(span) {
|
|
25
|
+
const url = span.attributes['http.url'] || span.attributes['url.full'];
|
|
26
|
+
return typeof url === 'string' ? url : String(url ?? '');
|
|
27
|
+
}
|
|
28
|
+
function getDbStatement(span) {
|
|
29
|
+
const stmt = span.attributes['db.statement'];
|
|
30
|
+
return typeof stmt === 'string' ? stmt : '';
|
|
31
|
+
}
|
|
32
|
+
/** Normalize a SQL statement by replacing literal values with ? placeholders */
|
|
33
|
+
function normalizeQuery(sql) {
|
|
34
|
+
return sql
|
|
35
|
+
.replace(/'[^']*'/g, '?') // string literals
|
|
36
|
+
.replace(/\b\d+\b/g, '?') // numeric literals
|
|
37
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
function truncate(str, max) {
|
|
41
|
+
return str.length > max ? str.substring(0, max) + '...' : str;
|
|
42
|
+
}
|
|
43
|
+
function getErrorMessage(span) {
|
|
44
|
+
// Try exception event first
|
|
45
|
+
const exceptionEvent = span.events.find((e) => e.name === 'exception');
|
|
46
|
+
const exceptionMsg = exceptionEvent?.attributes?.['exception.message'];
|
|
47
|
+
if (typeof exceptionMsg === 'string')
|
|
48
|
+
return exceptionMsg;
|
|
49
|
+
// Fall back to status message
|
|
50
|
+
if (span.status_message)
|
|
51
|
+
return span.status_message;
|
|
52
|
+
return 'Unknown error';
|
|
53
|
+
}
|
|
54
|
+
// ─── Analyzer implementations ───
|
|
55
|
+
/**
|
|
56
|
+
* N+1 Query Detector
|
|
57
|
+
*
|
|
58
|
+
* Groups database spans by normalized query pattern within a single trace.
|
|
59
|
+
* If the same pattern appears 5+ times, flags it as an N+1.
|
|
60
|
+
*/
|
|
61
|
+
exports.nPlusOneDetector = {
|
|
62
|
+
name: 'n-plus-one',
|
|
63
|
+
analyze(spans) {
|
|
64
|
+
const results = [];
|
|
65
|
+
// Group DB spans by normalized statement
|
|
66
|
+
const queryCounts = new Map();
|
|
67
|
+
for (const span of spans) {
|
|
68
|
+
if (!isDbSpan(span))
|
|
69
|
+
continue;
|
|
70
|
+
const stmt = getDbStatement(span);
|
|
71
|
+
if (!stmt)
|
|
72
|
+
continue;
|
|
73
|
+
const normalized = normalizeQuery(stmt);
|
|
74
|
+
const existing = queryCounts.get(normalized);
|
|
75
|
+
if (existing) {
|
|
76
|
+
existing.count++;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
queryCounts.set(normalized, { count: 1, raw: stmt });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const [pattern, { count, raw }] of queryCounts) {
|
|
83
|
+
if (count >= 5) {
|
|
84
|
+
results.push({
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
rule: 'n-plus-one',
|
|
87
|
+
message: `N+1 query detected: '${truncate(pattern, 80)}' executed ${count} times`,
|
|
88
|
+
detail: `Full query: ${truncate(raw, 200)}`,
|
|
89
|
+
suggestion: 'Use a batch query or JOIN instead of querying in a loop',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return results;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Slow Database Query Detector
|
|
98
|
+
*
|
|
99
|
+
* Flags any database span over 500ms.
|
|
100
|
+
*/
|
|
101
|
+
exports.slowQueryDetector = {
|
|
102
|
+
name: 'slow-query',
|
|
103
|
+
analyze(spans) {
|
|
104
|
+
const results = [];
|
|
105
|
+
for (const span of spans) {
|
|
106
|
+
if (!isDbSpan(span))
|
|
107
|
+
continue;
|
|
108
|
+
if (span.duration_ms <= 500)
|
|
109
|
+
continue;
|
|
110
|
+
const stmt = getDbStatement(span);
|
|
111
|
+
const dbSystem = String(span.attributes['db.system'] || 'database');
|
|
112
|
+
results.push({
|
|
113
|
+
severity: 'warning',
|
|
114
|
+
rule: 'slow-query',
|
|
115
|
+
message: `Slow ${dbSystem} query: ${truncate(stmt || span.operation_name, 100)} (${span.duration_ms}ms)`,
|
|
116
|
+
suggestion: 'Consider adding an index or optimizing this query',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return results;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Unhandled Error Detector
|
|
124
|
+
*
|
|
125
|
+
* Detects when a span has ERROR status and its parent also has ERROR status,
|
|
126
|
+
* indicating the error propagated up without being caught.
|
|
127
|
+
*/
|
|
128
|
+
exports.unhandledErrorDetector = {
|
|
129
|
+
name: 'unhandled-error',
|
|
130
|
+
analyze(spans) {
|
|
131
|
+
const results = [];
|
|
132
|
+
for (const span of spans) {
|
|
133
|
+
if (span.status_code !== 'ERROR')
|
|
134
|
+
continue;
|
|
135
|
+
const parent = getParentSpan(span, spans);
|
|
136
|
+
if (!parent || parent.status_code !== 'ERROR')
|
|
137
|
+
continue;
|
|
138
|
+
// Skip HTTP client spans — they have their own analyzer
|
|
139
|
+
if (isHttpClientSpan(span))
|
|
140
|
+
continue;
|
|
141
|
+
const errorMsg = getErrorMessage(span);
|
|
142
|
+
results.push({
|
|
143
|
+
severity: 'error',
|
|
144
|
+
rule: 'unhandled-error',
|
|
145
|
+
message: `Unhandled error in ${span.operation_name}: ${truncate(errorMsg, 100)}`,
|
|
146
|
+
suggestion: 'Add error handling (try/catch) around this operation',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Missing Error Handling on External Calls
|
|
154
|
+
*
|
|
155
|
+
* When an HTTP client span fails and the parent span also has ERROR status,
|
|
156
|
+
* the external call failure wasn't handled gracefully.
|
|
157
|
+
*/
|
|
158
|
+
exports.missingErrorHandlingDetector = {
|
|
159
|
+
name: 'missing-error-handling',
|
|
160
|
+
analyze(spans) {
|
|
161
|
+
const results = [];
|
|
162
|
+
for (const span of spans) {
|
|
163
|
+
if (!isHttpClientSpan(span))
|
|
164
|
+
continue;
|
|
165
|
+
if (span.status_code !== 'ERROR')
|
|
166
|
+
continue;
|
|
167
|
+
const parent = getParentSpan(span, spans);
|
|
168
|
+
if (!parent || parent.status_code !== 'ERROR')
|
|
169
|
+
continue;
|
|
170
|
+
const url = getHttpUrl(span);
|
|
171
|
+
// Extract just the host for cleaner output
|
|
172
|
+
let host = url;
|
|
173
|
+
try {
|
|
174
|
+
host = new URL(url).host;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// keep raw url
|
|
178
|
+
}
|
|
179
|
+
results.push({
|
|
180
|
+
severity: 'error',
|
|
181
|
+
rule: 'missing-error-handling',
|
|
182
|
+
message: `External API call to ${host} failed with no error handling`,
|
|
183
|
+
detail: `URL: ${truncate(url, 150)}`,
|
|
184
|
+
suggestion: 'Wrap this API call in try/catch and handle failures gracefully',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Slow External API Call
|
|
192
|
+
*
|
|
193
|
+
* Flags any HTTP client span over 2 seconds.
|
|
194
|
+
*/
|
|
195
|
+
exports.slowExternalCallDetector = {
|
|
196
|
+
name: 'slow-external-call',
|
|
197
|
+
analyze(spans) {
|
|
198
|
+
const results = [];
|
|
199
|
+
for (const span of spans) {
|
|
200
|
+
if (!isHttpClientSpan(span))
|
|
201
|
+
continue;
|
|
202
|
+
if (span.duration_ms <= 2000)
|
|
203
|
+
continue;
|
|
204
|
+
const url = getHttpUrl(span);
|
|
205
|
+
let host = url;
|
|
206
|
+
try {
|
|
207
|
+
host = new URL(url).host;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// keep raw url
|
|
211
|
+
}
|
|
212
|
+
results.push({
|
|
213
|
+
severity: 'warning',
|
|
214
|
+
rule: 'slow-external-call',
|
|
215
|
+
message: `Slow external call to ${host}: ${span.duration_ms}ms`,
|
|
216
|
+
detail: `URL: ${truncate(url, 150)}`,
|
|
217
|
+
suggestion: 'Consider adding a timeout, caching the response, or making it async',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
// ─── All built-in analyzers ───
|
|
224
|
+
exports.defaultAnalyzers = [
|
|
225
|
+
exports.nPlusOneDetector,
|
|
226
|
+
exports.slowQueryDetector,
|
|
227
|
+
exports.unhandledErrorDetector,
|
|
228
|
+
exports.missingErrorHandlingDetector,
|
|
229
|
+
exports.slowExternalCallDetector,
|
|
230
|
+
];
|