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