brakit 0.7.4 → 0.7.6
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 +78 -50
- package/dist/api.d.ts +116 -3
- package/dist/api.js +615 -353
- package/dist/bin/brakit.js +38 -31
- package/dist/runtime/index.js +1150 -510
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -98,6 +98,8 @@ var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
|
98
98
|
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
99
99
|
var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
|
|
100
100
|
var INTERNAL_ID_SUFFIX = /Id$|_id$/;
|
|
101
|
+
var SELECT_STAR_RE = /^SELECT\s+\*/i;
|
|
102
|
+
var SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
|
|
101
103
|
var RULE_HINTS = {
|
|
102
104
|
"exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
|
|
103
105
|
"token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
|
|
@@ -407,6 +409,58 @@ var corsCredentialsRule = {
|
|
|
407
409
|
}
|
|
408
410
|
};
|
|
409
411
|
|
|
412
|
+
// src/constants/thresholds.ts
|
|
413
|
+
var FLOW_GAP_MS = 5e3;
|
|
414
|
+
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
415
|
+
var MIN_POLLING_SEQUENCE = 3;
|
|
416
|
+
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
417
|
+
var N1_QUERY_THRESHOLD = 5;
|
|
418
|
+
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
419
|
+
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
420
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
421
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
422
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
423
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
424
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
425
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
426
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
427
|
+
var HIGH_ROW_COUNT = 100;
|
|
428
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
429
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
430
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
431
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
432
|
+
var REGRESSION_PCT_THRESHOLD = 50;
|
|
433
|
+
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
434
|
+
var REGRESSION_MIN_REQUESTS = 5;
|
|
435
|
+
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
436
|
+
var OVERFETCH_MANY_FIELDS = 12;
|
|
437
|
+
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
438
|
+
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
439
|
+
|
|
440
|
+
// src/utils/response.ts
|
|
441
|
+
function unwrapResponse(parsed) {
|
|
442
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
443
|
+
const obj = parsed;
|
|
444
|
+
const keys = Object.keys(obj);
|
|
445
|
+
if (keys.length > 3) return parsed;
|
|
446
|
+
let best = null;
|
|
447
|
+
let bestSize = 0;
|
|
448
|
+
for (const key of keys) {
|
|
449
|
+
const val = obj[key];
|
|
450
|
+
if (Array.isArray(val) && val.length > bestSize) {
|
|
451
|
+
best = val;
|
|
452
|
+
bestSize = val.length;
|
|
453
|
+
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
454
|
+
const size = Object.keys(val).length;
|
|
455
|
+
if (size > bestSize) {
|
|
456
|
+
best = val;
|
|
457
|
+
bestSize = size;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
|
|
462
|
+
}
|
|
463
|
+
|
|
410
464
|
// src/analysis/rules/response-pii-leak.ts
|
|
411
465
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
412
466
|
var FULL_RECORD_MIN_FIELDS = 5;
|
|
@@ -451,28 +505,6 @@ function hasInternalIds(obj) {
|
|
|
451
505
|
}
|
|
452
506
|
return false;
|
|
453
507
|
}
|
|
454
|
-
function unwrapResponse(parsed) {
|
|
455
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
456
|
-
const obj = parsed;
|
|
457
|
-
const keys = Object.keys(obj);
|
|
458
|
-
if (keys.length > 3) return parsed;
|
|
459
|
-
let best = null;
|
|
460
|
-
let bestSize = 0;
|
|
461
|
-
for (const key of keys) {
|
|
462
|
-
const val = obj[key];
|
|
463
|
-
if (Array.isArray(val) && val.length > bestSize) {
|
|
464
|
-
best = val;
|
|
465
|
-
bestSize = val.length;
|
|
466
|
-
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
467
|
-
const size = Object.keys(val).length;
|
|
468
|
-
if (size > bestSize) {
|
|
469
|
-
best = val;
|
|
470
|
-
bestSize = size;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return best && bestSize >= 3 ? best : parsed;
|
|
475
|
-
}
|
|
476
508
|
function detectPII(method, reqBody, resBody) {
|
|
477
509
|
const target = unwrapResponse(resBody);
|
|
478
510
|
if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
|
|
@@ -598,27 +630,6 @@ var DASHBOARD_PREFIX = "/__brakit";
|
|
|
598
630
|
var MAX_REQUEST_ENTRIES = 1e3;
|
|
599
631
|
var MAX_TELEMETRY_ENTRIES = 1e3;
|
|
600
632
|
|
|
601
|
-
// src/constants/thresholds.ts
|
|
602
|
-
var FLOW_GAP_MS = 5e3;
|
|
603
|
-
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
604
|
-
var MIN_POLLING_SEQUENCE = 3;
|
|
605
|
-
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
606
|
-
var N1_QUERY_THRESHOLD = 5;
|
|
607
|
-
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
608
|
-
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
609
|
-
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
610
|
-
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
611
|
-
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
612
|
-
var CROSS_ENDPOINT_PCT = 50;
|
|
613
|
-
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
614
|
-
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
615
|
-
var LARGE_RESPONSE_BYTES = 51200;
|
|
616
|
-
var HIGH_ROW_COUNT = 100;
|
|
617
|
-
var OVERFETCH_MIN_REQUESTS = 2;
|
|
618
|
-
var OVERFETCH_MIN_FIELDS = 8;
|
|
619
|
-
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
620
|
-
var OVERFETCH_NULL_RATIO = 0.3;
|
|
621
|
-
|
|
622
633
|
// src/utils/static-patterns.ts
|
|
623
634
|
var STATIC_PATTERNS = [
|
|
624
635
|
/^\/_next\//,
|
|
@@ -670,7 +681,7 @@ var RequestStore = class {
|
|
|
670
681
|
responseHeaders: flattenHeaders(input.responseHeaders),
|
|
671
682
|
responseBody: responseBodyStr,
|
|
672
683
|
startedAt: input.startTime,
|
|
673
|
-
durationMs: Math.round(performance.now() - input.startTime),
|
|
684
|
+
durationMs: Math.round((input.endTime ?? performance.now()) - input.startTime),
|
|
674
685
|
responseSize: input.responseBody?.length ?? 0,
|
|
675
686
|
isStatic: isStaticPath(path)
|
|
676
687
|
};
|
|
@@ -760,14 +771,21 @@ var defaultQueryStore = new QueryStore();
|
|
|
760
771
|
// src/store/metrics/metrics-store.ts
|
|
761
772
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
762
773
|
|
|
774
|
+
// src/utils/endpoint.ts
|
|
775
|
+
function getEndpointKey(method, path) {
|
|
776
|
+
return `${method} ${path}`;
|
|
777
|
+
}
|
|
778
|
+
|
|
763
779
|
// src/store/metrics/persistence.ts
|
|
764
780
|
import {
|
|
765
781
|
readFileSync as readFileSync2,
|
|
766
782
|
writeFileSync as writeFileSync2,
|
|
767
783
|
mkdirSync as mkdirSync2,
|
|
768
784
|
existsSync as existsSync2,
|
|
769
|
-
unlinkSync
|
|
785
|
+
unlinkSync,
|
|
786
|
+
renameSync
|
|
770
787
|
} from "fs";
|
|
788
|
+
import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
|
|
771
789
|
import { resolve as resolve2 } from "path";
|
|
772
790
|
|
|
773
791
|
// src/analysis/group.ts
|
|
@@ -1123,6 +1141,22 @@ function deriveFlowLabel(requests, sourcePage) {
|
|
|
1123
1141
|
return trigger.label;
|
|
1124
1142
|
}
|
|
1125
1143
|
|
|
1144
|
+
// src/utils/collections.ts
|
|
1145
|
+
function groupBy(items, keyFn) {
|
|
1146
|
+
const map = /* @__PURE__ */ new Map();
|
|
1147
|
+
for (const item of items) {
|
|
1148
|
+
const key = keyFn(item);
|
|
1149
|
+
if (key == null) continue;
|
|
1150
|
+
let arr = map.get(key);
|
|
1151
|
+
if (!arr) {
|
|
1152
|
+
arr = [];
|
|
1153
|
+
map.set(key, arr);
|
|
1154
|
+
}
|
|
1155
|
+
arr.push(item);
|
|
1156
|
+
}
|
|
1157
|
+
return map;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1126
1160
|
// src/instrument/adapters/normalize.ts
|
|
1127
1161
|
function normalizeSQL(sql) {
|
|
1128
1162
|
if (!sql) return { op: "OTHER", table: "" };
|
|
@@ -1155,7 +1189,7 @@ function normalizeQueryParams(sql) {
|
|
|
1155
1189
|
return n;
|
|
1156
1190
|
}
|
|
1157
1191
|
|
|
1158
|
-
// src/analysis/insights.ts
|
|
1192
|
+
// src/analysis/insights/query-helpers.ts
|
|
1159
1193
|
function getQueryShape(q) {
|
|
1160
1194
|
if (q.sql) return normalizeQueryParams(q.sql) ?? "";
|
|
1161
1195
|
return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
|
|
@@ -1167,143 +1201,234 @@ function getQueryInfo(q) {
|
|
|
1167
1201
|
table: q.table ?? q.model ?? ""
|
|
1168
1202
|
};
|
|
1169
1203
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1204
|
+
|
|
1205
|
+
// src/analysis/insights/prepare.ts
|
|
1206
|
+
function createEndpointGroup() {
|
|
1207
|
+
return {
|
|
1208
|
+
total: 0,
|
|
1209
|
+
errors: 0,
|
|
1210
|
+
totalDuration: 0,
|
|
1211
|
+
queryCount: 0,
|
|
1212
|
+
totalSize: 0,
|
|
1213
|
+
totalQueryTimeMs: 0,
|
|
1214
|
+
totalFetchTimeMs: 0,
|
|
1215
|
+
queryShapeDurations: /* @__PURE__ */ new Map()
|
|
1216
|
+
};
|
|
1178
1217
|
}
|
|
1179
|
-
function
|
|
1180
|
-
const insights = [];
|
|
1218
|
+
function prepareContext(ctx) {
|
|
1181
1219
|
const nonStatic = ctx.requests.filter(
|
|
1182
1220
|
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1183
1221
|
);
|
|
1184
|
-
const queriesByReq =
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1222
|
+
const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
|
|
1223
|
+
const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
|
|
1224
|
+
const reqById = new Map(nonStatic.map((r) => [r.id, r]));
|
|
1225
|
+
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1226
|
+
for (const r of nonStatic) {
|
|
1227
|
+
const ep = getEndpointKey(r.method, r.path);
|
|
1228
|
+
let g = endpointGroups.get(ep);
|
|
1229
|
+
if (!g) {
|
|
1230
|
+
g = createEndpointGroup();
|
|
1231
|
+
endpointGroups.set(ep, g);
|
|
1191
1232
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
const req = reqById.get(reqId);
|
|
1199
|
-
if (!req) continue;
|
|
1200
|
-
const endpoint = `${req.method} ${req.path}`;
|
|
1201
|
-
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1233
|
+
g.total++;
|
|
1234
|
+
if (r.statusCode >= 400) g.errors++;
|
|
1235
|
+
g.totalDuration += r.durationMs;
|
|
1236
|
+
g.totalSize += r.responseSize ?? 0;
|
|
1237
|
+
const reqQueries = queriesByReq.get(r.id) ?? [];
|
|
1238
|
+
g.queryCount += reqQueries.length;
|
|
1202
1239
|
for (const q of reqQueries) {
|
|
1240
|
+
g.totalQueryTimeMs += q.durationMs;
|
|
1203
1241
|
const shape = getQueryShape(q);
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1242
|
+
const info = getQueryInfo(q);
|
|
1243
|
+
let sd = g.queryShapeDurations.get(shape);
|
|
1244
|
+
if (!sd) {
|
|
1245
|
+
sd = { totalMs: 0, count: 0, label: info.op + (info.table ? ` ${info.table}` : "") };
|
|
1246
|
+
g.queryShapeDurations.set(shape, sd);
|
|
1208
1247
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1248
|
+
sd.totalMs += q.durationMs;
|
|
1249
|
+
sd.count++;
|
|
1211
1250
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
|
|
1216
|
-
if (n1Seen.has(key)) continue;
|
|
1217
|
-
n1Seen.add(key);
|
|
1218
|
-
insights.push({
|
|
1219
|
-
severity: "critical",
|
|
1220
|
-
type: "n1",
|
|
1221
|
-
title: "N+1 Query Pattern",
|
|
1222
|
-
desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
|
|
1223
|
-
hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
|
|
1224
|
-
nav: "queries"
|
|
1225
|
-
});
|
|
1251
|
+
const reqFetches = fetchesByReq.get(r.id) ?? [];
|
|
1252
|
+
for (const f of reqFetches) {
|
|
1253
|
+
g.totalFetchTimeMs += f.durationMs;
|
|
1226
1254
|
}
|
|
1227
1255
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1256
|
+
return {
|
|
1257
|
+
...ctx,
|
|
1258
|
+
nonStatic,
|
|
1259
|
+
queriesByReq,
|
|
1260
|
+
fetchesByReq,
|
|
1261
|
+
reqById,
|
|
1262
|
+
endpointGroups
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/analysis/insights/runner.ts
|
|
1267
|
+
var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
1268
|
+
var InsightRunner = class {
|
|
1269
|
+
rules = [];
|
|
1270
|
+
register(rule) {
|
|
1271
|
+
this.rules.push(rule);
|
|
1272
|
+
}
|
|
1273
|
+
run(ctx) {
|
|
1274
|
+
const prepared = prepareContext(ctx);
|
|
1275
|
+
const insights = [];
|
|
1276
|
+
for (const rule of this.rules) {
|
|
1277
|
+
try {
|
|
1278
|
+
insights.push(...rule.check(prepared));
|
|
1279
|
+
} catch {
|
|
1247
1280
|
}
|
|
1248
1281
|
}
|
|
1282
|
+
insights.sort(
|
|
1283
|
+
(a, b) => (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2)
|
|
1284
|
+
);
|
|
1285
|
+
return insights;
|
|
1249
1286
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// src/analysis/insights/rules/n1.ts
|
|
1290
|
+
var n1Rule = {
|
|
1291
|
+
id: "n1",
|
|
1292
|
+
check(ctx) {
|
|
1293
|
+
const insights = [];
|
|
1294
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1295
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1296
|
+
const req = ctx.reqById.get(reqId);
|
|
1297
|
+
if (!req) continue;
|
|
1298
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
1299
|
+
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1300
|
+
for (const q of reqQueries) {
|
|
1301
|
+
const shape = getQueryShape(q);
|
|
1302
|
+
let group = shapeGroups.get(shape);
|
|
1303
|
+
if (!group) {
|
|
1304
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
|
|
1305
|
+
shapeGroups.set(shape, group);
|
|
1306
|
+
}
|
|
1307
|
+
group.count++;
|
|
1308
|
+
group.distinctSql.add(q.sql ?? shape);
|
|
1309
|
+
}
|
|
1310
|
+
for (const [, sg] of shapeGroups) {
|
|
1311
|
+
if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
|
|
1312
|
+
const info = getQueryInfo(sg.first);
|
|
1313
|
+
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
|
|
1314
|
+
if (seen.has(key)) continue;
|
|
1315
|
+
seen.add(key);
|
|
1316
|
+
insights.push({
|
|
1317
|
+
severity: "critical",
|
|
1318
|
+
type: "n1",
|
|
1319
|
+
title: "N+1 Query Pattern",
|
|
1320
|
+
desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
|
|
1321
|
+
hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
|
|
1322
|
+
nav: "queries"
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1266
1325
|
}
|
|
1326
|
+
return insights;
|
|
1267
1327
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// src/analysis/insights/rules/cross-endpoint.ts
|
|
1331
|
+
var crossEndpointRule = {
|
|
1332
|
+
id: "cross-endpoint",
|
|
1333
|
+
check(ctx) {
|
|
1334
|
+
const insights = [];
|
|
1335
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
1336
|
+
const allEndpoints = /* @__PURE__ */ new Set();
|
|
1337
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1338
|
+
const req = ctx.reqById.get(reqId);
|
|
1339
|
+
if (!req) continue;
|
|
1340
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
1341
|
+
allEndpoints.add(endpoint);
|
|
1342
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
1343
|
+
for (const q of reqQueries) {
|
|
1344
|
+
const shape = getQueryShape(q);
|
|
1345
|
+
let entry = queryMap.get(shape);
|
|
1346
|
+
if (!entry) {
|
|
1347
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1348
|
+
queryMap.set(shape, entry);
|
|
1349
|
+
}
|
|
1350
|
+
entry.count++;
|
|
1351
|
+
if (!seenInReq.has(shape)) {
|
|
1352
|
+
seenInReq.add(shape);
|
|
1353
|
+
entry.endpoints.add(endpoint);
|
|
1354
|
+
}
|
|
1280
1355
|
}
|
|
1281
|
-
entry.count++;
|
|
1282
1356
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1357
|
+
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1358
|
+
for (const [, cem] of queryMap) {
|
|
1359
|
+
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1360
|
+
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1361
|
+
const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
|
|
1362
|
+
if (p < CROSS_ENDPOINT_PCT) continue;
|
|
1363
|
+
const info = getQueryInfo(cem.first);
|
|
1364
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1365
|
+
insights.push({
|
|
1366
|
+
severity: "warning",
|
|
1367
|
+
type: "cross-endpoint",
|
|
1368
|
+
title: "Repeated Query Across Endpoints",
|
|
1369
|
+
desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
|
|
1370
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1371
|
+
nav: "queries"
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return insights;
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// src/analysis/insights/rules/redundant-query.ts
|
|
1380
|
+
var redundantQueryRule = {
|
|
1381
|
+
id: "redundant-query",
|
|
1382
|
+
check(ctx) {
|
|
1383
|
+
const insights = [];
|
|
1384
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1385
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1386
|
+
const req = ctx.reqById.get(reqId);
|
|
1387
|
+
if (!req) continue;
|
|
1388
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
1389
|
+
const exact = /* @__PURE__ */ new Map();
|
|
1390
|
+
for (const q of reqQueries) {
|
|
1391
|
+
if (!q.sql) continue;
|
|
1392
|
+
let entry = exact.get(q.sql);
|
|
1393
|
+
if (!entry) {
|
|
1394
|
+
entry = { count: 0, first: q };
|
|
1395
|
+
exact.set(q.sql, entry);
|
|
1396
|
+
}
|
|
1397
|
+
entry.count++;
|
|
1398
|
+
}
|
|
1399
|
+
for (const [, e] of exact) {
|
|
1400
|
+
if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1401
|
+
const info = getQueryInfo(e.first);
|
|
1402
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1403
|
+
const dedupKey = `${endpoint}:${label}`;
|
|
1404
|
+
if (seen.has(dedupKey)) continue;
|
|
1405
|
+
seen.add(dedupKey);
|
|
1406
|
+
insights.push({
|
|
1407
|
+
severity: "warning",
|
|
1408
|
+
type: "redundant-query",
|
|
1409
|
+
title: "Redundant Query",
|
|
1410
|
+
desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
|
|
1411
|
+
hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
|
|
1412
|
+
nav: "queries"
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1298
1415
|
}
|
|
1416
|
+
return insights;
|
|
1299
1417
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
// src/analysis/insights/rules/error.ts
|
|
1421
|
+
var errorRule = {
|
|
1422
|
+
id: "error",
|
|
1423
|
+
check(ctx) {
|
|
1424
|
+
if (ctx.errors.length === 0) return [];
|
|
1425
|
+
const insights = [];
|
|
1426
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1302
1427
|
for (const e of ctx.errors) {
|
|
1303
1428
|
const name = e.name || "Error";
|
|
1304
|
-
|
|
1429
|
+
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1305
1430
|
}
|
|
1306
|
-
for (const [name, cnt] of
|
|
1431
|
+
for (const [name, cnt] of groups) {
|
|
1307
1432
|
insights.push({
|
|
1308
1433
|
severity: "critical",
|
|
1309
1434
|
type: "error",
|
|
@@ -1313,235 +1438,367 @@ function computeInsights(ctx) {
|
|
|
1313
1438
|
nav: "errors"
|
|
1314
1439
|
});
|
|
1315
1440
|
}
|
|
1441
|
+
return insights;
|
|
1316
1442
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
// src/analysis/insights/rules/error-hotspot.ts
|
|
1446
|
+
var errorHotspotRule = {
|
|
1447
|
+
id: "error-hotspot",
|
|
1448
|
+
check(ctx) {
|
|
1449
|
+
const insights = [];
|
|
1450
|
+
for (const [ep, g] of ctx.endpointGroups) {
|
|
1451
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1452
|
+
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1453
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1454
|
+
insights.push({
|
|
1455
|
+
severity: "critical",
|
|
1456
|
+
type: "error-hotspot",
|
|
1457
|
+
title: "Error Hotspot",
|
|
1458
|
+
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1459
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1460
|
+
nav: "requests"
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1324
1463
|
}
|
|
1325
|
-
|
|
1326
|
-
if (r.statusCode >= 400) g.errors++;
|
|
1327
|
-
g.totalDuration += r.durationMs;
|
|
1328
|
-
g.queryCount += (queriesByReq.get(r.id) ?? []).length;
|
|
1329
|
-
g.totalSize += r.responseSize ?? 0;
|
|
1464
|
+
return insights;
|
|
1330
1465
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
// src/analysis/insights/rules/duplicate.ts
|
|
1469
|
+
var duplicateRule = {
|
|
1470
|
+
id: "duplicate",
|
|
1471
|
+
check(ctx) {
|
|
1472
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
1473
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
1474
|
+
for (const flow of ctx.flows) {
|
|
1475
|
+
if (!flow.requests) continue;
|
|
1476
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1477
|
+
for (const fr of flow.requests) {
|
|
1478
|
+
if (!fr.isDuplicate) continue;
|
|
1479
|
+
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1480
|
+
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1481
|
+
if (!seenInFlow.has(dupKey)) {
|
|
1482
|
+
seenInFlow.add(dupKey);
|
|
1483
|
+
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1488
|
+
const insights = [];
|
|
1489
|
+
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
1490
|
+
const d = dupEntries[i];
|
|
1335
1491
|
insights.push({
|
|
1336
|
-
severity: "
|
|
1337
|
-
type: "
|
|
1338
|
-
title: "
|
|
1339
|
-
desc: `${
|
|
1340
|
-
hint: "
|
|
1341
|
-
nav: "
|
|
1492
|
+
severity: "warning",
|
|
1493
|
+
type: "duplicate",
|
|
1494
|
+
title: "Duplicate API Call",
|
|
1495
|
+
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1496
|
+
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
|
|
1497
|
+
nav: "actions"
|
|
1342
1498
|
});
|
|
1343
1499
|
}
|
|
1500
|
+
return insights;
|
|
1344
1501
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
// src/utils/format.ts
|
|
1505
|
+
function formatDuration(ms) {
|
|
1506
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1507
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1508
|
+
}
|
|
1509
|
+
function formatSize(bytes) {
|
|
1510
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1511
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1512
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1513
|
+
}
|
|
1514
|
+
function pct(part, total) {
|
|
1515
|
+
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/analysis/insights/rules/slow.ts
|
|
1519
|
+
var slowRule = {
|
|
1520
|
+
id: "slow",
|
|
1521
|
+
check(ctx) {
|
|
1522
|
+
const insights = [];
|
|
1523
|
+
for (const [ep, g] of ctx.endpointGroups) {
|
|
1524
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1525
|
+
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1526
|
+
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1527
|
+
const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
|
|
1528
|
+
const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
|
|
1529
|
+
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1530
|
+
const parts = [];
|
|
1531
|
+
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1532
|
+
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1533
|
+
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1534
|
+
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1535
|
+
let detail;
|
|
1536
|
+
let slowestMs = 0;
|
|
1537
|
+
for (const [, sd] of g.queryShapeDurations) {
|
|
1538
|
+
const avgShapeMs = sd.totalMs / sd.count;
|
|
1539
|
+
if (avgShapeMs > slowestMs) {
|
|
1540
|
+
slowestMs = avgShapeMs;
|
|
1541
|
+
detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
|
|
1542
|
+
}
|
|
1357
1543
|
}
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1361
|
-
for (let i = 0; i < Math.min(dupEntries.length, 3); i++) {
|
|
1362
|
-
const d = dupEntries[i];
|
|
1363
|
-
insights.push({
|
|
1364
|
-
severity: "warning",
|
|
1365
|
-
type: "duplicate",
|
|
1366
|
-
title: "Duplicate API Call",
|
|
1367
|
-
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1368
|
-
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
|
|
1369
|
-
nav: "actions"
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
for (const [ep, g] of endpointGroups) {
|
|
1373
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1374
|
-
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1375
|
-
if (avgMs >= SLOW_ENDPOINT_THRESHOLD_MS) {
|
|
1376
1544
|
insights.push({
|
|
1377
1545
|
severity: "warning",
|
|
1378
1546
|
type: "slow",
|
|
1379
1547
|
title: "Slow Endpoint",
|
|
1380
|
-
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}
|
|
1381
|
-
hint: "
|
|
1548
|
+
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1549
|
+
hint: avgQueryMs >= avgFetchMs && avgQueryMs >= avgAppMs ? "Most time is in database queries. Check the Queries tab for slow or redundant queries." : avgFetchMs >= avgQueryMs && avgFetchMs >= avgAppMs ? "Most time is in outbound HTTP calls. Check if upstream services are slow or if calls can be parallelized." : "Most time is in application code. Profile the handler for CPU-heavy operations or blocking calls.",
|
|
1550
|
+
detail,
|
|
1382
1551
|
nav: "requests"
|
|
1383
1552
|
});
|
|
1384
1553
|
}
|
|
1554
|
+
return insights;
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
// src/analysis/insights/rules/query-heavy.ts
|
|
1559
|
+
var queryHeavyRule = {
|
|
1560
|
+
id: "query-heavy",
|
|
1561
|
+
check(ctx) {
|
|
1562
|
+
const insights = [];
|
|
1563
|
+
for (const [ep, g] of ctx.endpointGroups) {
|
|
1564
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1565
|
+
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1566
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1567
|
+
insights.push({
|
|
1568
|
+
severity: "warning",
|
|
1569
|
+
type: "query-heavy",
|
|
1570
|
+
title: "Query-Heavy Endpoint",
|
|
1571
|
+
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1572
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1573
|
+
nav: "queries"
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return insights;
|
|
1385
1578
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
// src/analysis/insights/rules/select-star.ts
|
|
1582
|
+
var selectStarRule = {
|
|
1583
|
+
id: "select-star",
|
|
1584
|
+
check(ctx) {
|
|
1585
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1586
|
+
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1587
|
+
for (const q of reqQueries) {
|
|
1588
|
+
if (!q.sql) continue;
|
|
1589
|
+
const isSelectStar = SELECT_STAR_RE.test(q.sql.trim()) || SELECT_DOT_STAR_RE.test(q.sql);
|
|
1590
|
+
if (!isSelectStar) continue;
|
|
1591
|
+
const info = getQueryInfo(q);
|
|
1592
|
+
const key = info.table || "unknown";
|
|
1593
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
const insights = [];
|
|
1597
|
+
for (const [table, count] of seen) {
|
|
1598
|
+
if (count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1390
1599
|
insights.push({
|
|
1391
1600
|
severity: "warning",
|
|
1392
|
-
type: "
|
|
1393
|
-
title: "Query
|
|
1394
|
-
desc:
|
|
1395
|
-
hint: "
|
|
1601
|
+
type: "select-star",
|
|
1602
|
+
title: "SELECT * Query",
|
|
1603
|
+
desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1604
|
+
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
|
|
1396
1605
|
nav: "queries"
|
|
1397
1606
|
});
|
|
1398
1607
|
}
|
|
1608
|
+
return insights;
|
|
1399
1609
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
title: "SELECT * Query",
|
|
1417
|
-
desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1418
|
-
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
|
|
1419
|
-
nav: "queries"
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
const highRowSeen = /* @__PURE__ */ new Map();
|
|
1423
|
-
for (const [, reqQueries] of queriesByReq) {
|
|
1424
|
-
for (const q of reqQueries) {
|
|
1425
|
-
if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1426
|
-
const info = getQueryInfo(q);
|
|
1427
|
-
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1428
|
-
let entry = highRowSeen.get(key);
|
|
1429
|
-
if (!entry) {
|
|
1430
|
-
entry = { max: 0, count: 0 };
|
|
1431
|
-
highRowSeen.set(key, entry);
|
|
1432
|
-
}
|
|
1433
|
-
entry.count++;
|
|
1434
|
-
if (q.rowCount > entry.max) entry.max = q.rowCount;
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
for (const [key, hrs] of highRowSeen) {
|
|
1438
|
-
if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1439
|
-
insights.push({
|
|
1440
|
-
severity: "warning",
|
|
1441
|
-
type: "high-rows",
|
|
1442
|
-
title: "Large Result Set",
|
|
1443
|
-
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
|
|
1444
|
-
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
|
|
1445
|
-
nav: "queries"
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
const overfetchSeen = /* @__PURE__ */ new Set();
|
|
1449
|
-
for (const r of nonStatic) {
|
|
1450
|
-
if (r.statusCode >= 400 || !r.responseBody) continue;
|
|
1451
|
-
const ep = `${r.method} ${r.path}`;
|
|
1452
|
-
if (overfetchSeen.has(ep)) continue;
|
|
1453
|
-
let parsed;
|
|
1454
|
-
try {
|
|
1455
|
-
parsed = JSON.parse(r.responseBody);
|
|
1456
|
-
} catch {
|
|
1457
|
-
continue;
|
|
1458
|
-
}
|
|
1459
|
-
let target = parsed;
|
|
1460
|
-
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
1461
|
-
const topKeys = Object.keys(target);
|
|
1462
|
-
if (topKeys.length <= 3) {
|
|
1463
|
-
let best = null;
|
|
1464
|
-
let bestSize = 0;
|
|
1465
|
-
for (const k of topKeys) {
|
|
1466
|
-
const val = target[k];
|
|
1467
|
-
if (Array.isArray(val) && val.length > bestSize) {
|
|
1468
|
-
best = val;
|
|
1469
|
-
bestSize = val.length;
|
|
1470
|
-
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
1471
|
-
const size = Object.keys(val).length;
|
|
1472
|
-
if (size > bestSize) {
|
|
1473
|
-
best = val;
|
|
1474
|
-
bestSize = size;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
// src/analysis/insights/rules/high-rows.ts
|
|
1613
|
+
var highRowsRule = {
|
|
1614
|
+
id: "high-rows",
|
|
1615
|
+
check(ctx) {
|
|
1616
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1617
|
+
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1618
|
+
for (const q of reqQueries) {
|
|
1619
|
+
if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1620
|
+
const info = getQueryInfo(q);
|
|
1621
|
+
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1622
|
+
let entry = seen.get(key);
|
|
1623
|
+
if (!entry) {
|
|
1624
|
+
entry = { max: 0, count: 0 };
|
|
1625
|
+
seen.set(key, entry);
|
|
1477
1626
|
}
|
|
1478
|
-
|
|
1627
|
+
entry.count++;
|
|
1628
|
+
if (q.rowCount > entry.max) entry.max = q.rowCount;
|
|
1479
1629
|
}
|
|
1480
1630
|
}
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (fields.length < OVERFETCH_MIN_FIELDS) continue;
|
|
1485
|
-
let internalIdCount = 0;
|
|
1486
|
-
let nullCount = 0;
|
|
1487
|
-
for (const key of fields) {
|
|
1488
|
-
if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
|
|
1489
|
-
const val = inspectObj[key];
|
|
1490
|
-
if (val === null || val === void 0) nullCount++;
|
|
1491
|
-
}
|
|
1492
|
-
const nullRatio = nullCount / fields.length;
|
|
1493
|
-
const reasons = [];
|
|
1494
|
-
if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
|
|
1495
|
-
if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
|
|
1496
|
-
if (fields.length >= OVERFETCH_MIN_FIELDS && reasons.length === 0 && fields.length >= 12) {
|
|
1497
|
-
reasons.push(`${fields.length} fields returned`);
|
|
1498
|
-
}
|
|
1499
|
-
if (reasons.length > 0) {
|
|
1500
|
-
overfetchSeen.add(ep);
|
|
1631
|
+
const insights = [];
|
|
1632
|
+
for (const [key, hrs] of seen) {
|
|
1633
|
+
if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1501
1634
|
insights.push({
|
|
1502
|
-
severity: "
|
|
1503
|
-
type: "
|
|
1504
|
-
title: "
|
|
1505
|
-
desc: `${
|
|
1506
|
-
hint: "
|
|
1507
|
-
nav: "
|
|
1635
|
+
severity: "warning",
|
|
1636
|
+
type: "high-rows",
|
|
1637
|
+
title: "Large Result Set",
|
|
1638
|
+
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
|
|
1639
|
+
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
|
|
1640
|
+
nav: "queries"
|
|
1508
1641
|
});
|
|
1509
1642
|
}
|
|
1643
|
+
return insights;
|
|
1510
1644
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
// src/analysis/insights/rules/response-overfetch.ts
|
|
1648
|
+
var responseOverfetchRule = {
|
|
1649
|
+
id: "response-overfetch",
|
|
1650
|
+
check(ctx) {
|
|
1651
|
+
const insights = [];
|
|
1652
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1653
|
+
for (const r of ctx.nonStatic) {
|
|
1654
|
+
if (r.statusCode >= 400 || !r.responseBody) continue;
|
|
1655
|
+
const ep = getEndpointKey(r.method, r.path);
|
|
1656
|
+
if (seen.has(ep)) continue;
|
|
1657
|
+
let parsed;
|
|
1658
|
+
try {
|
|
1659
|
+
parsed = JSON.parse(r.responseBody);
|
|
1660
|
+
} catch {
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
const target = unwrapResponse(parsed);
|
|
1664
|
+
const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
1665
|
+
if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
|
|
1666
|
+
const fields = Object.keys(inspectObj);
|
|
1667
|
+
if (fields.length < OVERFETCH_MIN_FIELDS) continue;
|
|
1668
|
+
let internalIdCount = 0;
|
|
1669
|
+
let nullCount = 0;
|
|
1670
|
+
for (const key of fields) {
|
|
1671
|
+
if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
|
|
1672
|
+
const val = inspectObj[key];
|
|
1673
|
+
if (val === null || val === void 0) nullCount++;
|
|
1674
|
+
}
|
|
1675
|
+
const nullRatio = nullCount / fields.length;
|
|
1676
|
+
const reasons = [];
|
|
1677
|
+
if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
|
|
1678
|
+
if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
|
|
1679
|
+
if (reasons.length === 0 && fields.length >= OVERFETCH_MANY_FIELDS) {
|
|
1680
|
+
reasons.push(`${fields.length} fields returned`);
|
|
1681
|
+
}
|
|
1682
|
+
if (reasons.length > 0) {
|
|
1683
|
+
seen.add(ep);
|
|
1684
|
+
insights.push({
|
|
1685
|
+
severity: "info",
|
|
1686
|
+
type: "response-overfetch",
|
|
1687
|
+
title: "Response Overfetch",
|
|
1688
|
+
desc: `${ep} \u2014 ${reasons.join(", ")}`,
|
|
1689
|
+
hint: "This response returns more data than the client likely needs. Use a DTO or select only required fields to reduce payload size and avoid leaking internal structure.",
|
|
1690
|
+
nav: "requests"
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1523
1693
|
}
|
|
1694
|
+
return insights;
|
|
1524
1695
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
// src/analysis/insights/rules/large-response.ts
|
|
1699
|
+
var largeResponseRule = {
|
|
1700
|
+
id: "large-response",
|
|
1701
|
+
check(ctx) {
|
|
1702
|
+
const insights = [];
|
|
1703
|
+
for (const [ep, g] of ctx.endpointGroups) {
|
|
1704
|
+
if (g.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1705
|
+
const avgSize = Math.round(g.totalSize / g.total);
|
|
1706
|
+
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
1707
|
+
insights.push({
|
|
1708
|
+
severity: "info",
|
|
1709
|
+
type: "large-response",
|
|
1710
|
+
title: "Large Response",
|
|
1711
|
+
desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
|
|
1712
|
+
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
|
|
1713
|
+
nav: "requests"
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1535
1716
|
}
|
|
1717
|
+
return insights;
|
|
1536
1718
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
// src/analysis/insights/rules/regression.ts
|
|
1722
|
+
var regressionRule = {
|
|
1723
|
+
id: "regression",
|
|
1724
|
+
check(ctx) {
|
|
1725
|
+
if (!ctx.previousMetrics || ctx.previousMetrics.length === 0) return [];
|
|
1726
|
+
const insights = [];
|
|
1727
|
+
for (const epMetrics of ctx.previousMetrics) {
|
|
1728
|
+
if (epMetrics.sessions.length < 2) continue;
|
|
1729
|
+
const prev = epMetrics.sessions[epMetrics.sessions.length - 2];
|
|
1730
|
+
const current = epMetrics.sessions[epMetrics.sessions.length - 1];
|
|
1731
|
+
if (prev.requestCount < REGRESSION_MIN_REQUESTS || current.requestCount < REGRESSION_MIN_REQUESTS) continue;
|
|
1732
|
+
const p95Increase = current.p95DurationMs - prev.p95DurationMs;
|
|
1733
|
+
const p95PctChange = prev.p95DurationMs > 0 ? Math.round(p95Increase / prev.p95DurationMs * 100) : 0;
|
|
1734
|
+
if (p95Increase >= REGRESSION_MIN_INCREASE_MS && p95PctChange >= REGRESSION_PCT_THRESHOLD) {
|
|
1735
|
+
insights.push({
|
|
1736
|
+
severity: "warning",
|
|
1737
|
+
type: "regression",
|
|
1738
|
+
title: "Performance Regression",
|
|
1739
|
+
desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
|
|
1740
|
+
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing.",
|
|
1741
|
+
nav: "graph"
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
|
|
1745
|
+
insights.push({
|
|
1746
|
+
severity: "warning",
|
|
1747
|
+
type: "regression",
|
|
1748
|
+
title: "Query Count Regression",
|
|
1749
|
+
desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
|
|
1750
|
+
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations.",
|
|
1751
|
+
nav: "queries"
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
return insights;
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
// src/analysis/insights/rules/security.ts
|
|
1760
|
+
var securityRule = {
|
|
1761
|
+
id: "security",
|
|
1762
|
+
check(ctx) {
|
|
1763
|
+
if (!ctx.securityFindings) return [];
|
|
1764
|
+
return ctx.securityFindings.map((f) => ({
|
|
1765
|
+
severity: f.severity,
|
|
1766
|
+
type: "security",
|
|
1767
|
+
title: f.title,
|
|
1768
|
+
desc: f.desc,
|
|
1769
|
+
hint: f.hint,
|
|
1770
|
+
nav: "security"
|
|
1771
|
+
}));
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
// src/analysis/insights/index.ts
|
|
1776
|
+
function createDefaultInsightRunner() {
|
|
1777
|
+
const runner = new InsightRunner();
|
|
1778
|
+
runner.register(n1Rule);
|
|
1779
|
+
runner.register(crossEndpointRule);
|
|
1780
|
+
runner.register(redundantQueryRule);
|
|
1781
|
+
runner.register(errorRule);
|
|
1782
|
+
runner.register(errorHotspotRule);
|
|
1783
|
+
runner.register(duplicateRule);
|
|
1784
|
+
runner.register(slowRule);
|
|
1785
|
+
runner.register(queryHeavyRule);
|
|
1786
|
+
runner.register(selectStarRule);
|
|
1787
|
+
runner.register(highRowsRule);
|
|
1788
|
+
runner.register(responseOverfetchRule);
|
|
1789
|
+
runner.register(largeResponseRule);
|
|
1790
|
+
runner.register(regressionRule);
|
|
1791
|
+
runner.register(securityRule);
|
|
1792
|
+
return runner;
|
|
1793
|
+
}
|
|
1794
|
+
function computeInsights(ctx) {
|
|
1795
|
+
return createDefaultInsightRunner().run(ctx);
|
|
1540
1796
|
}
|
|
1541
1797
|
|
|
1542
1798
|
// src/analysis/engine.ts
|
|
1543
1799
|
var AnalysisEngine = class {
|
|
1544
|
-
constructor(debounceMs = 300) {
|
|
1800
|
+
constructor(metricsStore, debounceMs = 300) {
|
|
1801
|
+
this.metricsStore = metricsStore;
|
|
1545
1802
|
this.debounceMs = debounceMs;
|
|
1546
1803
|
this.scanner = createDefaultScanner();
|
|
1547
1804
|
this.boundRequestListener = () => this.scheduleRecompute();
|
|
@@ -1599,6 +1856,7 @@ var AnalysisEngine = class {
|
|
|
1599
1856
|
const queries = defaultQueryStore.getAll();
|
|
1600
1857
|
const errors = defaultErrorStore.getAll();
|
|
1601
1858
|
const logs = defaultLogStore.getAll();
|
|
1859
|
+
const fetches = defaultFetchStore.getAll();
|
|
1602
1860
|
const flows = groupRequestsIntoFlows(requests);
|
|
1603
1861
|
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
1604
1862
|
this.cachedInsights = computeInsights({
|
|
@@ -1606,6 +1864,8 @@ var AnalysisEngine = class {
|
|
|
1606
1864
|
queries,
|
|
1607
1865
|
errors,
|
|
1608
1866
|
flows,
|
|
1867
|
+
fetches,
|
|
1868
|
+
previousMetrics: this.metricsStore.getAll(),
|
|
1609
1869
|
securityFindings: this.cachedFindings
|
|
1610
1870
|
});
|
|
1611
1871
|
for (const fn of this.listeners) {
|
|
@@ -1618,13 +1878,15 @@ var AnalysisEngine = class {
|
|
|
1618
1878
|
};
|
|
1619
1879
|
|
|
1620
1880
|
// src/index.ts
|
|
1621
|
-
var VERSION = "0.7.
|
|
1881
|
+
var VERSION = "0.7.6";
|
|
1622
1882
|
export {
|
|
1623
1883
|
AdapterRegistry,
|
|
1624
1884
|
AnalysisEngine,
|
|
1885
|
+
InsightRunner,
|
|
1625
1886
|
SecurityScanner,
|
|
1626
1887
|
VERSION,
|
|
1627
1888
|
computeInsights,
|
|
1889
|
+
createDefaultInsightRunner,
|
|
1628
1890
|
createDefaultScanner,
|
|
1629
1891
|
detectProject
|
|
1630
1892
|
};
|