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.
@@ -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.",
@@ -3176,7 +3297,28 @@ var init_rules = __esm({
3176
3297
  }
3177
3298
  });
3178
3299
 
3179
- // src/analysis/insights.ts
3300
+ // src/utils/collections.ts
3301
+ function groupBy(items, keyFn) {
3302
+ const map = /* @__PURE__ */ new Map();
3303
+ for (const item of items) {
3304
+ const key = keyFn(item);
3305
+ if (key == null) continue;
3306
+ let arr = map.get(key);
3307
+ if (!arr) {
3308
+ arr = [];
3309
+ map.set(key, arr);
3310
+ }
3311
+ arr.push(item);
3312
+ }
3313
+ return map;
3314
+ }
3315
+ var init_collections = __esm({
3316
+ "src/utils/collections.ts"() {
3317
+ "use strict";
3318
+ }
3319
+ });
3320
+
3321
+ // src/analysis/insights/query-helpers.ts
3180
3322
  function getQueryShape(q) {
3181
3323
  if (q.sql) return normalizeQueryParams(q.sql) ?? "";
3182
3324
  return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
@@ -3188,384 +3330,794 @@ function getQueryInfo(q) {
3188
3330
  table: q.table ?? q.model ?? ""
3189
3331
  };
3190
3332
  }
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`;
3333
+ var init_query_helpers = __esm({
3334
+ "src/analysis/insights/query-helpers.ts"() {
3335
+ "use strict";
3336
+ init_normalize();
3337
+ }
3338
+ });
3339
+
3340
+ // src/analysis/insights/prepare.ts
3341
+ function createEndpointGroup() {
3342
+ return {
3343
+ total: 0,
3344
+ errors: 0,
3345
+ totalDuration: 0,
3346
+ queryCount: 0,
3347
+ totalSize: 0,
3348
+ totalQueryTimeMs: 0,
3349
+ totalFetchTimeMs: 0,
3350
+ queryShapeDurations: /* @__PURE__ */ new Map()
3351
+ };
3199
3352
  }
3200
- function computeInsights(ctx) {
3201
- const insights = [];
3353
+ function prepareContext(ctx) {
3202
3354
  const nonStatic = ctx.requests.filter(
3203
3355
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
3204
3356
  );
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);
3357
+ const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
3358
+ const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
3359
+ const reqById = new Map(nonStatic.map((r) => [r.id, r]));
3360
+ const endpointGroups = /* @__PURE__ */ new Map();
3361
+ for (const r of nonStatic) {
3362
+ const ep = getEndpointKey(r.method, r.path);
3363
+ let g = endpointGroups.get(ep);
3364
+ if (!g) {
3365
+ g = createEndpointGroup();
3366
+ endpointGroups.set(ep, g);
3212
3367
  }
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();
3368
+ g.total++;
3369
+ if (r.statusCode >= 400) g.errors++;
3370
+ g.totalDuration += r.durationMs;
3371
+ g.totalSize += r.responseSize ?? 0;
3372
+ const reqQueries = queriesByReq.get(r.id) ?? [];
3373
+ g.queryCount += reqQueries.length;
3223
3374
  for (const q of reqQueries) {
3375
+ g.totalQueryTimeMs += q.durationMs;
3224
3376
  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);
3377
+ const info = getQueryInfo(q);
3378
+ let sd = g.queryShapeDurations.get(shape);
3379
+ if (!sd) {
3380
+ sd = { totalMs: 0, count: 0, label: info.op + (info.table ? ` ${info.table}` : "") };
3381
+ g.queryShapeDurations.set(shape, sd);
3229
3382
  }
3230
- group.count++;
3231
- group.distinctSql.add(q.sql ?? shape);
3383
+ sd.totalMs += q.durationMs;
3384
+ sd.count++;
3232
3385
  }
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
- });
3386
+ const reqFetches = fetchesByReq.get(r.id) ?? [];
3387
+ for (const f of reqFetches) {
3388
+ g.totalFetchTimeMs += f.durationMs;
3247
3389
  }
3248
3390
  }
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);
3391
+ return {
3392
+ ...ctx,
3393
+ nonStatic,
3394
+ queriesByReq,
3395
+ fetchesByReq,
3396
+ reqById,
3397
+ endpointGroups
3398
+ };
3399
+ }
3400
+ var init_prepare = __esm({
3401
+ "src/analysis/insights/prepare.ts"() {
3402
+ "use strict";
3403
+ init_collections();
3404
+ init_endpoint();
3405
+ init_constants();
3406
+ init_query_helpers();
3407
+ }
3408
+ });
3409
+
3410
+ // src/analysis/insights/runner.ts
3411
+ var SEVERITY_ORDER, InsightRunner;
3412
+ var init_runner = __esm({
3413
+ "src/analysis/insights/runner.ts"() {
3414
+ "use strict";
3415
+ init_prepare();
3416
+ SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
3417
+ InsightRunner = class {
3418
+ rules = [];
3419
+ register(rule) {
3420
+ this.rules.push(rule);
3263
3421
  }
3264
- entry.count++;
3265
- if (!seenInReq.has(shape)) {
3266
- seenInReq.add(shape);
3267
- entry.endpoints.add(endpoint);
3422
+ run(ctx) {
3423
+ const prepared = prepareContext(ctx);
3424
+ const insights = [];
3425
+ for (const rule of this.rules) {
3426
+ try {
3427
+ insights.push(...rule.check(prepared));
3428
+ } catch {
3429
+ }
3430
+ }
3431
+ insights.sort(
3432
+ (a, b) => (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2)
3433
+ );
3434
+ return insights;
3268
3435
  }
3269
- }
3436
+ };
3270
3437
  }
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
- }
3438
+ });
3439
+
3440
+ // src/analysis/insights/rules/n1.ts
3441
+ var n1Rule;
3442
+ var init_n1 = __esm({
3443
+ "src/analysis/insights/rules/n1.ts"() {
3444
+ "use strict";
3445
+ init_query_helpers();
3446
+ init_endpoint();
3447
+ init_thresholds();
3448
+ n1Rule = {
3449
+ id: "n1",
3450
+ check(ctx) {
3451
+ const insights = [];
3452
+ const seen = /* @__PURE__ */ new Set();
3453
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3454
+ const req = ctx.reqById.get(reqId);
3455
+ if (!req) continue;
3456
+ const endpoint = getEndpointKey(req.method, req.path);
3457
+ const shapeGroups = /* @__PURE__ */ new Map();
3458
+ for (const q of reqQueries) {
3459
+ const shape = getQueryShape(q);
3460
+ let group = shapeGroups.get(shape);
3461
+ if (!group) {
3462
+ group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
3463
+ shapeGroups.set(shape, group);
3464
+ }
3465
+ group.count++;
3466
+ group.distinctSql.add(q.sql ?? shape);
3467
+ }
3468
+ for (const [, sg] of shapeGroups) {
3469
+ if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
3470
+ const info = getQueryInfo(sg.first);
3471
+ const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
3472
+ if (seen.has(key)) continue;
3473
+ seen.add(key);
3474
+ insights.push({
3475
+ severity: "critical",
3476
+ type: "n1",
3477
+ title: "N+1 Query Pattern",
3478
+ desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
3479
+ 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.",
3480
+ nav: "queries"
3481
+ });
3482
+ }
3483
+ }
3484
+ return insights;
3485
+ }
3486
+ };
3288
3487
  }
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);
3488
+ });
3489
+
3490
+ // src/analysis/insights/rules/cross-endpoint.ts
3491
+ var crossEndpointRule;
3492
+ var init_cross_endpoint = __esm({
3493
+ "src/analysis/insights/rules/cross-endpoint.ts"() {
3494
+ "use strict";
3495
+ init_query_helpers();
3496
+ init_endpoint();
3497
+ init_thresholds();
3498
+ crossEndpointRule = {
3499
+ id: "cross-endpoint",
3500
+ check(ctx) {
3501
+ const insights = [];
3502
+ const queryMap = /* @__PURE__ */ new Map();
3503
+ const allEndpoints = /* @__PURE__ */ new Set();
3504
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3505
+ const req = ctx.reqById.get(reqId);
3506
+ if (!req) continue;
3507
+ const endpoint = getEndpointKey(req.method, req.path);
3508
+ allEndpoints.add(endpoint);
3509
+ const seenInReq = /* @__PURE__ */ new Set();
3510
+ for (const q of reqQueries) {
3511
+ const shape = getQueryShape(q);
3512
+ let entry = queryMap.get(shape);
3513
+ if (!entry) {
3514
+ entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
3515
+ queryMap.set(shape, entry);
3516
+ }
3517
+ entry.count++;
3518
+ if (!seenInReq.has(shape)) {
3519
+ seenInReq.add(shape);
3520
+ entry.endpoints.add(endpoint);
3521
+ }
3522
+ }
3523
+ }
3524
+ if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
3525
+ for (const [, cem] of queryMap) {
3526
+ if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
3527
+ if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
3528
+ const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
3529
+ if (p < CROSS_ENDPOINT_PCT) continue;
3530
+ const info = getQueryInfo(cem.first);
3531
+ const label = info.op + (info.table ? ` ${info.table}` : "");
3532
+ insights.push({
3533
+ severity: "warning",
3534
+ type: "cross-endpoint",
3535
+ title: "Repeated Query Across Endpoints",
3536
+ desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
3537
+ hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
3538
+ nav: "queries"
3539
+ });
3540
+ }
3541
+ }
3542
+ return insights;
3301
3543
  }
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
- }
3544
+ };
3320
3545
  }
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
- }
3546
+ });
3547
+
3548
+ // src/analysis/insights/rules/redundant-query.ts
3549
+ var redundantQueryRule;
3550
+ var init_redundant_query = __esm({
3551
+ "src/analysis/insights/rules/redundant-query.ts"() {
3552
+ "use strict";
3553
+ init_query_helpers();
3554
+ init_endpoint();
3555
+ init_thresholds();
3556
+ redundantQueryRule = {
3557
+ id: "redundant-query",
3558
+ check(ctx) {
3559
+ const insights = [];
3560
+ const seen = /* @__PURE__ */ new Set();
3561
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
3562
+ const req = ctx.reqById.get(reqId);
3563
+ if (!req) continue;
3564
+ const endpoint = getEndpointKey(req.method, req.path);
3565
+ const exact = /* @__PURE__ */ new Map();
3566
+ for (const q of reqQueries) {
3567
+ if (!q.sql) continue;
3568
+ let entry = exact.get(q.sql);
3569
+ if (!entry) {
3570
+ entry = { count: 0, first: q };
3571
+ exact.set(q.sql, entry);
3572
+ }
3573
+ entry.count++;
3574
+ }
3575
+ for (const [, e] of exact) {
3576
+ if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
3577
+ const info = getQueryInfo(e.first);
3578
+ const label = info.op + (info.table ? ` ${info.table}` : "");
3579
+ const dedupKey = `${endpoint}:${label}`;
3580
+ if (seen.has(dedupKey)) continue;
3581
+ seen.add(dedupKey);
3582
+ insights.push({
3583
+ severity: "warning",
3584
+ type: "redundant-query",
3585
+ title: "Redundant Query",
3586
+ desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
3587
+ 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.",
3588
+ nav: "queries"
3589
+ });
3590
+ }
3591
+ }
3592
+ return insights;
3593
+ }
3594
+ };
3337
3595
  }
3338
- const endpointGroups = /* @__PURE__ */ new Map();
3339
- for (const r of nonStatic) {
3340
- const ep = `${r.method} ${r.path}`;
3341
- let g = endpointGroups.get(ep);
3342
- if (!g) {
3343
- g = { total: 0, errors: 0, totalDuration: 0, queryCount: 0, totalSize: 0 };
3344
- endpointGroups.set(ep, g);
3345
- }
3346
- g.total++;
3347
- if (r.statusCode >= 400) g.errors++;
3348
- g.totalDuration += r.durationMs;
3349
- g.queryCount += (queriesByReq.get(r.id) ?? []).length;
3350
- g.totalSize += r.responseSize ?? 0;
3596
+ });
3597
+
3598
+ // src/analysis/insights/rules/error.ts
3599
+ var errorRule;
3600
+ var init_error = __esm({
3601
+ "src/analysis/insights/rules/error.ts"() {
3602
+ "use strict";
3603
+ errorRule = {
3604
+ id: "error",
3605
+ check(ctx) {
3606
+ if (ctx.errors.length === 0) return [];
3607
+ const insights = [];
3608
+ const groups = /* @__PURE__ */ new Map();
3609
+ for (const e of ctx.errors) {
3610
+ const name = e.name || "Error";
3611
+ groups.set(name, (groups.get(name) ?? 0) + 1);
3612
+ }
3613
+ for (const [name, cnt] of groups) {
3614
+ insights.push({
3615
+ severity: "critical",
3616
+ type: "error",
3617
+ title: "Unhandled Error",
3618
+ desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
3619
+ hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
3620
+ nav: "errors"
3621
+ });
3622
+ }
3623
+ return insights;
3624
+ }
3625
+ };
3351
3626
  }
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
- }
3627
+ });
3628
+
3629
+ // src/analysis/insights/rules/error-hotspot.ts
3630
+ var errorHotspotRule;
3631
+ var init_error_hotspot = __esm({
3632
+ "src/analysis/insights/rules/error-hotspot.ts"() {
3633
+ "use strict";
3634
+ init_thresholds();
3635
+ errorHotspotRule = {
3636
+ id: "error-hotspot",
3637
+ check(ctx) {
3638
+ const insights = [];
3639
+ for (const [ep, g] of ctx.endpointGroups) {
3640
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3641
+ const errorRate = Math.round(g.errors / g.total * 100);
3642
+ if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
3643
+ insights.push({
3644
+ severity: "critical",
3645
+ type: "error-hotspot",
3646
+ title: "Error Hotspot",
3647
+ desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
3648
+ hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
3649
+ nav: "requests"
3650
+ });
3651
+ }
3652
+ }
3653
+ return insights;
3654
+ }
3655
+ };
3365
3656
  }
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);
3657
+ });
3658
+
3659
+ // src/analysis/insights/rules/duplicate.ts
3660
+ var duplicateRule;
3661
+ var init_duplicate = __esm({
3662
+ "src/analysis/insights/rules/duplicate.ts"() {
3663
+ "use strict";
3664
+ init_thresholds();
3665
+ duplicateRule = {
3666
+ id: "duplicate",
3667
+ check(ctx) {
3668
+ const dupCounts = /* @__PURE__ */ new Map();
3669
+ const flowCount = /* @__PURE__ */ new Map();
3670
+ for (const flow of ctx.flows) {
3671
+ if (!flow.requests) continue;
3672
+ const seenInFlow = /* @__PURE__ */ new Set();
3673
+ for (const fr of flow.requests) {
3674
+ if (!fr.isDuplicate) continue;
3675
+ const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
3676
+ dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
3677
+ if (!seenInFlow.has(dupKey)) {
3678
+ seenInFlow.add(dupKey);
3679
+ flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
3680
+ }
3681
+ }
3682
+ }
3683
+ const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
3684
+ const insights = [];
3685
+ for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
3686
+ const d = dupEntries[i];
3687
+ insights.push({
3688
+ severity: "warning",
3689
+ type: "duplicate",
3690
+ title: "Duplicate API Call",
3691
+ desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
3692
+ 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.",
3693
+ nav: "actions"
3694
+ });
3695
+ }
3696
+ return insights;
3378
3697
  }
3379
- }
3698
+ };
3380
3699
  }
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
- });
3700
+ });
3701
+
3702
+ // src/utils/format.ts
3703
+ function formatDuration(ms) {
3704
+ if (ms < 1e3) return `${ms}ms`;
3705
+ return `${(ms / 1e3).toFixed(1)}s`;
3706
+ }
3707
+ function formatSize(bytes) {
3708
+ if (bytes < 1024) return `${bytes}B`;
3709
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
3710
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
3711
+ }
3712
+ function pct(part, total) {
3713
+ return total > 0 ? Math.round(part / total * 100) : 0;
3714
+ }
3715
+ var init_format = __esm({
3716
+ "src/utils/format.ts"() {
3717
+ "use strict";
3392
3718
  }
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
- }
3719
+ });
3720
+
3721
+ // src/analysis/insights/rules/slow.ts
3722
+ var slowRule;
3723
+ var init_slow = __esm({
3724
+ "src/analysis/insights/rules/slow.ts"() {
3725
+ "use strict";
3726
+ init_format();
3727
+ init_thresholds();
3728
+ slowRule = {
3729
+ id: "slow",
3730
+ check(ctx) {
3731
+ const insights = [];
3732
+ for (const [ep, g] of ctx.endpointGroups) {
3733
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3734
+ const avgMs = Math.round(g.totalDuration / g.total);
3735
+ if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
3736
+ const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
3737
+ const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
3738
+ const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
3739
+ const parts = [];
3740
+ if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
3741
+ if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
3742
+ if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
3743
+ const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
3744
+ let detail;
3745
+ let slowestMs = 0;
3746
+ for (const [, sd] of g.queryShapeDurations) {
3747
+ const avgShapeMs = sd.totalMs / sd.count;
3748
+ if (avgShapeMs > slowestMs) {
3749
+ slowestMs = avgShapeMs;
3750
+ detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
3751
+ }
3752
+ }
3753
+ insights.push({
3754
+ severity: "warning",
3755
+ type: "slow",
3756
+ title: "Slow Endpoint",
3757
+ desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
3758
+ 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.",
3759
+ detail,
3760
+ nav: "requests"
3761
+ });
3762
+ }
3763
+ return insights;
3764
+ }
3765
+ };
3406
3766
  }
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
- }
3767
+ });
3768
+
3769
+ // src/analysis/insights/rules/query-heavy.ts
3770
+ var queryHeavyRule;
3771
+ var init_query_heavy = __esm({
3772
+ "src/analysis/insights/rules/query-heavy.ts"() {
3773
+ "use strict";
3774
+ init_thresholds();
3775
+ queryHeavyRule = {
3776
+ id: "query-heavy",
3777
+ check(ctx) {
3778
+ const insights = [];
3779
+ for (const [ep, g] of ctx.endpointGroups) {
3780
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
3781
+ const avgQueries = Math.round(g.queryCount / g.total);
3782
+ if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
3783
+ insights.push({
3784
+ severity: "warning",
3785
+ type: "query-heavy",
3786
+ title: "Query-Heavy Endpoint",
3787
+ desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
3788
+ hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
3789
+ nav: "queries"
3790
+ });
3791
+ }
3792
+ }
3793
+ return insights;
3794
+ }
3795
+ };
3420
3796
  }
3421
- const selectStarSeen = /* @__PURE__ */ new Map();
3422
- for (const [, reqQueries] of queriesByReq) {
3423
- 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;
3427
- const info = getQueryInfo(q);
3428
- const key = info.table || "unknown";
3429
- selectStarSeen.set(key, (selectStarSeen.get(key) ?? 0) + 1);
3430
- }
3797
+ });
3798
+
3799
+ // src/analysis/insights/rules/select-star.ts
3800
+ var selectStarRule;
3801
+ var init_select_star = __esm({
3802
+ "src/analysis/insights/rules/select-star.ts"() {
3803
+ "use strict";
3804
+ init_query_helpers();
3805
+ init_thresholds();
3806
+ init_patterns();
3807
+ selectStarRule = {
3808
+ id: "select-star",
3809
+ check(ctx) {
3810
+ const seen = /* @__PURE__ */ new Map();
3811
+ for (const [, reqQueries] of ctx.queriesByReq) {
3812
+ for (const q of reqQueries) {
3813
+ if (!q.sql) continue;
3814
+ const isSelectStar = SELECT_STAR_RE.test(q.sql.trim()) || SELECT_DOT_STAR_RE.test(q.sql);
3815
+ if (!isSelectStar) continue;
3816
+ const info = getQueryInfo(q);
3817
+ const key = info.table || "unknown";
3818
+ seen.set(key, (seen.get(key) ?? 0) + 1);
3819
+ }
3820
+ }
3821
+ const insights = [];
3822
+ for (const [table, count] of seen) {
3823
+ if (count < OVERFETCH_MIN_REQUESTS) continue;
3824
+ insights.push({
3825
+ severity: "warning",
3826
+ type: "select-star",
3827
+ title: "SELECT * Query",
3828
+ desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
3829
+ hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
3830
+ nav: "queries"
3831
+ });
3832
+ }
3833
+ return insights;
3834
+ }
3835
+ };
3431
3836
  }
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
- });
3837
+ });
3838
+
3839
+ // src/analysis/insights/rules/high-rows.ts
3840
+ var highRowsRule;
3841
+ var init_high_rows = __esm({
3842
+ "src/analysis/insights/rules/high-rows.ts"() {
3843
+ "use strict";
3844
+ init_query_helpers();
3845
+ init_thresholds();
3846
+ highRowsRule = {
3847
+ id: "high-rows",
3848
+ check(ctx) {
3849
+ const seen = /* @__PURE__ */ new Map();
3850
+ for (const [, reqQueries] of ctx.queriesByReq) {
3851
+ for (const q of reqQueries) {
3852
+ if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
3853
+ const info = getQueryInfo(q);
3854
+ const key = `${info.op} ${info.table || "unknown"}`;
3855
+ let entry = seen.get(key);
3856
+ if (!entry) {
3857
+ entry = { max: 0, count: 0 };
3858
+ seen.set(key, entry);
3859
+ }
3860
+ entry.count++;
3861
+ if (q.rowCount > entry.max) entry.max = q.rowCount;
3862
+ }
3863
+ }
3864
+ const insights = [];
3865
+ for (const [key, hrs] of seen) {
3866
+ if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
3867
+ insights.push({
3868
+ severity: "warning",
3869
+ type: "high-rows",
3870
+ title: "Large Result Set",
3871
+ desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
3872
+ hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
3873
+ nav: "queries"
3874
+ });
3875
+ }
3876
+ return insights;
3877
+ }
3878
+ };
3442
3879
  }
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;
3880
+ });
3881
+
3882
+ // src/utils/response.ts
3883
+ function unwrapResponse2(parsed) {
3884
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
3885
+ const obj = parsed;
3886
+ const keys = Object.keys(obj);
3887
+ if (keys.length > 3) return parsed;
3888
+ let best = null;
3889
+ let bestSize = 0;
3890
+ for (const key of keys) {
3891
+ const val = obj[key];
3892
+ if (Array.isArray(val) && val.length > bestSize) {
3893
+ best = val;
3894
+ bestSize = val.length;
3895
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
3896
+ const size = Object.keys(val).length;
3897
+ if (size > bestSize) {
3898
+ best = val;
3899
+ bestSize = size;
3900
+ }
3456
3901
  }
3457
3902
  }
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
- });
3903
+ return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
3904
+ }
3905
+ var init_response = __esm({
3906
+ "src/utils/response.ts"() {
3907
+ "use strict";
3908
+ init_thresholds();
3468
3909
  }
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
- }
3910
+ });
3911
+
3912
+ // src/analysis/insights/rules/response-overfetch.ts
3913
+ var responseOverfetchRule;
3914
+ var init_response_overfetch = __esm({
3915
+ "src/analysis/insights/rules/response-overfetch.ts"() {
3916
+ "use strict";
3917
+ init_endpoint();
3918
+ init_response();
3919
+ init_patterns();
3920
+ init_thresholds();
3921
+ responseOverfetchRule = {
3922
+ id: "response-overfetch",
3923
+ check(ctx) {
3924
+ const insights = [];
3925
+ const seen = /* @__PURE__ */ new Set();
3926
+ for (const r of ctx.nonStatic) {
3927
+ if (r.statusCode >= 400 || !r.responseBody) continue;
3928
+ const ep = getEndpointKey(r.method, r.path);
3929
+ if (seen.has(ep)) continue;
3930
+ let parsed;
3931
+ try {
3932
+ parsed = JSON.parse(r.responseBody);
3933
+ } catch {
3934
+ continue;
3935
+ }
3936
+ const target = unwrapResponse2(parsed);
3937
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
3938
+ if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
3939
+ const fields = Object.keys(inspectObj);
3940
+ if (fields.length < OVERFETCH_MIN_FIELDS) continue;
3941
+ let internalIdCount = 0;
3942
+ let nullCount = 0;
3943
+ for (const key of fields) {
3944
+ if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
3945
+ const val = inspectObj[key];
3946
+ if (val === null || val === void 0) nullCount++;
3947
+ }
3948
+ const nullRatio = nullCount / fields.length;
3949
+ const reasons = [];
3950
+ if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
3951
+ if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
3952
+ if (reasons.length === 0 && fields.length >= OVERFETCH_MANY_FIELDS) {
3953
+ reasons.push(`${fields.length} fields returned`);
3954
+ }
3955
+ if (reasons.length > 0) {
3956
+ seen.add(ep);
3957
+ insights.push({
3958
+ severity: "info",
3959
+ type: "response-overfetch",
3960
+ title: "Response Overfetch",
3961
+ desc: `${ep} \u2014 ${reasons.join(", ")}`,
3962
+ 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.",
3963
+ nav: "requests"
3964
+ });
3497
3965
  }
3498
3966
  }
3499
- if (best && bestSize >= 3) target = best;
3967
+ return insights;
3500
3968
  }
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
- }
3969
+ };
3531
3970
  }
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
- }
3971
+ });
3972
+
3973
+ // src/analysis/insights/rules/large-response.ts
3974
+ var largeResponseRule;
3975
+ var init_large_response = __esm({
3976
+ "src/analysis/insights/rules/large-response.ts"() {
3977
+ "use strict";
3978
+ init_format();
3979
+ init_thresholds();
3980
+ largeResponseRule = {
3981
+ id: "large-response",
3982
+ check(ctx) {
3983
+ const insights = [];
3984
+ for (const [ep, g] of ctx.endpointGroups) {
3985
+ if (g.total < OVERFETCH_MIN_REQUESTS) continue;
3986
+ const avgSize = Math.round(g.totalSize / g.total);
3987
+ if (avgSize > LARGE_RESPONSE_BYTES) {
3988
+ insights.push({
3989
+ severity: "info",
3990
+ type: "large-response",
3991
+ title: "Large Response",
3992
+ desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
3993
+ hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
3994
+ nav: "requests"
3995
+ });
3996
+ }
3997
+ }
3998
+ return insights;
3999
+ }
4000
+ };
3545
4001
  }
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
- }
4002
+ });
4003
+
4004
+ // src/analysis/insights/rules/regression.ts
4005
+ var regressionRule;
4006
+ var init_regression = __esm({
4007
+ "src/analysis/insights/rules/regression.ts"() {
4008
+ "use strict";
4009
+ init_format();
4010
+ init_thresholds();
4011
+ regressionRule = {
4012
+ id: "regression",
4013
+ check(ctx) {
4014
+ if (!ctx.previousMetrics || ctx.previousMetrics.length === 0) return [];
4015
+ const insights = [];
4016
+ for (const epMetrics of ctx.previousMetrics) {
4017
+ if (epMetrics.sessions.length < 2) continue;
4018
+ const prev = epMetrics.sessions[epMetrics.sessions.length - 2];
4019
+ const current = epMetrics.sessions[epMetrics.sessions.length - 1];
4020
+ if (prev.requestCount < REGRESSION_MIN_REQUESTS || current.requestCount < REGRESSION_MIN_REQUESTS) continue;
4021
+ const p95Increase = current.p95DurationMs - prev.p95DurationMs;
4022
+ const p95PctChange = prev.p95DurationMs > 0 ? Math.round(p95Increase / prev.p95DurationMs * 100) : 0;
4023
+ if (p95Increase >= REGRESSION_MIN_INCREASE_MS && p95PctChange >= REGRESSION_PCT_THRESHOLD) {
4024
+ insights.push({
4025
+ severity: "warning",
4026
+ type: "regression",
4027
+ title: "Performance Regression",
4028
+ desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
4029
+ hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing.",
4030
+ nav: "graph"
4031
+ });
4032
+ }
4033
+ if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
4034
+ insights.push({
4035
+ severity: "warning",
4036
+ type: "regression",
4037
+ title: "Query Count Regression",
4038
+ desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
4039
+ hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations.",
4040
+ nav: "queries"
4041
+ });
4042
+ }
4043
+ }
4044
+ return insights;
4045
+ }
4046
+ };
4047
+ }
4048
+ });
4049
+
4050
+ // src/analysis/insights/rules/security.ts
4051
+ var securityRule;
4052
+ var init_security2 = __esm({
4053
+ "src/analysis/insights/rules/security.ts"() {
4054
+ "use strict";
4055
+ securityRule = {
4056
+ id: "security",
4057
+ check(ctx) {
4058
+ if (!ctx.securityFindings) return [];
4059
+ return ctx.securityFindings.map((f) => ({
4060
+ severity: f.severity,
4061
+ type: "security",
4062
+ title: f.title,
4063
+ desc: f.desc,
4064
+ hint: f.hint,
4065
+ nav: "security"
4066
+ }));
4067
+ }
4068
+ };
3557
4069
  }
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;
4070
+ });
4071
+
4072
+ // src/analysis/insights/index.ts
4073
+ function createDefaultInsightRunner() {
4074
+ const runner = new InsightRunner();
4075
+ runner.register(n1Rule);
4076
+ runner.register(crossEndpointRule);
4077
+ runner.register(redundantQueryRule);
4078
+ runner.register(errorRule);
4079
+ runner.register(errorHotspotRule);
4080
+ runner.register(duplicateRule);
4081
+ runner.register(slowRule);
4082
+ runner.register(queryHeavyRule);
4083
+ runner.register(selectStarRule);
4084
+ runner.register(highRowsRule);
4085
+ runner.register(responseOverfetchRule);
4086
+ runner.register(largeResponseRule);
4087
+ runner.register(regressionRule);
4088
+ runner.register(securityRule);
4089
+ return runner;
4090
+ }
4091
+ function computeInsights(ctx) {
4092
+ return createDefaultInsightRunner().run(ctx);
3561
4093
  }
3562
4094
  var init_insights2 = __esm({
4095
+ "src/analysis/insights/index.ts"() {
4096
+ "use strict";
4097
+ init_runner();
4098
+ init_runner();
4099
+ init_n1();
4100
+ init_cross_endpoint();
4101
+ init_redundant_query();
4102
+ init_error();
4103
+ init_error_hotspot();
4104
+ init_duplicate();
4105
+ init_slow();
4106
+ init_query_heavy();
4107
+ init_select_star();
4108
+ init_high_rows();
4109
+ init_response_overfetch();
4110
+ init_large_response();
4111
+ init_regression();
4112
+ init_security2();
4113
+ }
4114
+ });
4115
+
4116
+ // src/analysis/insights.ts
4117
+ var init_insights3 = __esm({
3563
4118
  "src/analysis/insights.ts"() {
3564
4119
  "use strict";
3565
- init_constants();
3566
- init_thresholds();
3567
- init_normalize();
3568
- init_patterns();
4120
+ init_insights2();
3569
4121
  }
3570
4122
  });
3571
4123
 
@@ -3579,9 +4131,10 @@ var init_engine = __esm({
3579
4131
  init_request_log();
3580
4132
  init_group();
3581
4133
  init_rules();
3582
- init_insights2();
4134
+ init_insights3();
3583
4135
  AnalysisEngine = class {
3584
- constructor(debounceMs = 300) {
4136
+ constructor(metricsStore, debounceMs = 300) {
4137
+ this.metricsStore = metricsStore;
3585
4138
  this.debounceMs = debounceMs;
3586
4139
  this.scanner = createDefaultScanner();
3587
4140
  this.boundRequestListener = () => this.scheduleRecompute();
@@ -3639,6 +4192,7 @@ var init_engine = __esm({
3639
4192
  const queries = defaultQueryStore.getAll();
3640
4193
  const errors = defaultErrorStore.getAll();
3641
4194
  const logs = defaultLogStore.getAll();
4195
+ const fetches = defaultFetchStore.getAll();
3642
4196
  const flows = groupRequestsIntoFlows(requests);
3643
4197
  this.cachedFindings = this.scanner.scan({ requests, logs });
3644
4198
  this.cachedInsights = computeInsights({
@@ -3646,6 +4200,8 @@ var init_engine = __esm({
3646
4200
  queries,
3647
4201
  errors,
3648
4202
  flows,
4203
+ fetches,
4204
+ previousMetrics: this.metricsStore.getAll(),
3649
4205
  securityFindings: this.cachedFindings
3650
4206
  });
3651
4207
  for (const fn of this.listeners) {
@@ -3668,8 +4224,9 @@ var init_src = __esm({
3668
4224
  init_adapter_registry();
3669
4225
  init_rules();
3670
4226
  init_engine();
4227
+ init_insights3();
3671
4228
  init_insights2();
3672
- VERSION = "0.7.3";
4229
+ VERSION = "0.7.5";
3673
4230
  }
3674
4231
  });
3675
4232
 
@@ -4282,7 +4839,7 @@ function getFlowInsights() {
4282
4839
  }
4283
4840
  `;
4284
4841
  }
4285
- var init_insights3 = __esm({
4842
+ var init_insights4 = __esm({
4286
4843
  "src/dashboard/client/views/flows/insights.ts"() {
4287
4844
  "use strict";
4288
4845
  init_constants();
@@ -4474,7 +5031,7 @@ function getFlowsView() {
4474
5031
  var init_flows2 = __esm({
4475
5032
  "src/dashboard/client/views/flows.ts"() {
4476
5033
  "use strict";
4477
- init_insights3();
5034
+ init_insights4();
4478
5035
  init_detail();
4479
5036
  }
4480
5037
  });
@@ -5090,6 +5647,27 @@ function getGraphOverview() {
5090
5647
  (s.avgQueryCount > 0 ? '<span class="perf-ep-stat' + (s.avgQueryCount > HIGH_QUERY_THRESHOLD ? ' perf-ep-stat-warn' : '') + '">' + s.avgQueryCount + ' q/req</span>' : '') +
5091
5648
  '<span class="perf-ep-stat perf-ep-stat-muted">' + s.totalRequests + ' req' + (s.totalRequests !== 1 ? 's' : '') + '</span>';
5092
5649
 
5650
+ var ovTotal = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0);
5651
+ var ovBarHtml = '';
5652
+ if (ovTotal > 0) {
5653
+ var ovDbPct = Math.round((s.avgQueryTimeMs || 0) / ovTotal * 100);
5654
+ var ovFetchPct = Math.round((s.avgFetchTimeMs || 0) / ovTotal * 100);
5655
+ var ovAppPct = Math.max(0, 100 - ovDbPct - ovFetchPct);
5656
+ ovBarHtml =
5657
+ '<div class="perf-breakdown-inline">' +
5658
+ '<div class="perf-breakdown-bar perf-breakdown-bar-sm">' +
5659
+ (ovDbPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-db" style="width:' + ovDbPct + '%"></div>' : '') +
5660
+ (ovFetchPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-fetch" style="width:' + ovFetchPct + '%"></div>' : '') +
5661
+ (ovAppPct > 0 ? '<div class="perf-breakdown-seg perf-breakdown-app" style="width:' + ovAppPct + '%"></div>' : '') +
5662
+ '</div>' +
5663
+ '<span class="perf-breakdown-labels">' +
5664
+ (ovDbPct > 0 ? '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-db"></span>' + fmtMs(s.avgQueryTimeMs || 0) + '</span>' : '') +
5665
+ (ovFetchPct > 0 ? '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-fetch"></span>' + fmtMs(s.avgFetchTimeMs || 0) + '</span>' : '') +
5666
+ '<span class="perf-breakdown-lbl"><span class="perf-breakdown-dot perf-breakdown-app"></span>' + fmtMs(s.avgAppTimeMs || 0) + '</span>' +
5667
+ '</span>' +
5668
+ '</div>';
5669
+ }
5670
+
5093
5671
  var chartId = 'inline-scatter-' + idx;
5094
5672
 
5095
5673
  card.innerHTML =
@@ -5097,6 +5675,7 @@ function getGraphOverview() {
5097
5675
  '<span class="perf-ep-name">' + escHtml(ep.endpoint) + '</span>' +
5098
5676
  '<span class="perf-ep-stats">' + statsHtml + '</span>' +
5099
5677
  '</div>' +
5678
+ ovBarHtml +
5100
5679
  '<canvas id="' + chartId + '" class="perf-inline-canvas"></canvas>';
5101
5680
 
5102
5681
  list.appendChild(card);
@@ -5132,7 +5711,6 @@ function getGraphDetail() {
5132
5711
  var g = healthGrade(s.p95Ms);
5133
5712
  var errors = Math.round(s.errorRate * s.totalRequests);
5134
5713
 
5135
- // header
5136
5714
  var header = document.createElement('div');
5137
5715
  header.className = 'perf-detail-header';
5138
5716
  header.innerHTML =
@@ -5142,7 +5720,6 @@ function getGraphDetail() {
5142
5720
  '</div>';
5143
5721
  container.appendChild(header);
5144
5722
 
5145
- // metric cards
5146
5723
  var metrics = document.createElement('div');
5147
5724
  metrics.className = 'perf-metric-row';
5148
5725
  metrics.innerHTML =
@@ -5151,7 +5728,38 @@ function getGraphDetail() {
5151
5728
  buildMetricCard('Queries/req', String(s.avgQueryCount), s.avgQueryCount > ${HIGH_QUERY_COUNT_PER_REQ} ? 'var(--amber)' : 'var(--text)');
5152
5729
  container.appendChild(metrics);
5153
5730
 
5154
- // scatter chart
5731
+ var totalAvg = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0);
5732
+ if (totalAvg > 0) {
5733
+ var dbPct = Math.round((s.avgQueryTimeMs || 0) / totalAvg * 100);
5734
+ var fetchPct = Math.round((s.avgFetchTimeMs || 0) / totalAvg * 100);
5735
+ var appPct = Math.max(0, 100 - dbPct - fetchPct);
5736
+
5737
+ var breakdown = document.createElement('div');
5738
+ breakdown.className = 'perf-breakdown';
5739
+
5740
+ var breakdownLabel = document.createElement('div');
5741
+ breakdownLabel.className = 'perf-section-title';
5742
+ breakdownLabel.textContent = 'Time Breakdown';
5743
+ breakdown.appendChild(breakdownLabel);
5744
+
5745
+ var bar = document.createElement('div');
5746
+ bar.className = 'perf-breakdown-bar';
5747
+ if (dbPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-db" style="width:' + dbPct + '%"></div>';
5748
+ if (fetchPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-fetch" style="width:' + fetchPct + '%"></div>';
5749
+ if (appPct > 0) bar.innerHTML += '<div class="perf-breakdown-seg perf-breakdown-app" style="width:' + appPct + '%"></div>';
5750
+ breakdown.appendChild(bar);
5751
+
5752
+ var legend = document.createElement('div');
5753
+ legend.className = 'perf-breakdown-legend';
5754
+ legend.innerHTML =
5755
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-db"></span>DB ' + fmtMs(s.avgQueryTimeMs || 0) + ' (' + dbPct + '%)</span>' +
5756
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-fetch"></span>Fetch ' + fmtMs(s.avgFetchTimeMs || 0) + ' (' + fetchPct + '%)</span>' +
5757
+ '<span class="perf-breakdown-item"><span class="perf-breakdown-dot perf-breakdown-app"></span>App ' + fmtMs(s.avgAppTimeMs || 0) + ' (' + appPct + '%)</span>';
5758
+ breakdown.appendChild(legend);
5759
+
5760
+ container.appendChild(breakdown);
5761
+ }
5762
+
5155
5763
  var chartWrap = document.createElement('div');
5156
5764
  chartWrap.className = 'perf-chart-wrap';
5157
5765
  var chartLabel = document.createElement('div');
@@ -5169,7 +5777,6 @@ function getGraphDetail() {
5169
5777
 
5170
5778
  drawScatterChart(canvas, ep.requests);
5171
5779
 
5172
- // recent requests table
5173
5780
  if (ep.requests.length > 0) {
5174
5781
  var tableWrap = document.createElement('div');
5175
5782
  tableWrap.className = 'perf-history-wrap';
@@ -5180,6 +5787,7 @@ function getGraphDetail() {
5180
5787
  '<span class="perf-col perf-col-date">Time</span>' +
5181
5788
  '<span class="perf-col perf-col-health">Health</span>' +
5182
5789
  '<span class="perf-col perf-col-avg">Duration</span>' +
5790
+ '<span class="perf-col perf-col-breakdown">Breakdown</span>' +
5183
5791
  '<span class="perf-col perf-col-status">Status</span>' +
5184
5792
  '<span class="perf-col perf-col-qpr">Queries</span>';
5185
5793
  tableWrap.appendChild(colHeader);
@@ -5198,10 +5806,20 @@ function getGraphDetail() {
5198
5806
  var row = document.createElement('div');
5199
5807
  row.className = 'perf-hist-row' + (isError ? ' perf-hist-row-err' : '');
5200
5808
  row.setAttribute('data-req-idx', item.origIdx);
5809
+ var rDbMs = r.queryTimeMs || 0;
5810
+ var rFetchMs = r.fetchTimeMs || 0;
5811
+ var rAppMs = Math.max(0, r.durationMs - rDbMs - rFetchMs);
5812
+ var breakdownParts = [];
5813
+ if (rDbMs > 0) breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-db">DB ' + fmtMs(rDbMs) + '</span>');
5814
+ if (rFetchMs > 0) breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-fetch">Fetch ' + fmtMs(rFetchMs) + '</span>');
5815
+ breakdownParts.push('<span class="perf-bd-tag perf-bd-tag-app">App ' + fmtMs(rAppMs) + '</span>');
5816
+ var breakdownHtml = breakdownParts.join('');
5817
+
5201
5818
  row.innerHTML =
5202
5819
  '<span class="perf-col perf-col-date">' + timeStr + '</span>' +
5203
5820
  '<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
5821
  '<span class="perf-col perf-col-avg">' + fmtMs(r.durationMs) + '</span>' +
5822
+ '<span class="perf-col perf-col-breakdown">' + breakdownHtml + '</span>' +
5205
5823
  '<span class="perf-col perf-col-status" style="color:' + (isError ? 'var(--red)' : 'var(--text-muted)') + '">' + r.statusCode + '</span>' +
5206
5824
  '<span class="perf-col perf-col-qpr">' + r.queryCount + '</span>';
5207
5825
  tableWrap.appendChild(row);
@@ -5744,7 +6362,7 @@ function getSecurityView() {
5744
6362
  }
5745
6363
  `;
5746
6364
  }
5747
- var init_security2 = __esm({
6365
+ var init_security3 = __esm({
5748
6366
  "src/dashboard/client/views/security.ts"() {
5749
6367
  "use strict";
5750
6368
  }
@@ -6009,7 +6627,7 @@ var init_client = __esm({
6009
6627
  init_timeline2();
6010
6628
  init_graph2();
6011
6629
  init_overview3();
6012
- init_security2();
6630
+ init_security3();
6013
6631
  init_app2();
6014
6632
  }
6015
6633
  });
@@ -6113,7 +6731,7 @@ function createDashboardHandler(deps) {
6113
6731
  routes[DASHBOARD_API_TAB] = (req, res) => {
6114
6732
  const raw = (req.url ?? "").split("tab=")[1];
6115
6733
  if (raw) {
6116
- const tab = decodeURIComponent(raw).slice(0, 32);
6734
+ const tab = decodeURIComponent(raw).slice(0, MAX_TAB_NAME_LENGTH);
6117
6735
  if (VALID_TABS.has(tab) && isTelemetryEnabled()) recordTabViewed(tab);
6118
6736
  }
6119
6737
  res.writeHead(204);
@@ -6166,6 +6784,9 @@ var init_router = __esm({
6166
6784
 
6167
6785
  // src/output/terminal.ts
6168
6786
  import pc from "picocolors";
6787
+ function print(line) {
6788
+ process.stdout.write(line + "\n");
6789
+ }
6169
6790
  function severityIcon(severity) {
6170
6791
  if (severity === "critical") return pc.red("\u2717");
6171
6792
  if (severity === "warning") return pc.yellow("\u26A0");
@@ -6184,7 +6805,12 @@ function formatConsoleLine(insight, dashboardUrl, suffix) {
6184
6805
  const title = colorTitle(insight.severity, insight.title);
6185
6806
  const desc = pc.dim(truncate(insight.desc) + (suffix ?? ""));
6186
6807
  const link = pc.dim(`\u2192 ${dashboardUrl}`);
6187
- return ` ${icon} ${title} \u2014 ${desc} ${link}`;
6808
+ let line = ` ${icon} ${title} \u2014 ${desc} ${link}`;
6809
+ if (insight.detail) {
6810
+ line += `
6811
+ ${pc.dim("\u2514 " + insight.detail)}`;
6812
+ }
6813
+ return line;
6188
6814
  }
6189
6815
  function createConsoleInsightListener(proxyPort, metricsStore) {
6190
6816
  const printedKeys = /* @__PURE__ */ new Set();
@@ -6199,7 +6825,7 @@ function createConsoleInsightListener(proxyPort, metricsStore) {
6199
6825
  printedKeys.add(key);
6200
6826
  let suffix;
6201
6827
  if (insight.type === "slow") {
6202
- const ep = metricsStore.getAll().find((e) => e.endpoint === endpoint);
6828
+ const ep = metricsStore.getEndpoint(endpoint);
6203
6829
  if (ep && ep.sessions.length > 1) {
6204
6830
  const prev = ep.sessions[ep.sessions.length - 2];
6205
6831
  suffix = ` (\u2191 from ${prev.p95DurationMs < 1e3 ? prev.p95DurationMs + "ms" : (prev.p95DurationMs / 1e3).toFixed(1) + "s"})`;
@@ -6208,8 +6834,8 @@ function createConsoleInsightListener(proxyPort, metricsStore) {
6208
6834
  lines.push(formatConsoleLine(insight, dashUrl, suffix));
6209
6835
  }
6210
6836
  if (lines.length > 0) {
6211
- console.log();
6212
- for (const line of lines) console.log(line);
6837
+ print("");
6838
+ for (const line of lines) print(line);
6213
6839
  }
6214
6840
  };
6215
6841
  }
@@ -6303,6 +6929,12 @@ function decompress(body, encoding) {
6303
6929
  }
6304
6930
  return body;
6305
6931
  }
6932
+ function toBuffer(chunk) {
6933
+ if (Buffer.isBuffer(chunk)) return chunk;
6934
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
6935
+ if (typeof chunk === "string") return Buffer.from(chunk);
6936
+ return null;
6937
+ }
6306
6938
  function captureInProcess(req, res, requestId) {
6307
6939
  const startTime = performance.now();
6308
6940
  const method = req.method ?? "GET";
@@ -6314,9 +6946,11 @@ function captureInProcess(req, res, requestId) {
6314
6946
  try {
6315
6947
  const chunk = args[0];
6316
6948
  if (chunk != null && typeof chunk !== "function" && resSize < DEFAULT_MAX_BODY_CAPTURE) {
6317
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
6318
- resChunks.push(buf);
6319
- resSize += buf.length;
6949
+ const buf = toBuffer(chunk);
6950
+ if (buf) {
6951
+ resChunks.push(buf);
6952
+ resSize += buf.length;
6953
+ }
6320
6954
  }
6321
6955
  } catch {
6322
6956
  }
@@ -6326,12 +6960,15 @@ function captureInProcess(req, res, requestId) {
6326
6960
  try {
6327
6961
  const chunk = typeof args[0] !== "function" ? args[0] : void 0;
6328
6962
  if (chunk != null && resSize < DEFAULT_MAX_BODY_CAPTURE) {
6329
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
6330
- resChunks.push(buf);
6963
+ const buf = toBuffer(chunk);
6964
+ if (buf) {
6965
+ resChunks.push(buf);
6966
+ }
6331
6967
  }
6332
6968
  } catch {
6333
6969
  }
6334
6970
  const result = originalEnd.apply(this, args);
6971
+ const endTime = performance.now();
6335
6972
  try {
6336
6973
  const encoding = String(res.getHeader("content-encoding") ?? "").toLowerCase();
6337
6974
  let body = resChunks.length > 0 ? Buffer.concat(resChunks) : null;
@@ -6349,6 +6986,7 @@ function captureInProcess(req, res, requestId) {
6349
6986
  responseBody: body,
6350
6987
  responseContentType: String(res.getHeader("content-type") ?? ""),
6351
6988
  startTime,
6989
+ endTime,
6352
6990
  config: { maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE }
6353
6991
  });
6354
6992
  } catch {
@@ -6364,42 +7002,15 @@ var init_capture = __esm({
6364
7002
  }
6365
7003
  });
6366
7004
 
6367
- // src/runtime/setup.ts
6368
- var setup_exports = {};
6369
- __export(setup_exports, {
6370
- setup: () => setup
6371
- });
7005
+ // src/runtime/interceptor.ts
6372
7006
  import http from "http";
6373
7007
  import { randomUUID as randomUUID6 } from "crypto";
6374
- function setup() {
6375
- if (initialized) return;
6376
- initialized = true;
6377
- setEmitter(routeEvent2);
6378
- setupFetchHook();
6379
- setupConsoleHook();
6380
- setupErrorHook();
6381
- const registry = createDefaultRegistry();
6382
- registry.patchAll(routeEvent2);
6383
- const cwd = process.cwd();
6384
- const metricsStore = new MetricsStore(new FileMetricsPersistence(cwd));
6385
- metricsStore.start();
6386
- const analysisEngine = new AnalysisEngine();
6387
- analysisEngine.start();
6388
- const config = {
6389
- proxyPort: 0,
6390
- targetPort: 0,
6391
- showStatic: false,
6392
- maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE
6393
- };
6394
- const handleDashboard = createDashboardHandler({ metricsStore, analysisEngine });
6395
- onRequest((req) => {
6396
- const queryCount = defaultQueryStore.getByRequest(req.id).length;
6397
- metricsStore.recordRequest(req, queryCount);
6398
- });
6399
- const originalEmit = http.Server.prototype.emit;
7008
+ function installInterceptor(deps) {
7009
+ originalEmit = http.Server.prototype.emit;
7010
+ const saved = originalEmit;
6400
7011
  let bannerPrinted = false;
6401
7012
  http.Server.prototype.emit = safeWrap(
6402
- originalEmit,
7013
+ saved,
6403
7014
  function(original, event, ...args) {
6404
7015
  if (event !== "request") return original.apply(this, [event, ...args]);
6405
7016
  const req = args[0];
@@ -6409,9 +7020,8 @@ function setup() {
6409
7020
  const port = req.socket.localPort;
6410
7021
  if (port) {
6411
7022
  bannerPrinted = true;
6412
- config.proxyPort = port;
6413
- analysisEngine.onUpdate(createConsoleInsightListener(port, metricsStore));
6414
- console.log(` brakit v${VERSION} \u2014 http://localhost:${port}${DASHBOARD_PREFIX}`);
7023
+ deps.config.proxyPort = port;
7024
+ deps.onFirstRequest(port);
6415
7025
  }
6416
7026
  }
6417
7027
  if (isDashboardRequest(url)) {
@@ -6420,7 +7030,7 @@ function setup() {
6420
7030
  res.end("Not Found");
6421
7031
  return true;
6422
7032
  }
6423
- handleDashboard(req, res, config);
7033
+ deps.handleDashboard(req, res, deps.config);
6424
7034
  return true;
6425
7035
  }
6426
7036
  const requestId = randomUUID6();
@@ -6436,8 +7046,72 @@ function setup() {
6436
7046
  );
6437
7047
  }
6438
7048
  );
6439
- health.setTeardown(() => {
7049
+ }
7050
+ function uninstallInterceptor() {
7051
+ if (originalEmit) {
6440
7052
  http.Server.prototype.emit = originalEmit;
7053
+ originalEmit = null;
7054
+ }
7055
+ }
7056
+ var originalEmit;
7057
+ var init_interceptor = __esm({
7058
+ "src/runtime/interceptor.ts"() {
7059
+ "use strict";
7060
+ init_context();
7061
+ init_router();
7062
+ init_safe_wrap();
7063
+ init_guard();
7064
+ init_capture();
7065
+ originalEmit = null;
7066
+ }
7067
+ });
7068
+
7069
+ // src/runtime/setup.ts
7070
+ var setup_exports = {};
7071
+ __export(setup_exports, {
7072
+ setup: () => setup
7073
+ });
7074
+ function setup() {
7075
+ if (initialized) return;
7076
+ initialized = true;
7077
+ setEmitter(routeEvent2);
7078
+ setupFetchHook();
7079
+ setupConsoleHook();
7080
+ setupErrorHook();
7081
+ const registry = createDefaultRegistry();
7082
+ registry.patchAll(routeEvent2);
7083
+ const cwd = process.cwd();
7084
+ const metricsStore = new MetricsStore(new FileMetricsPersistence(cwd));
7085
+ metricsStore.start();
7086
+ const analysisEngine = new AnalysisEngine(metricsStore);
7087
+ analysisEngine.start();
7088
+ const config = {
7089
+ proxyPort: 0,
7090
+ targetPort: 0,
7091
+ showStatic: false,
7092
+ maxBodyCapture: DEFAULT_MAX_BODY_CAPTURE
7093
+ };
7094
+ const handleDashboard = createDashboardHandler({ metricsStore, analysisEngine });
7095
+ onRequest((req) => {
7096
+ const queries = defaultQueryStore.getByRequest(req.id);
7097
+ const fetches = defaultFetchStore.getByRequest(req.id);
7098
+ metricsStore.recordRequest(req, {
7099
+ queryCount: queries.length,
7100
+ queryTimeMs: queries.reduce((s, q) => s + q.durationMs, 0),
7101
+ fetchTimeMs: fetches.reduce((s, f) => s + f.durationMs, 0)
7102
+ });
7103
+ });
7104
+ installInterceptor({
7105
+ handleDashboard,
7106
+ config,
7107
+ onFirstRequest(port) {
7108
+ analysisEngine.onUpdate(createConsoleInsightListener(port, metricsStore));
7109
+ process.stdout.write(` brakit v${VERSION} \u2014 http://localhost:${port}${DASHBOARD_PREFIX}
7110
+ `);
7111
+ }
7112
+ });
7113
+ health.setTeardown(() => {
7114
+ uninstallInterceptor();
6441
7115
  analysisEngine.stop();
6442
7116
  metricsStore.stop();
6443
7117
  });
@@ -6462,7 +7136,6 @@ var initialized;
6462
7136
  var init_setup = __esm({
6463
7137
  "src/runtime/setup.ts"() {
6464
7138
  "use strict";
6465
- init_context();
6466
7139
  init_transport2();
6467
7140
  init_fetch();
6468
7141
  init_console();
@@ -6475,10 +7148,8 @@ var init_setup = __esm({
6475
7148
  init_terminal();
6476
7149
  init_src();
6477
7150
  init_constants();
6478
- init_safe_wrap();
6479
7151
  init_health2();
6480
- init_guard();
6481
- init_capture();
7152
+ init_interceptor();
6482
7153
  initialized = false;
6483
7154
  }
6484
7155
  });