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.
@@ -33,7 +33,7 @@ var init_routes = __esm({
33
33
  });
34
34
 
35
35
  // src/constants/limits.ts
36
- var MAX_REQUEST_ENTRIES, DEFAULT_MAX_BODY_CAPTURE, DEFAULT_API_LIMIT, MAX_TELEMETRY_ENTRIES;
36
+ var MAX_REQUEST_ENTRIES, DEFAULT_MAX_BODY_CAPTURE, DEFAULT_API_LIMIT, MAX_TELEMETRY_ENTRIES, MAX_TAB_NAME_LENGTH;
37
37
  var init_limits = __esm({
38
38
  "src/constants/limits.ts"() {
39
39
  "use strict";
@@ -41,11 +41,12 @@ var init_limits = __esm({
41
41
  DEFAULT_MAX_BODY_CAPTURE = 10240;
42
42
  DEFAULT_API_LIMIT = 500;
43
43
  MAX_TELEMETRY_ENTRIES = 1e3;
44
+ MAX_TAB_NAME_LENGTH = 32;
44
45
  }
45
46
  });
46
47
 
47
48
  // src/constants/thresholds.ts
48
- var FLOW_GAP_MS, SLOW_REQUEST_THRESHOLD_MS, MIN_POLLING_SEQUENCE, ENDPOINT_TRUNCATE_LENGTH, N1_QUERY_THRESHOLD, ERROR_RATE_THRESHOLD_PCT, SLOW_ENDPOINT_THRESHOLD_MS, MIN_REQUESTS_FOR_INSIGHT, HIGH_QUERY_COUNT_PER_REQ, CROSS_ENDPOINT_MIN_ENDPOINTS, CROSS_ENDPOINT_PCT, CROSS_ENDPOINT_MIN_OCCURRENCES, REDUNDANT_QUERY_MIN_COUNT, LARGE_RESPONSE_BYTES, HIGH_ROW_COUNT, OVERFETCH_MIN_REQUESTS, OVERFETCH_MIN_FIELDS, OVERFETCH_MIN_INTERNAL_IDS, OVERFETCH_NULL_RATIO;
49
+ var FLOW_GAP_MS, SLOW_REQUEST_THRESHOLD_MS, MIN_POLLING_SEQUENCE, ENDPOINT_TRUNCATE_LENGTH, N1_QUERY_THRESHOLD, ERROR_RATE_THRESHOLD_PCT, SLOW_ENDPOINT_THRESHOLD_MS, MIN_REQUESTS_FOR_INSIGHT, HIGH_QUERY_COUNT_PER_REQ, CROSS_ENDPOINT_MIN_ENDPOINTS, CROSS_ENDPOINT_PCT, CROSS_ENDPOINT_MIN_OCCURRENCES, REDUNDANT_QUERY_MIN_COUNT, LARGE_RESPONSE_BYTES, HIGH_ROW_COUNT, OVERFETCH_MIN_REQUESTS, OVERFETCH_MIN_FIELDS, OVERFETCH_MIN_INTERNAL_IDS, OVERFETCH_NULL_RATIO, REGRESSION_PCT_THRESHOLD, REGRESSION_MIN_INCREASE_MS, REGRESSION_MIN_REQUESTS, QUERY_COUNT_REGRESSION_RATIO, OVERFETCH_MANY_FIELDS, OVERFETCH_UNWRAP_MIN_SIZE, MAX_DUPLICATE_INSIGHTS;
49
50
  var init_thresholds = __esm({
50
51
  "src/constants/thresholds.ts"() {
51
52
  "use strict";
@@ -68,6 +69,13 @@ var init_thresholds = __esm({
68
69
  OVERFETCH_MIN_FIELDS = 8;
69
70
  OVERFETCH_MIN_INTERNAL_IDS = 2;
70
71
  OVERFETCH_NULL_RATIO = 0.3;
72
+ REGRESSION_PCT_THRESHOLD = 50;
73
+ REGRESSION_MIN_INCREASE_MS = 200;
74
+ REGRESSION_MIN_REQUESTS = 5;
75
+ QUERY_COUNT_REGRESSION_RATIO = 1.5;
76
+ OVERFETCH_MANY_FIELDS = 12;
77
+ OVERFETCH_UNWRAP_MIN_SIZE = 3;
78
+ MAX_DUPLICATE_INSIGHTS = 3;
71
79
  }
72
80
  });
73
81
 
@@ -154,20 +162,6 @@ var init_constants = __esm({
154
162
  }
155
163
  });
156
164
 
157
- // src/instrument/hooks/context.ts
158
- import { AsyncLocalStorage } from "async_hooks";
159
- import { randomUUID } from "crypto";
160
- function getRequestContext() {
161
- return storage.getStore();
162
- }
163
- var storage;
164
- var init_context = __esm({
165
- "src/instrument/hooks/context.ts"() {
166
- "use strict";
167
- storage = new AsyncLocalStorage();
168
- }
169
- });
170
-
171
165
  // src/instrument/transport.ts
172
166
  function setEmitter(fn) {
173
167
  emitter = fn;
@@ -183,6 +177,20 @@ var init_transport2 = __esm({
183
177
  }
184
178
  });
185
179
 
180
+ // src/instrument/hooks/context.ts
181
+ import { AsyncLocalStorage } from "async_hooks";
182
+ import { randomUUID } from "crypto";
183
+ function getRequestContext() {
184
+ return storage.getStore();
185
+ }
186
+ var storage;
187
+ var init_context = __esm({
188
+ "src/instrument/hooks/context.ts"() {
189
+ "use strict";
190
+ storage = new AsyncLocalStorage();
191
+ }
192
+ });
193
+
186
194
  // src/instrument/hooks/fetch.ts
187
195
  import { subscribe } from "diagnostics_channel";
188
196
  function isNoise(origin) {
@@ -770,7 +778,7 @@ var init_request_store = __esm({
770
778
  responseHeaders: flattenHeaders(input.responseHeaders),
771
779
  responseBody: responseBodyStr,
772
780
  startedAt: input.startTime,
773
- durationMs: Math.round(performance.now() - input.startTime),
781
+ durationMs: Math.round((input.endTime ?? performance.now()) - input.startTime),
774
782
  responseSize: input.responseBody?.length ?? 0,
775
783
  isStatic: isStaticPath(path)
776
784
  };
@@ -1297,25 +1305,52 @@ var init_math = __esm({
1297
1305
  }
1298
1306
  });
1299
1307
 
1308
+ // src/utils/endpoint.ts
1309
+ function getEndpointKey(method, path) {
1310
+ return `${method} ${path}`;
1311
+ }
1312
+ var init_endpoint = __esm({
1313
+ "src/utils/endpoint.ts"() {
1314
+ "use strict";
1315
+ }
1316
+ });
1317
+
1300
1318
  // src/store/metrics/metrics-store.ts
1301
1319
  import { randomUUID as randomUUID4 } from "crypto";
1320
+ function createAccumulator() {
1321
+ return {
1322
+ durations: [],
1323
+ queryCounts: [],
1324
+ errorCount: 0,
1325
+ totalDurationSum: 0,
1326
+ totalRequestCount: 0,
1327
+ totalErrorCount: 0,
1328
+ totalQuerySum: 0,
1329
+ totalQueryTimeMs: 0,
1330
+ totalFetchTimeMs: 0
1331
+ };
1332
+ }
1302
1333
  var MetricsStore;
1303
1334
  var init_metrics_store = __esm({
1304
1335
  "src/store/metrics/metrics-store.ts"() {
1305
1336
  "use strict";
1306
1337
  init_constants();
1307
1338
  init_math();
1339
+ init_endpoint();
1308
1340
  MetricsStore = class {
1309
1341
  constructor(persistence) {
1310
1342
  this.persistence = persistence;
1311
1343
  this.data = persistence.load();
1344
+ for (const ep of this.data.endpoints) {
1345
+ this.endpointIndex.set(ep.endpoint, ep);
1346
+ }
1312
1347
  }
1313
1348
  data;
1349
+ endpointIndex = /* @__PURE__ */ new Map();
1314
1350
  sessionId = randomUUID4();
1315
1351
  sessionStart = Date.now();
1316
1352
  flushTimer = null;
1317
1353
  accumulators = /* @__PURE__ */ new Map();
1318
- /** Pending data points not yet flushed to disk. */
1319
1354
  pendingPoints = /* @__PURE__ */ new Map();
1320
1355
  start() {
1321
1356
  this.flushTimer = setInterval(
@@ -1329,19 +1364,25 @@ var init_metrics_store = __esm({
1329
1364
  clearInterval(this.flushTimer);
1330
1365
  this.flushTimer = null;
1331
1366
  }
1332
- this.flush();
1367
+ this.flush(true);
1333
1368
  }
1334
- recordRequest(req, queryCount) {
1369
+ recordRequest(req, metrics) {
1335
1370
  if (req.isStatic) return;
1336
- const key = req.method + " " + req.path;
1371
+ const key = getEndpointKey(req.method, req.path);
1337
1372
  let acc = this.accumulators.get(key);
1338
1373
  if (!acc) {
1339
- acc = { durations: [], errorCount: 0, queryCounts: [] };
1374
+ acc = createAccumulator();
1340
1375
  this.accumulators.set(key, acc);
1341
1376
  }
1342
1377
  acc.durations.push(req.durationMs);
1343
- acc.queryCounts.push(queryCount);
1378
+ acc.queryCounts.push(metrics.queryCount);
1344
1379
  if (req.statusCode >= 400) acc.errorCount++;
1380
+ acc.totalDurationSum += req.durationMs;
1381
+ acc.totalRequestCount++;
1382
+ acc.totalQuerySum += metrics.queryCount;
1383
+ acc.totalQueryTimeMs += metrics.queryTimeMs;
1384
+ acc.totalFetchTimeMs += metrics.fetchTimeMs;
1385
+ if (req.statusCode >= 400) acc.totalErrorCount++;
1345
1386
  const timestamp = Math.round(
1346
1387
  Date.now() - (performance.now() - req.startedAt)
1347
1388
  );
@@ -1349,7 +1390,9 @@ var init_metrics_store = __esm({
1349
1390
  timestamp,
1350
1391
  durationMs: req.durationMs,
1351
1392
  statusCode: req.statusCode,
1352
- queryCount
1393
+ queryCount: metrics.queryCount,
1394
+ queryTimeMs: metrics.queryTimeMs,
1395
+ fetchTimeMs: metrics.fetchTimeMs
1353
1396
  };
1354
1397
  let pending2 = this.pendingPoints.get(key);
1355
1398
  if (!pending2) {
@@ -1362,20 +1405,18 @@ var init_metrics_store = __esm({
1362
1405
  return this.data.endpoints;
1363
1406
  }
1364
1407
  getEndpoint(endpoint) {
1365
- return this.data.endpoints.find((e) => e.endpoint === endpoint);
1408
+ return this.endpointIndex.get(endpoint);
1366
1409
  }
1367
- /** Returns live per-request data for the performance tab. */
1368
1410
  getLiveEndpoints() {
1369
1411
  const merged = /* @__PURE__ */ new Map();
1370
1412
  for (const ep of this.data.endpoints) {
1371
1413
  if (ep.dataPoints && ep.dataPoints.length > 0) {
1372
- merged.set(ep.endpoint, [...ep.dataPoints]);
1414
+ merged.set(ep.endpoint, ep.dataPoints);
1373
1415
  }
1374
1416
  }
1375
1417
  for (const [endpoint, points] of this.pendingPoints) {
1376
- const existing = merged.get(endpoint) || [];
1377
- existing.push(...points);
1378
- merged.set(endpoint, existing);
1418
+ const existing = merged.get(endpoint);
1419
+ merged.set(endpoint, existing ? existing.concat(points) : points);
1379
1420
  }
1380
1421
  const endpoints = [];
1381
1422
  for (const [endpoint, requests] of merged) {
@@ -1383,14 +1424,23 @@ var init_metrics_store = __esm({
1383
1424
  const durations = requests.map((r) => r.durationMs);
1384
1425
  const errors = requests.filter((r) => r.statusCode >= 400).length;
1385
1426
  const totalQueries = requests.reduce((s, r) => s + r.queryCount, 0);
1427
+ const totalQueryTime = requests.reduce((s, r) => s + (r.queryTimeMs ?? 0), 0);
1428
+ const totalFetchTime = requests.reduce((s, r) => s + (r.fetchTimeMs ?? 0), 0);
1429
+ const n = requests.length;
1430
+ const avgDurationMs = Math.round(durations.reduce((s, d) => s + d, 0) / n);
1431
+ const avgQueryTimeMs = Math.round(totalQueryTime / n);
1432
+ const avgFetchTimeMs = Math.round(totalFetchTime / n);
1386
1433
  endpoints.push({
1387
1434
  endpoint,
1388
1435
  requests,
1389
1436
  summary: {
1390
1437
  p95Ms: percentile(durations, 0.95),
1391
- errorRate: requests.length > 0 ? errors / requests.length : 0,
1392
- avgQueryCount: requests.length > 0 ? Math.round(totalQueries / requests.length) : 0,
1393
- totalRequests: requests.length
1438
+ errorRate: errors / n,
1439
+ avgQueryCount: Math.round(totalQueries / n),
1440
+ totalRequests: n,
1441
+ avgQueryTimeMs,
1442
+ avgFetchTimeMs,
1443
+ avgAppTimeMs: Math.max(0, avgDurationMs - avgQueryTimeMs - avgFetchTimeMs)
1394
1444
  }
1395
1445
  });
1396
1446
  }
@@ -1399,33 +1449,27 @@ var init_metrics_store = __esm({
1399
1449
  }
1400
1450
  reset() {
1401
1451
  this.data = { version: 1, endpoints: [] };
1452
+ this.endpointIndex.clear();
1402
1453
  this.accumulators.clear();
1403
1454
  this.pendingPoints.clear();
1404
1455
  this.persistence.remove();
1405
1456
  }
1406
- flush() {
1457
+ flush(sync = false) {
1407
1458
  for (const [endpoint, acc] of this.accumulators) {
1408
1459
  if (acc.durations.length === 0) continue;
1460
+ const n = acc.totalRequestCount;
1409
1461
  const session = {
1410
1462
  sessionId: this.sessionId,
1411
1463
  startedAt: this.sessionStart,
1412
- avgDurationMs: Math.round(
1413
- acc.durations.reduce((s, d) => s + d, 0) / acc.durations.length
1414
- ),
1464
+ avgDurationMs: Math.round(acc.totalDurationSum / n),
1415
1465
  p95DurationMs: percentile(acc.durations, 0.95),
1416
- requestCount: acc.durations.length,
1417
- errorCount: acc.errorCount,
1418
- avgQueryCount: acc.queryCounts.length > 0 ? Math.round(
1419
- acc.queryCounts.reduce((s, c) => s + c, 0) / acc.queryCounts.length
1420
- ) : 0
1466
+ requestCount: n,
1467
+ errorCount: acc.totalErrorCount,
1468
+ avgQueryCount: n > 0 ? Math.round(acc.totalQuerySum / n) : 0,
1469
+ avgQueryTimeMs: n > 0 ? Math.round(acc.totalQueryTimeMs / n) : 0,
1470
+ avgFetchTimeMs: n > 0 ? Math.round(acc.totalFetchTimeMs / n) : 0
1421
1471
  };
1422
- let epMetrics = this.data.endpoints.find(
1423
- (e) => e.endpoint === endpoint
1424
- );
1425
- if (!epMetrics) {
1426
- epMetrics = { endpoint, sessions: [] };
1427
- this.data.endpoints.push(epMetrics);
1428
- }
1472
+ const epMetrics = this.getOrCreateEndpoint(endpoint);
1429
1473
  const existingIdx = epMetrics.sessions.findIndex(
1430
1474
  (s) => s.sessionId === this.sessionId
1431
1475
  );
@@ -1437,23 +1481,31 @@ var init_metrics_store = __esm({
1437
1481
  if (epMetrics.sessions.length > METRICS_MAX_SESSIONS) {
1438
1482
  epMetrics.sessions = epMetrics.sessions.slice(-METRICS_MAX_SESSIONS);
1439
1483
  }
1484
+ acc.durations.length = 0;
1485
+ acc.queryCounts.length = 0;
1486
+ acc.errorCount = 0;
1440
1487
  }
1441
1488
  for (const [endpoint, points] of this.pendingPoints) {
1442
1489
  if (points.length === 0) continue;
1443
- let epMetrics = this.data.endpoints.find(
1444
- (e) => e.endpoint === endpoint
1445
- );
1446
- if (!epMetrics) {
1447
- epMetrics = { endpoint, sessions: [] };
1448
- this.data.endpoints.push(epMetrics);
1449
- }
1450
- const existing = epMetrics.dataPoints || [];
1451
- epMetrics.dataPoints = [...existing, ...points].slice(
1452
- -METRICS_MAX_DATA_POINTS
1453
- );
1490
+ const epMetrics = this.getOrCreateEndpoint(endpoint);
1491
+ const existing = epMetrics.dataPoints ?? [];
1492
+ epMetrics.dataPoints = existing.concat(points).slice(-METRICS_MAX_DATA_POINTS);
1454
1493
  }
1455
1494
  this.pendingPoints.clear();
1456
- this.persistence.save(this.data);
1495
+ if (sync) {
1496
+ this.persistence.saveSync(this.data);
1497
+ } else {
1498
+ this.persistence.save(this.data);
1499
+ }
1500
+ }
1501
+ getOrCreateEndpoint(endpoint) {
1502
+ let ep = this.endpointIndex.get(endpoint);
1503
+ if (!ep) {
1504
+ ep = { endpoint, sessions: [] };
1505
+ this.data.endpoints.push(ep);
1506
+ this.endpointIndex.set(endpoint, ep);
1507
+ }
1508
+ return ep;
1457
1509
  }
1458
1510
  };
1459
1511
  }
@@ -1488,8 +1540,10 @@ import {
1488
1540
  writeFileSync as writeFileSync2,
1489
1541
  mkdirSync as mkdirSync2,
1490
1542
  existsSync as existsSync2,
1491
- unlinkSync
1543
+ unlinkSync,
1544
+ renameSync
1492
1545
  } from "fs";
1546
+ import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
1493
1547
  import { resolve as resolve2 } from "path";
1494
1548
  var FileMetricsPersistence;
1495
1549
  var init_persistence = __esm({
@@ -1500,9 +1554,13 @@ var init_persistence = __esm({
1500
1554
  FileMetricsPersistence = class {
1501
1555
  metricsDir;
1502
1556
  metricsPath;
1557
+ tmpPath;
1558
+ writing = false;
1559
+ pendingData = null;
1503
1560
  constructor(rootDir) {
1504
1561
  this.metricsDir = resolve2(rootDir, METRICS_DIR);
1505
1562
  this.metricsPath = resolve2(rootDir, METRICS_FILE);
1563
+ this.tmpPath = this.metricsPath + ".tmp";
1506
1564
  }
1507
1565
  load() {
1508
1566
  try {
@@ -1513,18 +1571,27 @@ var init_persistence = __esm({
1513
1571
  return parsed;
1514
1572
  }
1515
1573
  }
1516
- } catch {
1574
+ } catch (err) {
1575
+ process.stderr.write(`[brakit] failed to load metrics: ${err.message}
1576
+ `);
1517
1577
  }
1518
1578
  return { version: 1, endpoints: [] };
1519
1579
  }
1520
1580
  save(data) {
1581
+ if (this.writing) {
1582
+ this.pendingData = data;
1583
+ return;
1584
+ }
1585
+ this.writeAsync(data);
1586
+ }
1587
+ saveSync(data) {
1521
1588
  try {
1522
- if (!existsSync2(this.metricsDir)) {
1523
- mkdirSync2(this.metricsDir, { recursive: true });
1524
- ensureGitignore(this.metricsDir, METRICS_DIR);
1525
- }
1526
- writeFileSync2(this.metricsPath, JSON.stringify(data, null, 2));
1527
- } catch {
1589
+ this.ensureDir();
1590
+ writeFileSync2(this.tmpPath, JSON.stringify(data));
1591
+ renameSync(this.tmpPath, this.metricsPath);
1592
+ } catch (err) {
1593
+ process.stderr.write(`[brakit] failed to save metrics: ${err.message}
1594
+ `);
1528
1595
  }
1529
1596
  }
1530
1597
  remove() {
@@ -1535,6 +1602,33 @@ var init_persistence = __esm({
1535
1602
  } catch {
1536
1603
  }
1537
1604
  }
1605
+ async writeAsync(data) {
1606
+ this.writing = true;
1607
+ try {
1608
+ if (!existsSync2(this.metricsDir)) {
1609
+ await mkdir(this.metricsDir, { recursive: true });
1610
+ ensureGitignore(this.metricsDir, METRICS_DIR);
1611
+ }
1612
+ await writeFile2(this.tmpPath, JSON.stringify(data));
1613
+ await rename(this.tmpPath, this.metricsPath);
1614
+ } catch (err) {
1615
+ process.stderr.write(`[brakit] failed to save metrics: ${err.message}
1616
+ `);
1617
+ } finally {
1618
+ this.writing = false;
1619
+ if (this.pendingData) {
1620
+ const next = this.pendingData;
1621
+ this.pendingData = null;
1622
+ this.writeAsync(next);
1623
+ }
1624
+ }
1625
+ }
1626
+ ensureDir() {
1627
+ if (!existsSync2(this.metricsDir)) {
1628
+ mkdirSync2(this.metricsDir, { recursive: true });
1629
+ ensureGitignore(this.metricsDir, METRICS_DIR);
1630
+ }
1631
+ }
1538
1632
  };
1539
1633
  }
1540
1634
  });
@@ -2056,6 +2150,7 @@ function getBaseStyles() {
2056
2150
  --shadow-sm:0 1px 2px rgba(0,0,0,0.05);
2057
2151
  --shadow-md:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04);
2058
2152
  --shadow-lg:0 4px 12px rgba(0,0,0,0.08),0 1px 4px rgba(0,0,0,0.04);
2153
+ --breakdown-db:#6366f1;--breakdown-fetch:#f59e0b;--breakdown-app:#94a3b8;
2059
2154
  --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
2060
2155
  --sans:Inter,system-ui,-apple-system,sans-serif;
2061
2156
  }
@@ -2384,6 +2479,30 @@ function getPerformanceStyles() {
2384
2479
  .perf-metric-label{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);font-family:var(--sans);font-weight:600}
2385
2480
  .perf-metric-value{font-size:21px;font-weight:700;font-family:var(--mono)}
2386
2481
 
2482
+ /* Time breakdown */
2483
+ .perf-breakdown{padding:16px 28px;border-bottom:1px solid var(--border-subtle)}
2484
+ .perf-breakdown-bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:var(--bg-muted);border:1px solid var(--border)}
2485
+ .perf-breakdown-bar-sm{height:6px;border-radius:3px;flex:1}
2486
+ .perf-breakdown-seg{min-width:2px;transition:width .3s}
2487
+ .perf-breakdown-db{background:var(--breakdown-db)}
2488
+ .perf-breakdown-fetch{background:var(--breakdown-fetch)}
2489
+ .perf-breakdown-app{background:var(--breakdown-app)}
2490
+ .perf-breakdown-legend{display:flex;gap:16px;margin-top:8px;font-size:11px;font-family:var(--mono);color:var(--text-muted)}
2491
+ .perf-breakdown-item{display:flex;align-items:center;gap:5px}
2492
+ .perf-breakdown-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
2493
+ span.perf-breakdown-dot.perf-breakdown-db{background:var(--breakdown-db)}
2494
+ span.perf-breakdown-dot.perf-breakdown-fetch{background:var(--breakdown-fetch)}
2495
+ span.perf-breakdown-dot.perf-breakdown-app{background:var(--breakdown-app)}
2496
+ .perf-breakdown-inline{margin:0 0 8px;display:flex;align-items:center;gap:10px}
2497
+ .perf-breakdown-labels{display:flex;gap:8px;font-size:10px;font-family:var(--mono);color:var(--text-muted);flex-shrink:0}
2498
+ .perf-breakdown-lbl{display:flex;align-items:center;gap:3px}
2499
+ .perf-col-breakdown{flex:1;min-width:140px;display:flex;align-items:center;gap:4px;flex-wrap:wrap}
2500
+ .perf-bd-tag{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;font-size:10px;font-family:var(--mono);font-weight:500;white-space:nowrap}
2501
+ .perf-bd-tag-db{color:#818cf8;background:rgba(99,102,241,0.1)}
2502
+ .perf-bd-tag-fetch{color:#fbbf24;background:rgba(245,158,11,0.1)}
2503
+ .perf-bd-tag-app{color:var(--breakdown-app);background:rgba(148,163,184,0.1)}
2504
+ .perf-col-muted{color:var(--text-dim)}
2505
+
2387
2506
  /* Chart */
2388
2507
  .perf-chart-wrap{padding:16px 28px}
2389
2508
  .perf-canvas{border-radius:var(--radius);background:var(--bg-muted);border:1px solid var(--border)}
@@ -2579,7 +2698,7 @@ var init_project = __esm({
2579
2698
  });
2580
2699
 
2581
2700
  // src/analysis/rules/patterns.ts
2582
- var SECRET_KEYS, TOKEN_PARAMS, SAFE_PARAMS, STACK_TRACE_RE, DB_CONN_RE, SQL_FRAGMENT_RE, SECRET_VAL_RE, LOG_SECRET_RE, MASKED_RE, EMAIL_RE, INTERNAL_ID_KEYS, INTERNAL_ID_SUFFIX, RULE_HINTS;
2701
+ var SECRET_KEYS, TOKEN_PARAMS, SAFE_PARAMS, STACK_TRACE_RE, DB_CONN_RE, SQL_FRAGMENT_RE, SECRET_VAL_RE, LOG_SECRET_RE, MASKED_RE, EMAIL_RE, INTERNAL_ID_KEYS, INTERNAL_ID_SUFFIX, SELECT_STAR_RE, SELECT_DOT_STAR_RE, RULE_HINTS;
2583
2702
  var init_patterns = __esm({
2584
2703
  "src/analysis/rules/patterns.ts"() {
2585
2704
  "use strict";
@@ -2595,6 +2714,8 @@ var init_patterns = __esm({
2595
2714
  EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
2596
2715
  INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
2597
2716
  INTERNAL_ID_SUFFIX = /Id$|_id$/;
2717
+ SELECT_STAR_RE = /^SELECT\s+\*/i;
2718
+ SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
2598
2719
  RULE_HINTS = {
2599
2720
  "exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
2600
2721
  "token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
@@ -2955,6 +3076,36 @@ var init_cors_credentials = __esm({
2955
3076
  }
2956
3077
  });
2957
3078
 
3079
+ // src/utils/response.ts
3080
+ function unwrapResponse(parsed) {
3081
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
3082
+ const obj = parsed;
3083
+ const keys = Object.keys(obj);
3084
+ if (keys.length > 3) return parsed;
3085
+ let best = null;
3086
+ let bestSize = 0;
3087
+ for (const key of keys) {
3088
+ const val = obj[key];
3089
+ if (Array.isArray(val) && val.length > bestSize) {
3090
+ best = val;
3091
+ bestSize = val.length;
3092
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
3093
+ const size = Object.keys(val).length;
3094
+ if (size > bestSize) {
3095
+ best = val;
3096
+ bestSize = size;
3097
+ }
3098
+ }
3099
+ }
3100
+ return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
3101
+ }
3102
+ var init_response = __esm({
3103
+ "src/utils/response.ts"() {
3104
+ "use strict";
3105
+ init_thresholds();
3106
+ }
3107
+ });
3108
+
2958
3109
  // src/analysis/rules/response-pii-leak.ts
2959
3110
  function tryParseJson2(body) {
2960
3111
  if (!body) return null;
@@ -2996,28 +3147,6 @@ function hasInternalIds(obj) {
2996
3147
  }
2997
3148
  return false;
2998
3149
  }
2999
- function unwrapResponse(parsed) {
3000
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
3001
- const obj = parsed;
3002
- const keys = Object.keys(obj);
3003
- if (keys.length > 3) return parsed;
3004
- let best = null;
3005
- let bestSize = 0;
3006
- for (const key of keys) {
3007
- const val = obj[key];
3008
- if (Array.isArray(val) && val.length > bestSize) {
3009
- best = val;
3010
- bestSize = val.length;
3011
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
3012
- const size = Object.keys(val).length;
3013
- if (size > bestSize) {
3014
- best = val;
3015
- bestSize = size;
3016
- }
3017
- }
3018
- }
3019
- return best && bestSize >= 3 ? best : parsed;
3020
- }
3021
3150
  function detectPII(method, reqBody, resBody) {
3022
3151
  const target = unwrapResponse(resBody);
3023
3152
  if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
@@ -3065,6 +3194,7 @@ var init_response_pii_leak = __esm({
3065
3194
  "src/analysis/rules/response-pii-leak.ts"() {
3066
3195
  "use strict";
3067
3196
  init_patterns();
3197
+ init_response();
3068
3198
  WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
3069
3199
  FULL_RECORD_MIN_FIELDS = 5;
3070
3200
  LIST_PII_MIN_ITEMS = 2;
@@ -3176,7 +3306,28 @@ var init_rules = __esm({
3176
3306
  }
3177
3307
  });
3178
3308
 
3179
- // src/analysis/insights.ts
3309
+ // src/utils/collections.ts
3310
+ function groupBy(items, keyFn) {
3311
+ const map = /* @__PURE__ */ new Map();
3312
+ for (const item of items) {
3313
+ const key = keyFn(item);
3314
+ if (key == null) continue;
3315
+ let arr = map.get(key);
3316
+ if (!arr) {
3317
+ arr = [];
3318
+ map.set(key, arr);
3319
+ }
3320
+ arr.push(item);
3321
+ }
3322
+ return map;
3323
+ }
3324
+ var init_collections = __esm({
3325
+ "src/utils/collections.ts"() {
3326
+ "use strict";
3327
+ }
3328
+ });
3329
+
3330
+ // src/analysis/insights/query-helpers.ts
3180
3331
  function getQueryShape(q) {
3181
3332
  if (q.sql) return normalizeQueryParams(q.sql) ?? "";
3182
3333
  return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
@@ -3188,400 +3339,781 @@ function getQueryInfo(q) {
3188
3339
  table: q.table ?? q.model ?? ""
3189
3340
  };
3190
3341
  }
3191
- function formatDuration(ms) {
3192
- if (ms < 1e3) return `${ms}ms`;
3193
- return `${(ms / 1e3).toFixed(1)}s`;
3194
- }
3195
- function formatSize(bytes) {
3196
- if (bytes < 1024) return `${bytes}B`;
3197
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
3198
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
3342
+ var init_query_helpers = __esm({
3343
+ "src/analysis/insights/query-helpers.ts"() {
3344
+ "use strict";
3345
+ init_normalize();
3346
+ }
3347
+ });
3348
+
3349
+ // src/analysis/insights/prepare.ts
3350
+ function createEndpointGroup() {
3351
+ return {
3352
+ total: 0,
3353
+ errors: 0,
3354
+ totalDuration: 0,
3355
+ queryCount: 0,
3356
+ totalSize: 0,
3357
+ totalQueryTimeMs: 0,
3358
+ totalFetchTimeMs: 0,
3359
+ queryShapeDurations: /* @__PURE__ */ new Map()
3360
+ };
3199
3361
  }
3200
- function computeInsights(ctx) {
3201
- const insights = [];
3362
+ function prepareContext(ctx) {
3202
3363
  const nonStatic = ctx.requests.filter(
3203
3364
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
3204
3365
  );
3205
- const queriesByReq = /* @__PURE__ */ new Map();
3206
- for (const q of ctx.queries) {
3207
- if (!q.parentRequestId) continue;
3208
- let arr = queriesByReq.get(q.parentRequestId);
3209
- if (!arr) {
3210
- arr = [];
3211
- queriesByReq.set(q.parentRequestId, arr);
3212
- }
3213
- arr.push(q);
3214
- }
3215
- const reqById = /* @__PURE__ */ new Map();
3216
- for (const r of nonStatic) reqById.set(r.id, r);
3217
- const n1Seen = /* @__PURE__ */ new Set();
3218
- for (const [reqId, reqQueries] of queriesByReq) {
3219
- const req = reqById.get(reqId);
3220
- if (!req) continue;
3221
- const endpoint = `${req.method} ${req.path}`;
3222
- const shapeGroups = /* @__PURE__ */ new Map();
3223
- for (const q of reqQueries) {
3224
- const shape = getQueryShape(q);
3225
- let group = shapeGroups.get(shape);
3226
- if (!group) {
3227
- group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
3228
- shapeGroups.set(shape, group);
3229
- }
3230
- group.count++;
3231
- group.distinctSql.add(q.sql ?? shape);
3232
- }
3233
- for (const [, sg] of shapeGroups) {
3234
- if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
3235
- const info = getQueryInfo(sg.first);
3236
- const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
3237
- if (n1Seen.has(key)) continue;
3238
- n1Seen.add(key);
3239
- insights.push({
3240
- severity: "critical",
3241
- type: "n1",
3242
- title: "N+1 Query Pattern",
3243
- desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
3244
- 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.",
3245
- nav: "queries"
3246
- });
3247
- }
3248
- }
3249
- const ceQueryMap = /* @__PURE__ */ new Map();
3250
- const ceAllEndpoints = /* @__PURE__ */ new Set();
3251
- for (const [reqId, reqQueries] of queriesByReq) {
3252
- const req = reqById.get(reqId);
3253
- if (!req) continue;
3254
- const endpoint = `${req.method} ${req.path}`;
3255
- ceAllEndpoints.add(endpoint);
3256
- const seenInReq = /* @__PURE__ */ new Set();
3257
- for (const q of reqQueries) {
3258
- const shape = getQueryShape(q);
3259
- let entry = ceQueryMap.get(shape);
3260
- if (!entry) {
3261
- entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
3262
- ceQueryMap.set(shape, entry);
3263
- }
3264
- entry.count++;
3265
- if (!seenInReq.has(shape)) {
3266
- seenInReq.add(shape);
3267
- entry.endpoints.add(endpoint);
3268
- }
3269
- }
3270
- }
3271
- if (ceAllEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
3272
- for (const [, cem] of ceQueryMap) {
3273
- if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
3274
- if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
3275
- const pct = Math.round(cem.endpoints.size / ceAllEndpoints.size * 100);
3276
- if (pct < CROSS_ENDPOINT_PCT) continue;
3277
- const info = getQueryInfo(cem.first);
3278
- const label = info.op + (info.table ? ` ${info.table}` : "");
3279
- insights.push({
3280
- severity: "warning",
3281
- type: "cross-endpoint",
3282
- title: "Repeated Query Across Endpoints",
3283
- desc: `${label} runs on ${cem.endpoints.size} of ${ceAllEndpoints.size} endpoints (${pct}%).`,
3284
- hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
3285
- nav: "queries"
3286
- });
3287
- }
3288
- }
3289
- const rqSeen = /* @__PURE__ */ new Set();
3290
- for (const [reqId, reqQueries] of queriesByReq) {
3291
- const req = reqById.get(reqId);
3292
- if (!req) continue;
3293
- const endpoint = `${req.method} ${req.path}`;
3294
- const exact = /* @__PURE__ */ new Map();
3295
- for (const q of reqQueries) {
3296
- if (!q.sql) continue;
3297
- let entry = exact.get(q.sql);
3298
- if (!entry) {
3299
- entry = { count: 0, first: q };
3300
- exact.set(q.sql, entry);
3301
- }
3302
- entry.count++;
3303
- }
3304
- for (const [, e] of exact) {
3305
- if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
3306
- const info = getQueryInfo(e.first);
3307
- const label = info.op + (info.table ? ` ${info.table}` : "");
3308
- const dedupKey = `${endpoint}:${label}`;
3309
- if (rqSeen.has(dedupKey)) continue;
3310
- rqSeen.add(dedupKey);
3311
- insights.push({
3312
- severity: "warning",
3313
- type: "redundant-query",
3314
- title: "Redundant Query",
3315
- desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
3316
- 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.",
3317
- nav: "queries"
3318
- });
3319
- }
3320
- }
3321
- if (ctx.errors.length > 0) {
3322
- const errGroups = /* @__PURE__ */ new Map();
3323
- for (const e of ctx.errors) {
3324
- const name = e.name || "Error";
3325
- errGroups.set(name, (errGroups.get(name) ?? 0) + 1);
3326
- }
3327
- for (const [name, cnt] of errGroups) {
3328
- insights.push({
3329
- severity: "critical",
3330
- type: "error",
3331
- title: "Unhandled Error",
3332
- desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
3333
- hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
3334
- nav: "errors"
3335
- });
3336
- }
3337
- }
3366
+ const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
3367
+ const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
3368
+ const reqById = new Map(nonStatic.map((r) => [r.id, r]));
3338
3369
  const endpointGroups = /* @__PURE__ */ new Map();
3339
3370
  for (const r of nonStatic) {
3340
- const ep = `${r.method} ${r.path}`;
3371
+ const ep = getEndpointKey(r.method, r.path);
3341
3372
  let g = endpointGroups.get(ep);
3342
3373
  if (!g) {
3343
- g = { total: 0, errors: 0, totalDuration: 0, queryCount: 0, totalSize: 0 };
3374
+ g = createEndpointGroup();
3344
3375
  endpointGroups.set(ep, g);
3345
3376
  }
3346
3377
  g.total++;
3347
3378
  if (r.statusCode >= 400) g.errors++;
3348
3379
  g.totalDuration += r.durationMs;
3349
- g.queryCount += (queriesByReq.get(r.id) ?? []).length;
3350
3380
  g.totalSize += r.responseSize ?? 0;
3351
- }
3352
- for (const [ep, g] of endpointGroups) {
3353
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3354
- const errorRate = Math.round(g.errors / g.total * 100);
3355
- if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
3356
- insights.push({
3357
- severity: "critical",
3358
- type: "error-hotspot",
3359
- title: "Error Hotspot",
3360
- desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
3361
- hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
3362
- nav: "requests"
3363
- });
3364
- }
3365
- }
3366
- const dupCounts = /* @__PURE__ */ new Map();
3367
- const flowCount = /* @__PURE__ */ new Map();
3368
- for (const flow of ctx.flows) {
3369
- if (!flow.requests) continue;
3370
- const seenInFlow = /* @__PURE__ */ new Set();
3371
- for (const fr of flow.requests) {
3372
- if (!fr.isDuplicate) continue;
3373
- const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
3374
- dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
3375
- if (!seenInFlow.has(dupKey)) {
3376
- seenInFlow.add(dupKey);
3377
- flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
3378
- }
3379
- }
3380
- }
3381
- const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
3382
- for (let i = 0; i < Math.min(dupEntries.length, 3); i++) {
3383
- const d = dupEntries[i];
3384
- insights.push({
3385
- severity: "warning",
3386
- type: "duplicate",
3387
- title: "Duplicate API Call",
3388
- desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
3389
- 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.",
3390
- nav: "actions"
3391
- });
3392
- }
3393
- for (const [ep, g] of endpointGroups) {
3394
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3395
- const avgMs = Math.round(g.totalDuration / g.total);
3396
- if (avgMs >= SLOW_ENDPOINT_THRESHOLD_MS) {
3397
- insights.push({
3398
- severity: "warning",
3399
- type: "slow",
3400
- title: "Slow Endpoint",
3401
- desc: `${ep} \u2014 avg ${formatDuration(avgMs)} across ${g.total} request${g.total !== 1 ? "s" : ""}`,
3402
- hint: "Consistently slow responses hurt user experience. Check the Queries tab to see if database queries are the bottleneck.",
3403
- nav: "requests"
3404
- });
3405
- }
3406
- }
3407
- for (const [ep, g] of endpointGroups) {
3408
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3409
- const avgQueries = Math.round(g.queryCount / g.total);
3410
- if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
3411
- insights.push({
3412
- severity: "warning",
3413
- type: "query-heavy",
3414
- title: "Query-Heavy Endpoint",
3415
- desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
3416
- hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
3417
- nav: "queries"
3418
- });
3419
- }
3420
- }
3421
- const selectStarSeen = /* @__PURE__ */ new Map();
3422
- for (const [, reqQueries] of queriesByReq) {
3381
+ const reqQueries = queriesByReq.get(r.id) ?? [];
3382
+ g.queryCount += reqQueries.length;
3423
3383
  for (const q of reqQueries) {
3424
- if (!q.sql) continue;
3425
- const isSelectStar = /^SELECT\s+\*/i.test(q.sql.trim()) || /\.\*\s+FROM/i.test(q.sql);
3426
- if (!isSelectStar) continue;
3384
+ g.totalQueryTimeMs += q.durationMs;
3385
+ const shape = getQueryShape(q);
3427
3386
  const info = getQueryInfo(q);
3428
- const key = info.table || "unknown";
3429
- selectStarSeen.set(key, (selectStarSeen.get(key) ?? 0) + 1);
3387
+ let sd = g.queryShapeDurations.get(shape);
3388
+ if (!sd) {
3389
+ sd = { totalMs: 0, count: 0, label: info.op + (info.table ? ` ${info.table}` : "") };
3390
+ g.queryShapeDurations.set(shape, sd);
3391
+ }
3392
+ sd.totalMs += q.durationMs;
3393
+ sd.count++;
3430
3394
  }
3431
- }
3432
- for (const [table, count] of selectStarSeen) {
3433
- if (count < OVERFETCH_MIN_REQUESTS) continue;
3434
- insights.push({
3435
- severity: "warning",
3436
- type: "select-star",
3437
- title: "SELECT * Query",
3438
- desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
3439
- hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
3440
- nav: "queries"
3441
- });
3442
- }
3443
- const highRowSeen = /* @__PURE__ */ new Map();
3444
- for (const [, reqQueries] of queriesByReq) {
3445
- for (const q of reqQueries) {
3446
- if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
3447
- const info = getQueryInfo(q);
3448
- const key = `${info.op} ${info.table || "unknown"}`;
3449
- let entry = highRowSeen.get(key);
3450
- if (!entry) {
3451
- entry = { max: 0, count: 0 };
3452
- highRowSeen.set(key, entry);
3453
- }
3454
- entry.count++;
3455
- if (q.rowCount > entry.max) entry.max = q.rowCount;
3395
+ const reqFetches = fetchesByReq.get(r.id) ?? [];
3396
+ for (const f of reqFetches) {
3397
+ g.totalFetchTimeMs += f.durationMs;
3456
3398
  }
3457
3399
  }
3458
- for (const [key, hrs] of highRowSeen) {
3459
- if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
3460
- insights.push({
3461
- severity: "warning",
3462
- type: "high-rows",
3463
- title: "Large Result Set",
3464
- desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
3465
- hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
3466
- nav: "queries"
3467
- });
3400
+ return {
3401
+ ...ctx,
3402
+ nonStatic,
3403
+ queriesByReq,
3404
+ fetchesByReq,
3405
+ reqById,
3406
+ endpointGroups
3407
+ };
3408
+ }
3409
+ var init_prepare = __esm({
3410
+ "src/analysis/insights/prepare.ts"() {
3411
+ "use strict";
3412
+ init_collections();
3413
+ init_endpoint();
3414
+ init_constants();
3415
+ init_query_helpers();
3468
3416
  }
3469
- const overfetchSeen = /* @__PURE__ */ new Set();
3470
- for (const r of nonStatic) {
3471
- if (r.statusCode >= 400 || !r.responseBody) continue;
3472
- const ep = `${r.method} ${r.path}`;
3473
- if (overfetchSeen.has(ep)) continue;
3474
- let parsed;
3475
- try {
3476
- parsed = JSON.parse(r.responseBody);
3477
- } catch {
3478
- continue;
3479
- }
3480
- let target = parsed;
3481
- if (target && typeof target === "object" && !Array.isArray(target)) {
3482
- const topKeys = Object.keys(target);
3483
- if (topKeys.length <= 3) {
3484
- let best = null;
3485
- let bestSize = 0;
3486
- for (const k of topKeys) {
3487
- const val = target[k];
3488
- if (Array.isArray(val) && val.length > bestSize) {
3489
- best = val;
3490
- bestSize = val.length;
3491
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
3492
- const size = Object.keys(val).length;
3493
- if (size > bestSize) {
3494
- best = val;
3495
- bestSize = size;
3496
- }
3417
+ });
3418
+
3419
+ // src/analysis/insights/runner.ts
3420
+ var SEVERITY_ORDER, InsightRunner;
3421
+ var init_runner = __esm({
3422
+ "src/analysis/insights/runner.ts"() {
3423
+ "use strict";
3424
+ init_prepare();
3425
+ SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
3426
+ InsightRunner = class {
3427
+ rules = [];
3428
+ register(rule) {
3429
+ this.rules.push(rule);
3430
+ }
3431
+ run(ctx) {
3432
+ const prepared = prepareContext(ctx);
3433
+ const insights = [];
3434
+ for (const rule of this.rules) {
3435
+ try {
3436
+ insights.push(...rule.check(prepared));
3437
+ } catch {
3497
3438
  }
3498
3439
  }
3499
- if (best && bestSize >= 3) target = best;
3440
+ insights.sort(
3441
+ (a, b) => (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2)
3442
+ );
3443
+ return insights;
3500
3444
  }
3501
- }
3502
- const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
3503
- if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
3504
- const fields = Object.keys(inspectObj);
3505
- if (fields.length < OVERFETCH_MIN_FIELDS) continue;
3506
- let internalIdCount = 0;
3507
- let nullCount = 0;
3508
- for (const key of fields) {
3509
- if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
3510
- const val = inspectObj[key];
3511
- if (val === null || val === void 0) nullCount++;
3512
- }
3513
- const nullRatio = nullCount / fields.length;
3514
- const reasons = [];
3515
- if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
3516
- if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
3517
- if (fields.length >= OVERFETCH_MIN_FIELDS && reasons.length === 0 && fields.length >= 12) {
3518
- reasons.push(`${fields.length} fields returned`);
3519
- }
3520
- if (reasons.length > 0) {
3521
- overfetchSeen.add(ep);
3522
- insights.push({
3523
- severity: "info",
3524
- type: "response-overfetch",
3525
- title: "Response Overfetch",
3526
- desc: `${ep} \u2014 ${reasons.join(", ")}`,
3527
- 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.",
3528
- nav: "requests"
3529
- });
3530
- }
3531
- }
3532
- for (const [ep, g] of endpointGroups) {
3533
- if (g.total < OVERFETCH_MIN_REQUESTS) continue;
3534
- const avgSize = Math.round(g.totalSize / g.total);
3535
- if (avgSize > LARGE_RESPONSE_BYTES) {
3536
- insights.push({
3537
- severity: "info",
3538
- type: "large-response",
3539
- title: "Large Response",
3540
- desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
3541
- hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
3542
- nav: "requests"
3543
- });
3544
- }
3545
- }
3546
- if (ctx.securityFindings) {
3547
- for (const f of ctx.securityFindings) {
3548
- insights.push({
3549
- severity: f.severity,
3550
- type: "security",
3551
- title: f.title,
3552
- desc: f.desc,
3553
- hint: f.hint,
3554
- nav: "security"
3555
- });
3556
- }
3445
+ };
3557
3446
  }
3558
- const severityOrder = { critical: 0, warning: 1, info: 2 };
3559
- insights.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
3560
- return insights;
3561
- }
3562
- var init_insights2 = __esm({
3563
- "src/analysis/insights.ts"() {
3447
+ });
3448
+
3449
+ // src/analysis/insights/rules/n1.ts
3450
+ var n1Rule;
3451
+ var init_n1 = __esm({
3452
+ "src/analysis/insights/rules/n1.ts"() {
3564
3453
  "use strict";
3565
- init_constants();
3454
+ init_query_helpers();
3455
+ init_endpoint();
3566
3456
  init_thresholds();
3567
- init_normalize();
3568
- init_patterns();
3457
+ n1Rule = {
3458
+ id: "n1",
3459
+ check(ctx) {
3460
+ const insights = [];
3461
+ const seen = /* @__PURE__ */ new Set();
3462
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3463
+ const req = ctx.reqById.get(reqId);
3464
+ if (!req) continue;
3465
+ const endpoint = getEndpointKey(req.method, req.path);
3466
+ const shapeGroups = /* @__PURE__ */ new Map();
3467
+ for (const q of reqQueries) {
3468
+ const shape = getQueryShape(q);
3469
+ let group = shapeGroups.get(shape);
3470
+ if (!group) {
3471
+ group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
3472
+ shapeGroups.set(shape, group);
3473
+ }
3474
+ group.count++;
3475
+ group.distinctSql.add(q.sql ?? shape);
3476
+ }
3477
+ for (const [, sg] of shapeGroups) {
3478
+ if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
3479
+ const info = getQueryInfo(sg.first);
3480
+ const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
3481
+ if (seen.has(key)) continue;
3482
+ seen.add(key);
3483
+ insights.push({
3484
+ severity: "critical",
3485
+ type: "n1",
3486
+ title: "N+1 Query Pattern",
3487
+ desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
3488
+ 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.",
3489
+ nav: "queries"
3490
+ });
3491
+ }
3492
+ }
3493
+ return insights;
3494
+ }
3495
+ };
3569
3496
  }
3570
3497
  });
3571
3498
 
3572
- // src/analysis/engine.ts
3573
- var AnalysisEngine;
3574
- var init_engine = __esm({
3575
- "src/analysis/engine.ts"() {
3499
+ // src/analysis/insights/rules/cross-endpoint.ts
3500
+ var crossEndpointRule;
3501
+ var init_cross_endpoint = __esm({
3502
+ "src/analysis/insights/rules/cross-endpoint.ts"() {
3503
+ "use strict";
3504
+ init_query_helpers();
3505
+ init_endpoint();
3506
+ init_thresholds();
3507
+ crossEndpointRule = {
3508
+ id: "cross-endpoint",
3509
+ check(ctx) {
3510
+ const insights = [];
3511
+ const queryMap = /* @__PURE__ */ new Map();
3512
+ const allEndpoints = /* @__PURE__ */ new Set();
3513
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3514
+ const req = ctx.reqById.get(reqId);
3515
+ if (!req) continue;
3516
+ const endpoint = getEndpointKey(req.method, req.path);
3517
+ allEndpoints.add(endpoint);
3518
+ const seenInReq = /* @__PURE__ */ new Set();
3519
+ for (const q of reqQueries) {
3520
+ const shape = getQueryShape(q);
3521
+ let entry = queryMap.get(shape);
3522
+ if (!entry) {
3523
+ entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
3524
+ queryMap.set(shape, entry);
3525
+ }
3526
+ entry.count++;
3527
+ if (!seenInReq.has(shape)) {
3528
+ seenInReq.add(shape);
3529
+ entry.endpoints.add(endpoint);
3530
+ }
3531
+ }
3532
+ }
3533
+ if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
3534
+ for (const [, cem] of queryMap) {
3535
+ if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
3536
+ if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
3537
+ const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
3538
+ if (p < CROSS_ENDPOINT_PCT) continue;
3539
+ const info = getQueryInfo(cem.first);
3540
+ const label = info.op + (info.table ? ` ${info.table}` : "");
3541
+ insights.push({
3542
+ severity: "warning",
3543
+ type: "cross-endpoint",
3544
+ title: "Repeated Query Across Endpoints",
3545
+ desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
3546
+ hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
3547
+ nav: "queries"
3548
+ });
3549
+ }
3550
+ }
3551
+ return insights;
3552
+ }
3553
+ };
3554
+ }
3555
+ });
3556
+
3557
+ // src/analysis/insights/rules/redundant-query.ts
3558
+ var redundantQueryRule;
3559
+ var init_redundant_query = __esm({
3560
+ "src/analysis/insights/rules/redundant-query.ts"() {
3561
+ "use strict";
3562
+ init_query_helpers();
3563
+ init_endpoint();
3564
+ init_thresholds();
3565
+ redundantQueryRule = {
3566
+ id: "redundant-query",
3567
+ check(ctx) {
3568
+ const insights = [];
3569
+ const seen = /* @__PURE__ */ new Set();
3570
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3571
+ const req = ctx.reqById.get(reqId);
3572
+ if (!req) continue;
3573
+ const endpoint = getEndpointKey(req.method, req.path);
3574
+ const exact = /* @__PURE__ */ new Map();
3575
+ for (const q of reqQueries) {
3576
+ if (!q.sql) continue;
3577
+ let entry = exact.get(q.sql);
3578
+ if (!entry) {
3579
+ entry = { count: 0, first: q };
3580
+ exact.set(q.sql, entry);
3581
+ }
3582
+ entry.count++;
3583
+ }
3584
+ for (const [, e] of exact) {
3585
+ if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
3586
+ const info = getQueryInfo(e.first);
3587
+ const label = info.op + (info.table ? ` ${info.table}` : "");
3588
+ const dedupKey = `${endpoint}:${label}`;
3589
+ if (seen.has(dedupKey)) continue;
3590
+ seen.add(dedupKey);
3591
+ insights.push({
3592
+ severity: "warning",
3593
+ type: "redundant-query",
3594
+ title: "Redundant Query",
3595
+ desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
3596
+ 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.",
3597
+ nav: "queries"
3598
+ });
3599
+ }
3600
+ }
3601
+ return insights;
3602
+ }
3603
+ };
3604
+ }
3605
+ });
3606
+
3607
+ // src/analysis/insights/rules/error.ts
3608
+ var errorRule;
3609
+ var init_error = __esm({
3610
+ "src/analysis/insights/rules/error.ts"() {
3611
+ "use strict";
3612
+ errorRule = {
3613
+ id: "error",
3614
+ check(ctx) {
3615
+ if (ctx.errors.length === 0) return [];
3616
+ const insights = [];
3617
+ const groups = /* @__PURE__ */ new Map();
3618
+ for (const e of ctx.errors) {
3619
+ const name = e.name || "Error";
3620
+ groups.set(name, (groups.get(name) ?? 0) + 1);
3621
+ }
3622
+ for (const [name, cnt] of groups) {
3623
+ insights.push({
3624
+ severity: "critical",
3625
+ type: "error",
3626
+ title: "Unhandled Error",
3627
+ desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
3628
+ hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
3629
+ nav: "errors"
3630
+ });
3631
+ }
3632
+ return insights;
3633
+ }
3634
+ };
3635
+ }
3636
+ });
3637
+
3638
+ // src/analysis/insights/rules/error-hotspot.ts
3639
+ var errorHotspotRule;
3640
+ var init_error_hotspot = __esm({
3641
+ "src/analysis/insights/rules/error-hotspot.ts"() {
3642
+ "use strict";
3643
+ init_thresholds();
3644
+ errorHotspotRule = {
3645
+ id: "error-hotspot",
3646
+ check(ctx) {
3647
+ const insights = [];
3648
+ for (const [ep, g] of ctx.endpointGroups) {
3649
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3650
+ const errorRate = Math.round(g.errors / g.total * 100);
3651
+ if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
3652
+ insights.push({
3653
+ severity: "critical",
3654
+ type: "error-hotspot",
3655
+ title: "Error Hotspot",
3656
+ desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
3657
+ hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
3658
+ nav: "requests"
3659
+ });
3660
+ }
3661
+ }
3662
+ return insights;
3663
+ }
3664
+ };
3665
+ }
3666
+ });
3667
+
3668
+ // src/analysis/insights/rules/duplicate.ts
3669
+ var duplicateRule;
3670
+ var init_duplicate = __esm({
3671
+ "src/analysis/insights/rules/duplicate.ts"() {
3672
+ "use strict";
3673
+ init_thresholds();
3674
+ duplicateRule = {
3675
+ id: "duplicate",
3676
+ check(ctx) {
3677
+ const dupCounts = /* @__PURE__ */ new Map();
3678
+ const flowCount = /* @__PURE__ */ new Map();
3679
+ for (const flow of ctx.flows) {
3680
+ if (!flow.requests) continue;
3681
+ const seenInFlow = /* @__PURE__ */ new Set();
3682
+ for (const fr of flow.requests) {
3683
+ if (!fr.isDuplicate) continue;
3684
+ const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
3685
+ dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
3686
+ if (!seenInFlow.has(dupKey)) {
3687
+ seenInFlow.add(dupKey);
3688
+ flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
3689
+ }
3690
+ }
3691
+ }
3692
+ const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
3693
+ const insights = [];
3694
+ for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
3695
+ const d = dupEntries[i];
3696
+ insights.push({
3697
+ severity: "warning",
3698
+ type: "duplicate",
3699
+ title: "Duplicate API Call",
3700
+ desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
3701
+ 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.",
3702
+ nav: "actions"
3703
+ });
3704
+ }
3705
+ return insights;
3706
+ }
3707
+ };
3708
+ }
3709
+ });
3710
+
3711
+ // src/utils/format.ts
3712
+ function formatDuration(ms) {
3713
+ if (ms < 1e3) return `${ms}ms`;
3714
+ return `${(ms / 1e3).toFixed(1)}s`;
3715
+ }
3716
+ function formatSize(bytes) {
3717
+ if (bytes < 1024) return `${bytes}B`;
3718
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
3719
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
3720
+ }
3721
+ function pct(part, total) {
3722
+ return total > 0 ? Math.round(part / total * 100) : 0;
3723
+ }
3724
+ var init_format = __esm({
3725
+ "src/utils/format.ts"() {
3726
+ "use strict";
3727
+ }
3728
+ });
3729
+
3730
+ // src/analysis/insights/rules/slow.ts
3731
+ var slowRule;
3732
+ var init_slow = __esm({
3733
+ "src/analysis/insights/rules/slow.ts"() {
3734
+ "use strict";
3735
+ init_format();
3736
+ init_thresholds();
3737
+ slowRule = {
3738
+ id: "slow",
3739
+ check(ctx) {
3740
+ const insights = [];
3741
+ for (const [ep, g] of ctx.endpointGroups) {
3742
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3743
+ const avgMs = Math.round(g.totalDuration / g.total);
3744
+ if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
3745
+ const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
3746
+ const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
3747
+ const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
3748
+ const parts = [];
3749
+ if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
3750
+ if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
3751
+ if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
3752
+ const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
3753
+ let detail;
3754
+ let slowestMs = 0;
3755
+ for (const [, sd] of g.queryShapeDurations) {
3756
+ const avgShapeMs = sd.totalMs / sd.count;
3757
+ if (avgShapeMs > slowestMs) {
3758
+ slowestMs = avgShapeMs;
3759
+ detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
3760
+ }
3761
+ }
3762
+ insights.push({
3763
+ severity: "warning",
3764
+ type: "slow",
3765
+ title: "Slow Endpoint",
3766
+ desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
3767
+ 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.",
3768
+ detail,
3769
+ nav: "requests"
3770
+ });
3771
+ }
3772
+ return insights;
3773
+ }
3774
+ };
3775
+ }
3776
+ });
3777
+
3778
+ // src/analysis/insights/rules/query-heavy.ts
3779
+ var queryHeavyRule;
3780
+ var init_query_heavy = __esm({
3781
+ "src/analysis/insights/rules/query-heavy.ts"() {
3782
+ "use strict";
3783
+ init_thresholds();
3784
+ queryHeavyRule = {
3785
+ id: "query-heavy",
3786
+ check(ctx) {
3787
+ const insights = [];
3788
+ for (const [ep, g] of ctx.endpointGroups) {
3789
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3790
+ const avgQueries = Math.round(g.queryCount / g.total);
3791
+ if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
3792
+ insights.push({
3793
+ severity: "warning",
3794
+ type: "query-heavy",
3795
+ title: "Query-Heavy Endpoint",
3796
+ desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
3797
+ hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
3798
+ nav: "queries"
3799
+ });
3800
+ }
3801
+ }
3802
+ return insights;
3803
+ }
3804
+ };
3805
+ }
3806
+ });
3807
+
3808
+ // src/analysis/insights/rules/select-star.ts
3809
+ var selectStarRule;
3810
+ var init_select_star = __esm({
3811
+ "src/analysis/insights/rules/select-star.ts"() {
3812
+ "use strict";
3813
+ init_query_helpers();
3814
+ init_thresholds();
3815
+ init_patterns();
3816
+ selectStarRule = {
3817
+ id: "select-star",
3818
+ check(ctx) {
3819
+ const seen = /* @__PURE__ */ new Map();
3820
+ for (const [, reqQueries] of ctx.queriesByReq) {
3821
+ for (const q of reqQueries) {
3822
+ if (!q.sql) continue;
3823
+ const isSelectStar = SELECT_STAR_RE.test(q.sql.trim()) || SELECT_DOT_STAR_RE.test(q.sql);
3824
+ if (!isSelectStar) continue;
3825
+ const info = getQueryInfo(q);
3826
+ const key = info.table || "unknown";
3827
+ seen.set(key, (seen.get(key) ?? 0) + 1);
3828
+ }
3829
+ }
3830
+ const insights = [];
3831
+ for (const [table, count] of seen) {
3832
+ if (count < OVERFETCH_MIN_REQUESTS) continue;
3833
+ insights.push({
3834
+ severity: "warning",
3835
+ type: "select-star",
3836
+ title: "SELECT * Query",
3837
+ desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
3838
+ hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
3839
+ nav: "queries"
3840
+ });
3841
+ }
3842
+ return insights;
3843
+ }
3844
+ };
3845
+ }
3846
+ });
3847
+
3848
+ // src/analysis/insights/rules/high-rows.ts
3849
+ var highRowsRule;
3850
+ var init_high_rows = __esm({
3851
+ "src/analysis/insights/rules/high-rows.ts"() {
3852
+ "use strict";
3853
+ init_query_helpers();
3854
+ init_thresholds();
3855
+ highRowsRule = {
3856
+ id: "high-rows",
3857
+ check(ctx) {
3858
+ const seen = /* @__PURE__ */ new Map();
3859
+ for (const [, reqQueries] of ctx.queriesByReq) {
3860
+ for (const q of reqQueries) {
3861
+ if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
3862
+ const info = getQueryInfo(q);
3863
+ const key = `${info.op} ${info.table || "unknown"}`;
3864
+ let entry = seen.get(key);
3865
+ if (!entry) {
3866
+ entry = { max: 0, count: 0 };
3867
+ seen.set(key, entry);
3868
+ }
3869
+ entry.count++;
3870
+ if (q.rowCount > entry.max) entry.max = q.rowCount;
3871
+ }
3872
+ }
3873
+ const insights = [];
3874
+ for (const [key, hrs] of seen) {
3875
+ if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
3876
+ insights.push({
3877
+ severity: "warning",
3878
+ type: "high-rows",
3879
+ title: "Large Result Set",
3880
+ desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
3881
+ hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
3882
+ nav: "queries"
3883
+ });
3884
+ }
3885
+ return insights;
3886
+ }
3887
+ };
3888
+ }
3889
+ });
3890
+
3891
+ // src/analysis/insights/rules/response-overfetch.ts
3892
+ var responseOverfetchRule;
3893
+ var init_response_overfetch = __esm({
3894
+ "src/analysis/insights/rules/response-overfetch.ts"() {
3895
+ "use strict";
3896
+ init_endpoint();
3897
+ init_response();
3898
+ init_patterns();
3899
+ init_thresholds();
3900
+ responseOverfetchRule = {
3901
+ id: "response-overfetch",
3902
+ check(ctx) {
3903
+ const insights = [];
3904
+ const seen = /* @__PURE__ */ new Set();
3905
+ for (const r of ctx.nonStatic) {
3906
+ if (r.statusCode >= 400 || !r.responseBody) continue;
3907
+ const ep = getEndpointKey(r.method, r.path);
3908
+ if (seen.has(ep)) continue;
3909
+ let parsed;
3910
+ try {
3911
+ parsed = JSON.parse(r.responseBody);
3912
+ } catch {
3913
+ continue;
3914
+ }
3915
+ const target = unwrapResponse(parsed);
3916
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
3917
+ if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
3918
+ const fields = Object.keys(inspectObj);
3919
+ if (fields.length < OVERFETCH_MIN_FIELDS) continue;
3920
+ let internalIdCount = 0;
3921
+ let nullCount = 0;
3922
+ for (const key of fields) {
3923
+ if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
3924
+ const val = inspectObj[key];
3925
+ if (val === null || val === void 0) nullCount++;
3926
+ }
3927
+ const nullRatio = nullCount / fields.length;
3928
+ const reasons = [];
3929
+ if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
3930
+ if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
3931
+ if (reasons.length === 0 && fields.length >= OVERFETCH_MANY_FIELDS) {
3932
+ reasons.push(`${fields.length} fields returned`);
3933
+ }
3934
+ if (reasons.length > 0) {
3935
+ seen.add(ep);
3936
+ insights.push({
3937
+ severity: "info",
3938
+ type: "response-overfetch",
3939
+ title: "Response Overfetch",
3940
+ desc: `${ep} \u2014 ${reasons.join(", ")}`,
3941
+ 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.",
3942
+ nav: "requests"
3943
+ });
3944
+ }
3945
+ }
3946
+ return insights;
3947
+ }
3948
+ };
3949
+ }
3950
+ });
3951
+
3952
+ // src/analysis/insights/rules/large-response.ts
3953
+ var largeResponseRule;
3954
+ var init_large_response = __esm({
3955
+ "src/analysis/insights/rules/large-response.ts"() {
3956
+ "use strict";
3957
+ init_format();
3958
+ init_thresholds();
3959
+ largeResponseRule = {
3960
+ id: "large-response",
3961
+ check(ctx) {
3962
+ const insights = [];
3963
+ for (const [ep, g] of ctx.endpointGroups) {
3964
+ if (g.total < OVERFETCH_MIN_REQUESTS) continue;
3965
+ const avgSize = Math.round(g.totalSize / g.total);
3966
+ if (avgSize > LARGE_RESPONSE_BYTES) {
3967
+ insights.push({
3968
+ severity: "info",
3969
+ type: "large-response",
3970
+ title: "Large Response",
3971
+ desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
3972
+ hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
3973
+ nav: "requests"
3974
+ });
3975
+ }
3976
+ }
3977
+ return insights;
3978
+ }
3979
+ };
3980
+ }
3981
+ });
3982
+
3983
+ // src/analysis/insights/rules/regression.ts
3984
+ var regressionRule;
3985
+ var init_regression = __esm({
3986
+ "src/analysis/insights/rules/regression.ts"() {
3987
+ "use strict";
3988
+ init_format();
3989
+ init_thresholds();
3990
+ regressionRule = {
3991
+ id: "regression",
3992
+ check(ctx) {
3993
+ if (!ctx.previousMetrics || ctx.previousMetrics.length === 0) return [];
3994
+ const insights = [];
3995
+ for (const epMetrics of ctx.previousMetrics) {
3996
+ if (epMetrics.sessions.length < 2) continue;
3997
+ const prev = epMetrics.sessions[epMetrics.sessions.length - 2];
3998
+ const current = epMetrics.sessions[epMetrics.sessions.length - 1];
3999
+ if (prev.requestCount < REGRESSION_MIN_REQUESTS || current.requestCount < REGRESSION_MIN_REQUESTS) continue;
4000
+ const p95Increase = current.p95DurationMs - prev.p95DurationMs;
4001
+ const p95PctChange = prev.p95DurationMs > 0 ? Math.round(p95Increase / prev.p95DurationMs * 100) : 0;
4002
+ if (p95Increase >= REGRESSION_MIN_INCREASE_MS && p95PctChange >= REGRESSION_PCT_THRESHOLD) {
4003
+ insights.push({
4004
+ severity: "warning",
4005
+ type: "regression",
4006
+ title: "Performance Regression",
4007
+ desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
4008
+ hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing.",
4009
+ nav: "graph"
4010
+ });
4011
+ }
4012
+ if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
4013
+ insights.push({
4014
+ severity: "warning",
4015
+ type: "regression",
4016
+ title: "Query Count Regression",
4017
+ desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
4018
+ hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations.",
4019
+ nav: "queries"
4020
+ });
4021
+ }
4022
+ }
4023
+ return insights;
4024
+ }
4025
+ };
4026
+ }
4027
+ });
4028
+
4029
+ // src/analysis/insights/rules/security.ts
4030
+ var securityRule;
4031
+ var init_security2 = __esm({
4032
+ "src/analysis/insights/rules/security.ts"() {
4033
+ "use strict";
4034
+ securityRule = {
4035
+ id: "security",
4036
+ check(ctx) {
4037
+ if (!ctx.securityFindings) return [];
4038
+ return ctx.securityFindings.map((f) => ({
4039
+ severity: f.severity,
4040
+ type: "security",
4041
+ title: f.title,
4042
+ desc: f.desc,
4043
+ hint: f.hint,
4044
+ nav: "security"
4045
+ }));
4046
+ }
4047
+ };
4048
+ }
4049
+ });
4050
+
4051
+ // src/analysis/insights/index.ts
4052
+ function createDefaultInsightRunner() {
4053
+ const runner = new InsightRunner();
4054
+ runner.register(n1Rule);
4055
+ runner.register(crossEndpointRule);
4056
+ runner.register(redundantQueryRule);
4057
+ runner.register(errorRule);
4058
+ runner.register(errorHotspotRule);
4059
+ runner.register(duplicateRule);
4060
+ runner.register(slowRule);
4061
+ runner.register(queryHeavyRule);
4062
+ runner.register(selectStarRule);
4063
+ runner.register(highRowsRule);
4064
+ runner.register(responseOverfetchRule);
4065
+ runner.register(largeResponseRule);
4066
+ runner.register(regressionRule);
4067
+ runner.register(securityRule);
4068
+ return runner;
4069
+ }
4070
+ function computeInsights(ctx) {
4071
+ return createDefaultInsightRunner().run(ctx);
4072
+ }
4073
+ var init_insights2 = __esm({
4074
+ "src/analysis/insights/index.ts"() {
4075
+ "use strict";
4076
+ init_runner();
4077
+ init_runner();
4078
+ init_n1();
4079
+ init_cross_endpoint();
4080
+ init_redundant_query();
4081
+ init_error();
4082
+ init_error_hotspot();
4083
+ init_duplicate();
4084
+ init_slow();
4085
+ init_query_heavy();
4086
+ init_select_star();
4087
+ init_high_rows();
4088
+ init_response_overfetch();
4089
+ init_large_response();
4090
+ init_regression();
4091
+ init_security2();
4092
+ }
4093
+ });
4094
+
4095
+ // src/analysis/insights.ts
4096
+ var init_insights3 = __esm({
4097
+ "src/analysis/insights.ts"() {
3576
4098
  "use strict";
3577
- init_request_log();
3578
- init_store();
3579
- init_request_log();
3580
- init_group();
3581
- init_rules();
3582
4099
  init_insights2();
4100
+ }
4101
+ });
4102
+
4103
+ // src/analysis/engine.ts
4104
+ var AnalysisEngine;
4105
+ var init_engine = __esm({
4106
+ "src/analysis/engine.ts"() {
4107
+ "use strict";
4108
+ init_request_log();
4109
+ init_store();
4110
+ init_request_log();
4111
+ init_group();
4112
+ init_rules();
4113
+ init_insights3();
3583
4114
  AnalysisEngine = class {
3584
- constructor(debounceMs = 300) {
4115
+ constructor(metricsStore, debounceMs = 300) {
4116
+ this.metricsStore = metricsStore;
3585
4117
  this.debounceMs = debounceMs;
3586
4118
  this.scanner = createDefaultScanner();
3587
4119
  this.boundRequestListener = () => this.scheduleRecompute();
@@ -3639,6 +4171,7 @@ var init_engine = __esm({
3639
4171
  const queries = defaultQueryStore.getAll();
3640
4172
  const errors = defaultErrorStore.getAll();
3641
4173
  const logs = defaultLogStore.getAll();
4174
+ const fetches = defaultFetchStore.getAll();
3642
4175
  const flows = groupRequestsIntoFlows(requests);
3643
4176
  this.cachedFindings = this.scanner.scan({ requests, logs });
3644
4177
  this.cachedInsights = computeInsights({
@@ -3646,6 +4179,8 @@ var init_engine = __esm({
3646
4179
  queries,
3647
4180
  errors,
3648
4181
  flows,
4182
+ fetches,
4183
+ previousMetrics: this.metricsStore.getAll(),
3649
4184
  securityFindings: this.cachedFindings
3650
4185
  });
3651
4186
  for (const fn of this.listeners) {
@@ -3668,8 +4203,9 @@ var init_src = __esm({
3668
4203
  init_adapter_registry();
3669
4204
  init_rules();
3670
4205
  init_engine();
4206
+ init_insights3();
3671
4207
  init_insights2();
3672
- VERSION = "0.7.4";
4208
+ VERSION = "0.7.6";
3673
4209
  }
3674
4210
  });
3675
4211
 
@@ -4282,7 +4818,7 @@ function getFlowInsights() {
4282
4818
  }
4283
4819
  `;
4284
4820
  }
4285
- var init_insights3 = __esm({
4821
+ var init_insights4 = __esm({
4286
4822
  "src/dashboard/client/views/flows/insights.ts"() {
4287
4823
  "use strict";
4288
4824
  init_constants();
@@ -4474,7 +5010,7 @@ function getFlowsView() {
4474
5010
  var init_flows2 = __esm({
4475
5011
  "src/dashboard/client/views/flows.ts"() {
4476
5012
  "use strict";
4477
- init_insights3();
5013
+ init_insights4();
4478
5014
  init_detail();
4479
5015
  }
4480
5016
  });
@@ -5090,6 +5626,27 @@ function getGraphOverview() {
5090
5626
  (s.avgQueryCount > 0 ? '<span class="perf-ep-stat' + (s.avgQueryCount > HIGH_QUERY_THRESHOLD ? ' perf-ep-stat-warn' : '') + '">' + s.avgQueryCount + ' q/req</span>' : '') +
5091
5627
  '<span class="perf-ep-stat perf-ep-stat-muted">' + s.totalRequests + ' req' + (s.totalRequests !== 1 ? 's' : '') + '</span>';
5092
5628
 
5629
+ var ovTotal = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0);
5630
+ var ovBarHtml = '';
5631
+ if (ovTotal > 0) {
5632
+ var ovDbPct = Math.round((s.avgQueryTimeMs || 0) / ovTotal * 100);
5633
+ var ovFetchPct = Math.round((s.avgFetchTimeMs || 0) / ovTotal * 100);
5634
+ var ovAppPct = Math.max(0, 100 - ovDbPct - ovFetchPct);
5635
+ ovBarHtml =
5636
+ '<div class="perf-breakdown-inline">' +
5637
+ '<div class="perf-breakdown-bar perf-breakdown-bar-sm">' +
5638
+ (ovDbPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-db" style="width:' + ovDbPct + '%"></div>' : '') +
5639
+ (ovFetchPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-fetch" style="width:' + ovFetchPct + '%"></div>' : '') +
5640
+ (ovAppPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-app" style="width:' + ovAppPct + '%"></div>' : '') +
5641
+ '</div>' +
5642
+ '<span class="perf-breakdown-labels">' +
5643
+ (ovDbPct > 0 ? '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-db"></span>' + fmtMs(s.avgQueryTimeMs || 0) + '</span>' : '') +
5644
+ (ovFetchPct > 0 ? '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-fetch"></span>' + fmtMs(s.avgFetchTimeMs || 0) + '</span>' : '') +
5645
+ '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-app"></span>' + fmtMs(s.avgAppTimeMs || 0) + '</span>' +
5646
+ '</span>' +
5647
+ '</div>';
5648
+ }
5649
+
5093
5650
  var chartId = 'inline-scatter-' + idx;
5094
5651
 
5095
5652
  card.innerHTML =
@@ -5097,6 +5654,7 @@ function getGraphOverview() {
5097
5654
  '<span class="perf-ep-name">' + escHtml(ep.endpoint) + '</span>' +
5098
5655
  '<span class="perf-ep-stats">' + statsHtml + '</span>' +
5099
5656
  '</div>' +
5657
+ ovBarHtml +
5100
5658
  '<canvas id="' + chartId + '" class="perf-inline-canvas"></canvas>';
5101
5659
 
5102
5660
  list.appendChild(card);
@@ -5132,7 +5690,6 @@ function getGraphDetail() {
5132
5690
  var g = healthGrade(s.p95Ms);
5133
5691
  var errors = Math.round(s.errorRate * s.totalRequests);
5134
5692
 
5135
- // header
5136
5693
  var header = document.createElement('div');
5137
5694
  header.className = 'perf-detail-header';
5138
5695
  header.innerHTML =
@@ -5142,7 +5699,6 @@ function getGraphDetail() {
5142
5699
  '</div>';
5143
5700
  container.appendChild(header);
5144
5701
 
5145
- // metric cards
5146
5702
  var metrics = document.createElement('div');
5147
5703
  metrics.className = 'perf-metric-row';
5148
5704
  metrics.innerHTML =
@@ -5151,7 +5707,38 @@ function getGraphDetail() {
5151
5707
  buildMetricCard('Queries/req', String(s.avgQueryCount), s.avgQueryCount > ${HIGH_QUERY_COUNT_PER_REQ} ? 'var(--amber)' : 'var(--text)');
5152
5708
  container.appendChild(metrics);
5153
5709
 
5154
- // scatter chart
5710
+ var totalAvg = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0);
5711
+ if (totalAvg > 0) {
5712
+ var dbPct = Math.round((s.avgQueryTimeMs || 0) / totalAvg * 100);
5713
+ var fetchPct = Math.round((s.avgFetchTimeMs || 0) / totalAvg * 100);
5714
+ var appPct = Math.max(0, 100 - dbPct - fetchPct);
5715
+
5716
+ var breakdown = document.createElement('div');
5717
+ breakdown.className = 'perf-breakdown';
5718
+
5719
+ var breakdownLabel = document.createElement('div');
5720
+ breakdownLabel.className = 'perf-section-title';
5721
+ breakdownLabel.textContent = 'Time Breakdown';
5722
+ breakdown.appendChild(breakdownLabel);
5723
+
5724
+ var bar = document.createElement('div');
5725
+ bar.className = 'perf-breakdown-bar';
5726
+ if (dbPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-db" style="width:' + dbPct + '%"></div>';
5727
+ if (fetchPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-fetch" style="width:' + fetchPct + '%"></div>';
5728
+ if (appPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-app" style="width:' + appPct + '%"></div>';
5729
+ breakdown.appendChild(bar);
5730
+
5731
+ var legend = document.createElement('div');
5732
+ legend.className = 'perf-breakdown-legend';
5733
+ legend.innerHTML =
5734
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-db"></span>DB ' + fmtMs(s.avgQueryTimeMs || 0) + ' (' + dbPct + '%)</span>' +
5735
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-fetch"></span>Fetch ' + fmtMs(s.avgFetchTimeMs || 0) + ' (' + fetchPct + '%)</span>' +
5736
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-app"></span>App ' + fmtMs(s.avgAppTimeMs || 0) + ' (' + appPct + '%)</span>';
5737
+ breakdown.appendChild(legend);
5738
+
5739
+ container.appendChild(breakdown);
5740
+ }
5741
+
5155
5742
  var chartWrap = document.createElement('div');
5156
5743
  chartWrap.className = 'perf-chart-wrap';
5157
5744
  var chartLabel = document.createElement('div');
@@ -5169,7 +5756,6 @@ function getGraphDetail() {
5169
5756
 
5170
5757
  drawScatterChart(canvas, ep.requests);
5171
5758
 
5172
- // recent requests table
5173
5759
  if (ep.requests.length > 0) {
5174
5760
  var tableWrap = document.createElement('div');
5175
5761
  tableWrap.className = 'perf-history-wrap';
@@ -5180,6 +5766,7 @@ function getGraphDetail() {
5180
5766
  '<span class="perf-col perf-col-date">Time</span>' +
5181
5767
  '<span class="perf-col perf-col-health">Health</span>' +
5182
5768
  '<span class="perf-col perf-col-avg">Duration</span>' +
5769
+ '<span class="perf-col perf-col-breakdown">Breakdown</span>' +
5183
5770
  '<span class="perf-col perf-col-status">Status</span>' +
5184
5771
  '<span class="perf-col perf-col-qpr">Queries</span>';
5185
5772
  tableWrap.appendChild(colHeader);
@@ -5198,10 +5785,20 @@ function getGraphDetail() {
5198
5785
  var row = document.createElement('div');
5199
5786
  row.className = 'perf-hist-row' + (isError ? ' perf-hist-row-err' : '');
5200
5787
  row.setAttribute('data-req-idx', item.origIdx);
5788
+ var rDbMs = r.queryTimeMs || 0;
5789
+ var rFetchMs = r.fetchTimeMs || 0;
5790
+ var rAppMs = Math.max(0, r.durationMs - rDbMs - rFetchMs);
5791
+ var breakdownParts = [];
5792
+ if (rDbMs > 0) breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-db">DB ' + fmtMs(rDbMs) + '</span>');
5793
+ if (rFetchMs > 0) breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-fetch">Fetch ' + fmtMs(rFetchMs) + '</span>');
5794
+ breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-app">App ' + fmtMs(rAppMs) + '</span>');
5795
+ var breakdownHtml = breakdownParts.join('');
5796
+
5201
5797
  row.innerHTML =
5202
5798
  '<span class="perf-col perf-col-date">' + timeStr + '</span>' +
5203
5799
  '<span class="perf-col perf-col-health"><span class="perf-badge perf-badge-sm" style="color:' + rg.color + ';background:' + rg.bg + ';border-color:' + rg.border + '">' + rg.label + '</span></span>' +
5204
5800
  '<span class="perf-col perf-col-avg">' + fmtMs(r.durationMs) + '</span>' +
5801
+ '<span class="perf-col perf-col-breakdown">' + breakdownHtml + '</span>' +
5205
5802
  '<span class="perf-col perf-col-status" style="color:' + (isError ? 'var(--red)' : 'var(--text-muted)') + '">' + r.statusCode + '</span>' +
5206
5803
  '<span class="perf-col perf-col-qpr">' + r.queryCount + '</span>';
5207
5804
  tableWrap.appendChild(row);
@@ -5744,7 +6341,7 @@ function getSecurityView() {
5744
6341
  }
5745
6342
  `;
5746
6343
  }
5747
- var init_security2 = __esm({
6344
+ var init_security3 = __esm({
5748
6345
  "src/dashboard/client/views/security.ts"() {
5749
6346
  "use strict";
5750
6347
  }
@@ -6009,7 +6606,7 @@ var init_client = __esm({
6009
6606
  init_timeline2();
6010
6607
  init_graph2();
6011
6608
  init_overview3();
6012
- init_security2();
6609
+ init_security3();
6013
6610
  init_app2();
6014
6611
  }
6015
6612
  });
@@ -6113,7 +6710,7 @@ function createDashboardHandler(deps) {
6113
6710
  routes[DASHBOARD_API_TAB] = (req, res) => {
6114
6711
  const raw = (req.url ?? "").split("tab=")[1];
6115
6712
  if (raw) {
6116
- const tab = decodeURIComponent(raw).slice(0, 32);
6713
+ const tab = decodeURIComponent(raw).slice(0, MAX_TAB_NAME_LENGTH);
6117
6714
  if (VALID_TABS.has(tab) && isTelemetryEnabled()) recordTabViewed(tab);
6118
6715
  }
6119
6716
  res.writeHead(204);
@@ -6166,6 +6763,9 @@ var init_router = __esm({
6166
6763
 
6167
6764
  // src/output/terminal.ts
6168
6765
  import pc from "picocolors";
6766
+ function print(line) {
6767
+ process.stdout.write(line + "\n");
6768
+ }
6169
6769
  function severityIcon(severity) {
6170
6770
  if (severity === "critical") return pc.red("\u2717");
6171
6771
  if (severity === "warning") return pc.yellow("\u26A0");
@@ -6184,7 +6784,12 @@ function formatConsoleLine(insight, dashboardUrl, suffix) {
6184
6784
  const title = colorTitle(insight.severity, insight.title);
6185
6785
  const desc = pc.dim(truncate(insight.desc) + (suffix ?? ""));
6186
6786
  const link = pc.dim(`\u2192 ${dashboardUrl}`);
6187
- return ` ${icon} ${title} \u2014 ${desc} ${link}`;
6787
+ let line = ` ${icon} ${title} \u2014 ${desc} ${link}`;
6788
+ if (insight.detail) {
6789
+ line += `
6790
+ ${pc.dim("\u2514 " + insight.detail)}`;
6791
+ }
6792
+ return line;
6188
6793
  }
6189
6794
  function createConsoleInsightListener(proxyPort, metricsStore) {
6190
6795
  const printedKeys = /* @__PURE__ */ new Set();
@@ -6199,7 +6804,7 @@ function createConsoleInsightListener(proxyPort, metricsStore) {
6199
6804
  printedKeys.add(key);
6200
6805
  let suffix;
6201
6806
  if (insight.type === "slow") {
6202
- const ep = metricsStore.getAll().find((e) => e.endpoint === endpoint);
6807
+ const ep = metricsStore.getEndpoint(endpoint);
6203
6808
  if (ep && ep.sessions.length > 1) {
6204
6809
  const prev = ep.sessions[ep.sessions.length - 2];
6205
6810
  suffix = ` (\u2191 from ${prev.p95DurationMs < 1e3 ? prev.p95DurationMs + "ms" : (prev.p95DurationMs / 1e3).toFixed(1) + "s"})`;
@@ -6208,8 +6813,8 @@ function createConsoleInsightListener(proxyPort, metricsStore) {
6208
6813
  lines.push(formatConsoleLine(insight, dashUrl, suffix));
6209
6814
  }
6210
6815
  if (lines.length > 0) {
6211
- console.log();
6212
- for (const line of lines) console.log(line);
6816
+ print("");
6817
+ for (const line of lines) print(line);
6213
6818
  }
6214
6819
  };
6215
6820
  }
@@ -6342,6 +6947,7 @@ function captureInProcess(req, res, requestId) {
6342
6947
  } catch {
6343
6948
  }
6344
6949
  const result = originalEnd.apply(this, args);
6950
+ const endTime = performance.now();
6345
6951
  try {
6346
6952
  const encoding = String(res.getHeader("content-encoding") ?? "").toLowerCase();
6347
6953
  let body = resChunks.length > 0 ? Buffer.concat(resChunks) : null;
@@ -6359,6 +6965,7 @@ function captureInProcess(req, res, requestId) {
6359
6965
  responseBody: body,
6360
6966
  responseContentType: String(res.getHeader("content-type") ?? ""),
6361
6967
  startTime,
6968
+ endTime,
6362
6969
  config: { maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE }
6363
6970
  });
6364
6971
  } catch {
@@ -6374,42 +6981,15 @@ var init_capture = __esm({
6374
6981
  }
6375
6982
  });
6376
6983
 
6377
- // src/runtime/setup.ts
6378
- var setup_exports = {};
6379
- __export(setup_exports, {
6380
- setup: () => setup
6381
- });
6984
+ // src/runtime/interceptor.ts
6382
6985
  import http from "http";
6383
6986
  import { randomUUID as randomUUID6 } from "crypto";
6384
- function setup() {
6385
- if (initialized) return;
6386
- initialized = true;
6387
- setEmitter(routeEvent2);
6388
- setupFetchHook();
6389
- setupConsoleHook();
6390
- setupErrorHook();
6391
- const registry = createDefaultRegistry();
6392
- registry.patchAll(routeEvent2);
6393
- const cwd = process.cwd();
6394
- const metricsStore = new MetricsStore(new FileMetricsPersistence(cwd));
6395
- metricsStore.start();
6396
- const analysisEngine = new AnalysisEngine();
6397
- analysisEngine.start();
6398
- const config = {
6399
- proxyPort: 0,
6400
- targetPort: 0,
6401
- showStatic: false,
6402
- maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE
6403
- };
6404
- const handleDashboard = createDashboardHandler({ metricsStore, analysisEngine });
6405
- onRequest((req) => {
6406
- const queryCount = defaultQueryStore.getByRequest(req.id).length;
6407
- metricsStore.recordRequest(req, queryCount);
6408
- });
6409
- const originalEmit = http.Server.prototype.emit;
6987
+ function installInterceptor(deps) {
6988
+ originalEmit = http.Server.prototype.emit;
6989
+ const saved = originalEmit;
6410
6990
  let bannerPrinted = false;
6411
6991
  http.Server.prototype.emit = safeWrap(
6412
- originalEmit,
6992
+ saved,
6413
6993
  function(original, event, ...args) {
6414
6994
  if (event !== "request") return original.apply(this, [event, ...args]);
6415
6995
  const req = args[0];
@@ -6419,9 +6999,8 @@ function setup() {
6419
6999
  const port = req.socket.localPort;
6420
7000
  if (port) {
6421
7001
  bannerPrinted = true;
6422
- config.proxyPort = port;
6423
- analysisEngine.onUpdate(createConsoleInsightListener(port, metricsStore));
6424
- console.log(` brakit v${VERSION} \u2014 http://localhost:${port}${DASHBOARD_PREFIX}`);
7002
+ deps.config.proxyPort = port;
7003
+ deps.onFirstRequest(port);
6425
7004
  }
6426
7005
  }
6427
7006
  if (isDashboardRequest(url)) {
@@ -6430,7 +7009,7 @@ function setup() {
6430
7009
  res.end("Not Found");
6431
7010
  return true;
6432
7011
  }
6433
- handleDashboard(req, res, config);
7012
+ deps.handleDashboard(req, res, deps.config);
6434
7013
  return true;
6435
7014
  }
6436
7015
  const requestId = randomUUID6();
@@ -6446,8 +7025,72 @@ function setup() {
6446
7025
  );
6447
7026
  }
6448
7027
  );
6449
- health.setTeardown(() => {
7028
+ }
7029
+ function uninstallInterceptor() {
7030
+ if (originalEmit) {
6450
7031
  http.Server.prototype.emit = originalEmit;
7032
+ originalEmit = null;
7033
+ }
7034
+ }
7035
+ var originalEmit;
7036
+ var init_interceptor = __esm({
7037
+ "src/runtime/interceptor.ts"() {
7038
+ "use strict";
7039
+ init_context();
7040
+ init_router();
7041
+ init_safe_wrap();
7042
+ init_guard();
7043
+ init_capture();
7044
+ originalEmit = null;
7045
+ }
7046
+ });
7047
+
7048
+ // src/runtime/setup.ts
7049
+ var setup_exports = {};
7050
+ __export(setup_exports, {
7051
+ setup: () => setup
7052
+ });
7053
+ function setup() {
7054
+ if (initialized) return;
7055
+ initialized = true;
7056
+ setEmitter(routeEvent2);
7057
+ setupFetchHook();
7058
+ setupConsoleHook();
7059
+ setupErrorHook();
7060
+ const registry = createDefaultRegistry();
7061
+ registry.patchAll(routeEvent2);
7062
+ const cwd = process.cwd();
7063
+ const metricsStore = new MetricsStore(new FileMetricsPersistence(cwd));
7064
+ metricsStore.start();
7065
+ const analysisEngine = new AnalysisEngine(metricsStore);
7066
+ analysisEngine.start();
7067
+ const config = {
7068
+ proxyPort: 0,
7069
+ targetPort: 0,
7070
+ showStatic: false,
7071
+ maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE
7072
+ };
7073
+ const handleDashboard = createDashboardHandler({ metricsStore, analysisEngine });
7074
+ onRequest((req) => {
7075
+ const queries = defaultQueryStore.getByRequest(req.id);
7076
+ const fetches = defaultFetchStore.getByRequest(req.id);
7077
+ metricsStore.recordRequest(req, {
7078
+ queryCount: queries.length,
7079
+ queryTimeMs: queries.reduce((s, q) => s + q.durationMs, 0),
7080
+ fetchTimeMs: fetches.reduce((s, f) => s + f.durationMs, 0)
7081
+ });
7082
+ });
7083
+ installInterceptor({
7084
+ handleDashboard,
7085
+ config,
7086
+ onFirstRequest(port) {
7087
+ analysisEngine.onUpdate(createConsoleInsightListener(port, metricsStore));
7088
+ process.stdout.write(` brakit v${VERSION} \u2014 http://localhost:${port}${DASHBOARD_PREFIX}
7089
+ `);
7090
+ }
7091
+ });
7092
+ health.setTeardown(() => {
7093
+ uninstallInterceptor();
6451
7094
  analysisEngine.stop();
6452
7095
  metricsStore.stop();
6453
7096
  });
@@ -6472,7 +7115,6 @@ var initialized;
6472
7115
  var init_setup = __esm({
6473
7116
  "src/runtime/setup.ts"() {
6474
7117
  "use strict";
6475
- init_context();
6476
7118
  init_transport2();
6477
7119
  init_fetch();
6478
7120
  init_console();
@@ -6485,10 +7127,8 @@ var init_setup = __esm({
6485
7127
  init_terminal();
6486
7128
  init_src();
6487
7129
  init_constants();
6488
- init_safe_wrap();
6489
7130
  init_health2();
6490
- init_guard();
6491
- init_capture();
7131
+ init_interceptor();
6492
7132
  initialized = false;
6493
7133
  }
6494
7134
  });