brakit 0.8.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/api.d.ts +83 -46
- package/dist/api.js +776 -767
- package/dist/bin/brakit.js +305 -432
- package/dist/dashboard-client.global.js +465 -267
- package/dist/dashboard.html +584 -310
- package/dist/mcp/server.js +7 -15
- package/dist/runtime/index.js +1566 -1700
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -10,7 +10,7 @@ import { createHash } from "crypto";
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { resolve, join } from "path";
|
|
12
12
|
|
|
13
|
-
// src/constants/
|
|
13
|
+
// src/constants/config.ts
|
|
14
14
|
var ANALYSIS_DEBOUNCE_MS = 300;
|
|
15
15
|
var ISSUE_ID_HASH_LENGTH = 16;
|
|
16
16
|
var ISSUES_DATA_VERSION = 2;
|
|
@@ -21,6 +21,39 @@ var FULL_RECORD_MIN_FIELDS = 8;
|
|
|
21
21
|
var LIST_PII_MIN_ITEMS = 2;
|
|
22
22
|
var MAX_OBJECT_SCAN_DEPTH = 5;
|
|
23
23
|
var ISSUE_PRUNE_TTL_MS = 10 * 60 * 1e3;
|
|
24
|
+
var FLOW_GAP_MS = 5e3;
|
|
25
|
+
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
26
|
+
var MIN_POLLING_SEQUENCE = 3;
|
|
27
|
+
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
28
|
+
var N1_QUERY_THRESHOLD = 5;
|
|
29
|
+
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
30
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
31
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
32
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
33
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
34
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
35
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
36
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
37
|
+
var HIGH_ROW_COUNT = 100;
|
|
38
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
39
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
40
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
41
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
42
|
+
var REGRESSION_PCT_THRESHOLD = 50;
|
|
43
|
+
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
44
|
+
var REGRESSION_MIN_REQUESTS = 5;
|
|
45
|
+
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
46
|
+
var OVERFETCH_MANY_FIELDS = 12;
|
|
47
|
+
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
48
|
+
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
49
|
+
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
50
|
+
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
51
|
+
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
52
|
+
var STRICT_MODE_MAX_GAP_MS = 2e3;
|
|
53
|
+
var BASELINE_MIN_SESSIONS = 2;
|
|
54
|
+
var BASELINE_MIN_REQUESTS_PER_SESSION = 3;
|
|
55
|
+
var ISSUES_FILE = "issues.json";
|
|
56
|
+
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
24
57
|
|
|
25
58
|
// src/utils/log.ts
|
|
26
59
|
var PREFIX = "[brakit]";
|
|
@@ -42,7 +75,9 @@ function getErrorMessage(err) {
|
|
|
42
75
|
return String(err);
|
|
43
76
|
}
|
|
44
77
|
function validateIssuesData(parsed) {
|
|
45
|
-
if (parsed
|
|
78
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
79
|
+
const obj = parsed;
|
|
80
|
+
if (obj.version === ISSUES_DATA_VERSION && Array.isArray(obj.issues)) {
|
|
46
81
|
return parsed;
|
|
47
82
|
}
|
|
48
83
|
return null;
|
|
@@ -86,41 +121,6 @@ async function ensureGitignoreAsync(dir, entry) {
|
|
|
86
121
|
}
|
|
87
122
|
}
|
|
88
123
|
|
|
89
|
-
// src/constants/metrics.ts
|
|
90
|
-
var ISSUES_FILE = "issues.json";
|
|
91
|
-
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
92
|
-
|
|
93
|
-
// src/constants/thresholds.ts
|
|
94
|
-
var FLOW_GAP_MS = 5e3;
|
|
95
|
-
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
96
|
-
var MIN_POLLING_SEQUENCE = 3;
|
|
97
|
-
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
98
|
-
var N1_QUERY_THRESHOLD = 5;
|
|
99
|
-
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
100
|
-
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
101
|
-
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
102
|
-
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
103
|
-
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
104
|
-
var CROSS_ENDPOINT_PCT = 50;
|
|
105
|
-
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
106
|
-
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
107
|
-
var LARGE_RESPONSE_BYTES = 51200;
|
|
108
|
-
var HIGH_ROW_COUNT = 100;
|
|
109
|
-
var OVERFETCH_MIN_REQUESTS = 2;
|
|
110
|
-
var OVERFETCH_MIN_FIELDS = 8;
|
|
111
|
-
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
112
|
-
var OVERFETCH_NULL_RATIO = 0.3;
|
|
113
|
-
var REGRESSION_PCT_THRESHOLD = 50;
|
|
114
|
-
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
115
|
-
var REGRESSION_MIN_REQUESTS = 5;
|
|
116
|
-
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
117
|
-
var OVERFETCH_MANY_FIELDS = 12;
|
|
118
|
-
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
119
|
-
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
120
|
-
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
121
|
-
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
122
|
-
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
123
|
-
|
|
124
124
|
// src/utils/atomic-writer.ts
|
|
125
125
|
import {
|
|
126
126
|
writeFileSync as writeFileSync2,
|
|
@@ -467,11 +467,12 @@ function tryParseJson(body) {
|
|
|
467
467
|
return null;
|
|
468
468
|
}
|
|
469
469
|
}
|
|
470
|
+
var MAX_WRAPPER_KEYS = 3;
|
|
470
471
|
function unwrapResponse(parsed) {
|
|
471
472
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
472
473
|
const obj = parsed;
|
|
473
474
|
const keys = Object.keys(obj);
|
|
474
|
-
if (keys.length >
|
|
475
|
+
if (keys.length > MAX_WRAPPER_KEYS) return parsed;
|
|
475
476
|
let best = null;
|
|
476
477
|
let bestSize = 0;
|
|
477
478
|
for (const key of keys) {
|
|
@@ -529,27 +530,87 @@ function isRedirect(code) {
|
|
|
529
530
|
return code >= 300 && code < 400;
|
|
530
531
|
}
|
|
531
532
|
|
|
532
|
-
// src/
|
|
533
|
-
function
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
533
|
+
// src/utils/collections.ts
|
|
534
|
+
function getOrCreate(map, key, create) {
|
|
535
|
+
let value = map.get(key);
|
|
536
|
+
if (value === void 0) {
|
|
537
|
+
value = create();
|
|
538
|
+
map.set(key, value);
|
|
539
|
+
}
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
542
|
+
function deduplicateFindings(items, extract) {
|
|
543
|
+
const seen = /* @__PURE__ */ new Map();
|
|
544
|
+
const findings = [];
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
const result = extract(item);
|
|
547
|
+
if (!result) continue;
|
|
548
|
+
const existing = seen.get(result.key);
|
|
549
|
+
if (existing) {
|
|
550
|
+
existing.count++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
seen.set(result.key, result.finding);
|
|
554
|
+
findings.push(result.finding);
|
|
555
|
+
}
|
|
556
|
+
return findings;
|
|
557
|
+
}
|
|
558
|
+
function groupBy(items, keyFn) {
|
|
559
|
+
const map = /* @__PURE__ */ new Map();
|
|
560
|
+
for (const item of items) {
|
|
561
|
+
const key = keyFn(item);
|
|
562
|
+
if (key == null) continue;
|
|
563
|
+
let arr = map.get(key);
|
|
564
|
+
if (!arr) {
|
|
565
|
+
arr = [];
|
|
566
|
+
map.set(key, arr);
|
|
540
567
|
}
|
|
541
|
-
|
|
568
|
+
arr.push(item);
|
|
542
569
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
570
|
+
return map;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/utils/object-scan.ts
|
|
574
|
+
var DEFAULTS = {
|
|
575
|
+
maxDepth: MAX_OBJECT_SCAN_DEPTH,
|
|
576
|
+
arrayLimit: SECRET_SCAN_ARRAY_LIMIT
|
|
577
|
+
};
|
|
578
|
+
function walkObject(obj, visitor, options) {
|
|
579
|
+
const opts = { ...DEFAULTS, ...options };
|
|
580
|
+
walk(obj, visitor, opts, 0);
|
|
581
|
+
}
|
|
582
|
+
function walk(obj, visitor, opts, depth) {
|
|
583
|
+
if (depth >= opts.maxDepth) return;
|
|
584
|
+
if (!obj || typeof obj !== "object") return;
|
|
585
|
+
if (Array.isArray(obj)) {
|
|
586
|
+
for (let i = 0; i < Math.min(obj.length, opts.arrayLimit); i++) {
|
|
587
|
+
walk(obj[i], visitor, opts, depth + 1);
|
|
547
588
|
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
for (const key of Object.keys(obj)) {
|
|
592
|
+
const val = obj[key];
|
|
593
|
+
visitor(key, val, depth);
|
|
548
594
|
if (typeof val === "object" && val !== null) {
|
|
549
|
-
|
|
595
|
+
walk(val, visitor, opts, depth + 1);
|
|
550
596
|
}
|
|
551
597
|
}
|
|
552
|
-
|
|
598
|
+
}
|
|
599
|
+
function collectFromObject(obj, match, options) {
|
|
600
|
+
const results = [];
|
|
601
|
+
walkObject(obj, (key, value) => {
|
|
602
|
+
const result = match(key, value);
|
|
603
|
+
if (result !== null) results.push(result);
|
|
604
|
+
}, options);
|
|
605
|
+
return results;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/analysis/rules/auth-rules.ts
|
|
609
|
+
function findSecretKeys(obj) {
|
|
610
|
+
return collectFromObject(
|
|
611
|
+
obj,
|
|
612
|
+
(key, val) => SECRET_KEYS.test(key) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val) ? key : null
|
|
613
|
+
);
|
|
553
614
|
}
|
|
554
615
|
var exposedSecretRule = {
|
|
555
616
|
id: "exposed-secret",
|
|
@@ -557,50 +618,39 @@ var exposedSecretRule = {
|
|
|
557
618
|
name: "Exposed Secret in Response",
|
|
558
619
|
hint: RULE_HINTS["exposed-secret"],
|
|
559
620
|
check(ctx) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
const
|
|
565
|
-
if (
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
580
|
-
hint: this.hint,
|
|
581
|
-
endpoint: ep,
|
|
582
|
-
count: 1
|
|
621
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
622
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
623
|
+
const parsed = ctx.parsedBodies.response.get(request.id);
|
|
624
|
+
if (!parsed) return null;
|
|
625
|
+
const keys = findSecretKeys(parsed);
|
|
626
|
+
if (keys.length === 0) return null;
|
|
627
|
+
const ep = `${request.method} ${request.path}`;
|
|
628
|
+
return {
|
|
629
|
+
key: `${ep}:${keys.sort().join(",")}`,
|
|
630
|
+
finding: {
|
|
631
|
+
severity: "critical",
|
|
632
|
+
rule: "exposed-secret",
|
|
633
|
+
title: "Exposed Secret in Response",
|
|
634
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
635
|
+
hint: this.hint,
|
|
636
|
+
detail: `Exposed fields: ${keys.join(", ")}. ${keys.length} unmasked secret value${keys.length !== 1 ? "s" : ""} in response body.`,
|
|
637
|
+
endpoint: ep,
|
|
638
|
+
count: 1
|
|
639
|
+
}
|
|
583
640
|
};
|
|
584
|
-
|
|
585
|
-
findings.push(finding);
|
|
586
|
-
}
|
|
587
|
-
return findings;
|
|
641
|
+
});
|
|
588
642
|
}
|
|
589
643
|
};
|
|
590
|
-
|
|
591
|
-
// src/analysis/rules/token-in-url.ts
|
|
592
644
|
var tokenInUrlRule = {
|
|
593
645
|
id: "token-in-url",
|
|
594
646
|
severity: "critical",
|
|
595
647
|
name: "Auth Token in URL",
|
|
596
648
|
hint: RULE_HINTS["token-in-url"],
|
|
597
649
|
check(ctx) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
if (qIdx === -1) continue;
|
|
603
|
-
const params = r.url.substring(qIdx + 1).split("&");
|
|
650
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
651
|
+
const qIdx = request.url.indexOf("?");
|
|
652
|
+
if (qIdx === -1) return null;
|
|
653
|
+
const params = request.url.substring(qIdx + 1).split("&");
|
|
604
654
|
const flagged = [];
|
|
605
655
|
for (const param of params) {
|
|
606
656
|
const [name, ...rest] = param.split("=");
|
|
@@ -610,65 +660,128 @@ var tokenInUrlRule = {
|
|
|
610
660
|
flagged.push(name);
|
|
611
661
|
}
|
|
612
662
|
}
|
|
613
|
-
if (flagged.length === 0)
|
|
614
|
-
const ep = `${
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
663
|
+
if (flagged.length === 0) return null;
|
|
664
|
+
const ep = `${request.method} ${request.path}`;
|
|
665
|
+
return {
|
|
666
|
+
key: `${ep}:${flagged.sort().join(",")}`,
|
|
667
|
+
finding: {
|
|
668
|
+
severity: "critical",
|
|
669
|
+
rule: "token-in-url",
|
|
670
|
+
title: "Auth Token in URL",
|
|
671
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
672
|
+
hint: this.hint,
|
|
673
|
+
detail: `Parameters in URL: ${flagged.join(", ")}. Auth tokens in URLs are logged by proxies, browsers, and CDNs.`,
|
|
674
|
+
endpoint: ep,
|
|
675
|
+
count: 1
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
function isFrameworkResponse(request) {
|
|
682
|
+
if (isRedirect(request.statusCode)) return true;
|
|
683
|
+
if (request.path?.startsWith("/__")) return true;
|
|
684
|
+
if (request.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
var insecureCookieRule = {
|
|
688
|
+
id: "insecure-cookie",
|
|
689
|
+
severity: "warning",
|
|
690
|
+
name: "Insecure Cookie",
|
|
691
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
692
|
+
check(ctx) {
|
|
693
|
+
const cookieEntries = [];
|
|
694
|
+
for (const request of ctx.requests) {
|
|
695
|
+
if (!request.responseHeaders) continue;
|
|
696
|
+
if (isFrameworkResponse(request)) continue;
|
|
697
|
+
const setCookie = request.responseHeaders["set-cookie"];
|
|
698
|
+
if (!setCookie) continue;
|
|
699
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
700
|
+
for (const cookie of cookies) {
|
|
701
|
+
cookieEntries.push({ cookie });
|
|
620
702
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
703
|
+
}
|
|
704
|
+
return deduplicateFindings(cookieEntries, ({ cookie }) => {
|
|
705
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
706
|
+
const lower = cookie.toLowerCase();
|
|
707
|
+
const issues = [];
|
|
708
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
709
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
710
|
+
if (issues.length === 0) return null;
|
|
711
|
+
return {
|
|
712
|
+
key: `${cookieName}:${issues.join(",")}`,
|
|
713
|
+
finding: {
|
|
714
|
+
severity: "warning",
|
|
715
|
+
rule: "insecure-cookie",
|
|
716
|
+
title: "Insecure Cookie",
|
|
717
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
718
|
+
hint: this.hint,
|
|
719
|
+
detail: `Missing: ${issues.join(", ")}. ${issues.includes("HttpOnly") ? "Cookie accessible via JavaScript (XSS risk). " : ""}${issues.includes("SameSite") ? "Cookie sent on cross-site requests (CSRF risk)." : ""}`,
|
|
720
|
+
endpoint: cookieName,
|
|
721
|
+
count: 1
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
var corsCredentialsRule = {
|
|
728
|
+
id: "cors-credentials",
|
|
729
|
+
severity: "warning",
|
|
730
|
+
name: "CORS Credentials with Wildcard",
|
|
731
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
732
|
+
check(ctx) {
|
|
733
|
+
const findings = [];
|
|
734
|
+
const seen = /* @__PURE__ */ new Set();
|
|
735
|
+
for (const request of ctx.requests) {
|
|
736
|
+
if (!request.responseHeaders) continue;
|
|
737
|
+
const origin = request.responseHeaders["access-control-allow-origin"];
|
|
738
|
+
const creds = request.responseHeaders["access-control-allow-credentials"];
|
|
739
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
740
|
+
const ep = `${request.method} ${request.path}`;
|
|
741
|
+
if (seen.has(ep)) continue;
|
|
742
|
+
seen.add(ep);
|
|
743
|
+
findings.push({
|
|
744
|
+
severity: "warning",
|
|
745
|
+
rule: "cors-credentials",
|
|
746
|
+
title: "CORS Credentials with Wildcard",
|
|
747
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
626
748
|
hint: this.hint,
|
|
627
749
|
endpoint: ep,
|
|
628
750
|
count: 1
|
|
629
|
-
};
|
|
630
|
-
seen.set(dedupKey, finding);
|
|
631
|
-
findings.push(finding);
|
|
751
|
+
});
|
|
632
752
|
}
|
|
633
753
|
return findings;
|
|
634
754
|
}
|
|
635
755
|
};
|
|
636
756
|
|
|
637
|
-
// src/analysis/rules/
|
|
757
|
+
// src/analysis/rules/data-rules.ts
|
|
638
758
|
var stackTraceLeakRule = {
|
|
639
759
|
id: "stack-trace-leak",
|
|
640
760
|
severity: "critical",
|
|
641
761
|
name: "Stack Trace Leaked to Client",
|
|
642
762
|
hint: RULE_HINTS["stack-trace-leak"],
|
|
643
763
|
check(ctx) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
endpoint: ep,
|
|
662
|
-
count: 1
|
|
764
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
765
|
+
if (!request.responseBody) return null;
|
|
766
|
+
if (!STACK_TRACE_RE.test(request.responseBody)) return null;
|
|
767
|
+
const ep = `${request.method} ${request.path}`;
|
|
768
|
+
const firstLine = request.responseBody.split("\n").find((l) => STACK_TRACE_RE.test(l))?.trim() ?? "";
|
|
769
|
+
return {
|
|
770
|
+
key: ep,
|
|
771
|
+
finding: {
|
|
772
|
+
severity: "critical",
|
|
773
|
+
rule: "stack-trace-leak",
|
|
774
|
+
title: "Stack Trace Leaked to Client",
|
|
775
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
776
|
+
hint: this.hint,
|
|
777
|
+
detail: firstLine ? `Stack trace: ${firstLine.slice(0, 120)}` : void 0,
|
|
778
|
+
endpoint: ep,
|
|
779
|
+
count: 1
|
|
780
|
+
}
|
|
663
781
|
};
|
|
664
|
-
|
|
665
|
-
findings.push(finding);
|
|
666
|
-
}
|
|
667
|
-
return findings;
|
|
782
|
+
});
|
|
668
783
|
}
|
|
669
784
|
};
|
|
670
|
-
|
|
671
|
-
// src/analysis/rules/error-info-leak.ts
|
|
672
785
|
var CRITICAL_PATTERNS = [
|
|
673
786
|
{ re: DB_CONN_RE, label: "database connection string" },
|
|
674
787
|
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
@@ -680,90 +793,35 @@ var errorInfoLeakRule = {
|
|
|
680
793
|
name: "Sensitive Data in Error Response",
|
|
681
794
|
hint: RULE_HINTS["error-info-leak"],
|
|
682
795
|
check(ctx) {
|
|
683
|
-
const
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
if (
|
|
687
|
-
if (
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const dedupKey = `${ep}:${p.label}`;
|
|
693
|
-
const existing = seen.get(dedupKey);
|
|
694
|
-
if (existing) {
|
|
695
|
-
existing.count++;
|
|
696
|
-
continue;
|
|
796
|
+
const entries = [];
|
|
797
|
+
for (const request of ctx.requests) {
|
|
798
|
+
if (request.statusCode < 400) continue;
|
|
799
|
+
if (!request.responseBody) continue;
|
|
800
|
+
if (request.responseHeaders["x-nextjs-error"] || request.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
801
|
+
const ep = `${request.method} ${request.path}`;
|
|
802
|
+
for (const pattern of CRITICAL_PATTERNS) {
|
|
803
|
+
if (pattern.re.test(request.responseBody)) {
|
|
804
|
+
entries.push({ ep, pattern, body: request.responseBody });
|
|
697
805
|
}
|
|
698
|
-
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return deduplicateFindings(entries, ({ ep, pattern }) => {
|
|
809
|
+
return {
|
|
810
|
+
key: `${ep}:${pattern.label}`,
|
|
811
|
+
finding: {
|
|
699
812
|
severity: "critical",
|
|
700
813
|
rule: "error-info-leak",
|
|
701
814
|
title: "Sensitive Data in Error Response",
|
|
702
|
-
desc: `${ep} \u2014 error response exposes ${
|
|
815
|
+
desc: `${ep} \u2014 error response exposes ${pattern.label}`,
|
|
703
816
|
hint: this.hint,
|
|
817
|
+
detail: `Detected: ${pattern.label} in error response body`,
|
|
704
818
|
endpoint: ep,
|
|
705
819
|
count: 1
|
|
706
|
-
};
|
|
707
|
-
seen.set(dedupKey, finding);
|
|
708
|
-
findings.push(finding);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
return findings;
|
|
712
|
-
}
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
// src/analysis/rules/insecure-cookie.ts
|
|
716
|
-
function isFrameworkResponse(r) {
|
|
717
|
-
if (isRedirect(r.statusCode)) return true;
|
|
718
|
-
if (r.path?.startsWith("/__")) return true;
|
|
719
|
-
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
720
|
-
return false;
|
|
721
|
-
}
|
|
722
|
-
var insecureCookieRule = {
|
|
723
|
-
id: "insecure-cookie",
|
|
724
|
-
severity: "warning",
|
|
725
|
-
name: "Insecure Cookie",
|
|
726
|
-
hint: RULE_HINTS["insecure-cookie"],
|
|
727
|
-
check(ctx) {
|
|
728
|
-
const findings = [];
|
|
729
|
-
const seen = /* @__PURE__ */ new Map();
|
|
730
|
-
for (const r of ctx.requests) {
|
|
731
|
-
if (!r.responseHeaders) continue;
|
|
732
|
-
if (isFrameworkResponse(r)) continue;
|
|
733
|
-
const setCookie = r.responseHeaders["set-cookie"];
|
|
734
|
-
if (!setCookie) continue;
|
|
735
|
-
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
736
|
-
for (const cookie of cookies) {
|
|
737
|
-
const cookieName = cookie.trim().split("=")[0].trim();
|
|
738
|
-
const lower = cookie.toLowerCase();
|
|
739
|
-
const issues = [];
|
|
740
|
-
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
741
|
-
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
742
|
-
if (issues.length === 0) continue;
|
|
743
|
-
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
744
|
-
const existing = seen.get(dedupKey);
|
|
745
|
-
if (existing) {
|
|
746
|
-
existing.count++;
|
|
747
|
-
continue;
|
|
748
820
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
rule: "insecure-cookie",
|
|
752
|
-
title: "Insecure Cookie",
|
|
753
|
-
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
754
|
-
hint: this.hint,
|
|
755
|
-
endpoint: cookieName,
|
|
756
|
-
count: 1
|
|
757
|
-
};
|
|
758
|
-
seen.set(dedupKey, finding);
|
|
759
|
-
findings.push(finding);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
return findings;
|
|
821
|
+
};
|
|
822
|
+
});
|
|
763
823
|
}
|
|
764
824
|
};
|
|
765
|
-
|
|
766
|
-
// src/analysis/rules/sensitive-logs.ts
|
|
767
825
|
var sensitiveLogsRule = {
|
|
768
826
|
id: "sensitive-logs",
|
|
769
827
|
severity: "warning",
|
|
@@ -788,58 +846,13 @@ var sensitiveLogsRule = {
|
|
|
788
846
|
}];
|
|
789
847
|
}
|
|
790
848
|
};
|
|
791
|
-
|
|
792
|
-
// src/analysis/rules/cors-credentials.ts
|
|
793
|
-
var corsCredentialsRule = {
|
|
794
|
-
id: "cors-credentials",
|
|
795
|
-
severity: "warning",
|
|
796
|
-
name: "CORS Credentials with Wildcard",
|
|
797
|
-
hint: RULE_HINTS["cors-credentials"],
|
|
798
|
-
check(ctx) {
|
|
799
|
-
const findings = [];
|
|
800
|
-
const seen = /* @__PURE__ */ new Set();
|
|
801
|
-
for (const r of ctx.requests) {
|
|
802
|
-
if (!r.responseHeaders) continue;
|
|
803
|
-
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
804
|
-
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
805
|
-
if (origin !== "*" || creds !== "true") continue;
|
|
806
|
-
const ep = `${r.method} ${r.path}`;
|
|
807
|
-
if (seen.has(ep)) continue;
|
|
808
|
-
seen.add(ep);
|
|
809
|
-
findings.push({
|
|
810
|
-
severity: "warning",
|
|
811
|
-
rule: "cors-credentials",
|
|
812
|
-
title: "CORS Credentials with Wildcard",
|
|
813
|
-
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
814
|
-
hint: this.hint,
|
|
815
|
-
endpoint: ep,
|
|
816
|
-
count: 1
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
return findings;
|
|
820
|
-
}
|
|
821
|
-
};
|
|
822
|
-
|
|
823
|
-
// src/analysis/rules/response-pii-leak.ts
|
|
824
849
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
825
|
-
function findEmails(obj
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
emails.push(...findEmails(obj[i], depth + 1));
|
|
832
|
-
}
|
|
833
|
-
return emails;
|
|
834
|
-
}
|
|
835
|
-
for (const v of Object.values(obj)) {
|
|
836
|
-
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
837
|
-
emails.push(v);
|
|
838
|
-
} else if (typeof v === "object" && v !== null) {
|
|
839
|
-
emails.push(...findEmails(v, depth + 1));
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return emails;
|
|
850
|
+
function findEmails(obj) {
|
|
851
|
+
return collectFromObject(
|
|
852
|
+
obj,
|
|
853
|
+
(_key, val) => typeof val === "string" && EMAIL_RE.test(val) ? val : null,
|
|
854
|
+
{ arrayLimit: PII_SCAN_ARRAY_LIMIT }
|
|
855
|
+
);
|
|
843
856
|
}
|
|
844
857
|
function topLevelFieldCount(obj) {
|
|
845
858
|
if (Array.isArray(obj)) {
|
|
@@ -924,35 +937,33 @@ var responsePiiLeakRule = {
|
|
|
924
937
|
name: "PII Leak in Response",
|
|
925
938
|
hint: RULE_HINTS["response-pii-leak"],
|
|
926
939
|
check(ctx) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
if (
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
if (
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
940
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
941
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
942
|
+
if (SELF_SERVICE_PATH.test(request.path)) return null;
|
|
943
|
+
const resJson = ctx.parsedBodies.response.get(request.id);
|
|
944
|
+
if (!resJson) return null;
|
|
945
|
+
const reqJson = ctx.parsedBodies.request.get(request.id) ?? null;
|
|
946
|
+
const detection = detectPII(request.method, reqJson, resJson);
|
|
947
|
+
if (!detection) return null;
|
|
948
|
+
const ep = `${request.method} ${request.path}`;
|
|
949
|
+
const fieldCount = topLevelFieldCount(resJson);
|
|
950
|
+
const detailParts = [`Pattern: ${REASON_LABELS[detection.reason]}`];
|
|
951
|
+
if (detection.emailCount > 0) detailParts.push(`${detection.emailCount} email${detection.emailCount !== 1 ? "s" : ""} detected`);
|
|
952
|
+
if (fieldCount > 0) detailParts.push(`${fieldCount} fields per record`);
|
|
953
|
+
return {
|
|
954
|
+
key: ep,
|
|
955
|
+
finding: {
|
|
956
|
+
severity: "warning",
|
|
957
|
+
rule: "response-pii-leak",
|
|
958
|
+
title: "PII Leak in Response",
|
|
959
|
+
desc: `${ep} \u2014 exposes PII in response`,
|
|
960
|
+
hint: this.hint,
|
|
961
|
+
detail: detailParts.join(". "),
|
|
962
|
+
endpoint: ep,
|
|
963
|
+
count: 1
|
|
964
|
+
}
|
|
951
965
|
};
|
|
952
|
-
|
|
953
|
-
findings.push(finding);
|
|
954
|
-
}
|
|
955
|
-
return findings;
|
|
966
|
+
});
|
|
956
967
|
}
|
|
957
968
|
};
|
|
958
969
|
|
|
@@ -960,14 +971,14 @@ var responsePiiLeakRule = {
|
|
|
960
971
|
function buildBodyCache(requests) {
|
|
961
972
|
const response = /* @__PURE__ */ new Map();
|
|
962
973
|
const request = /* @__PURE__ */ new Map();
|
|
963
|
-
for (const
|
|
964
|
-
if (
|
|
965
|
-
const parsed = tryParseJson(
|
|
966
|
-
if (parsed != null) response.set(
|
|
974
|
+
for (const req of requests) {
|
|
975
|
+
if (req.responseBody) {
|
|
976
|
+
const parsed = tryParseJson(req.responseBody);
|
|
977
|
+
if (parsed != null) response.set(req.id, parsed);
|
|
967
978
|
}
|
|
968
|
-
if (
|
|
969
|
-
const parsed = tryParseJson(
|
|
970
|
-
if (parsed != null) request.set(
|
|
979
|
+
if (req.requestBody) {
|
|
980
|
+
const parsed = tryParseJson(req.requestBody);
|
|
981
|
+
if (parsed != null) request.set(req.id, parsed);
|
|
971
982
|
}
|
|
972
983
|
}
|
|
973
984
|
return { response, request };
|
|
@@ -988,7 +999,8 @@ var SecurityScanner = class {
|
|
|
988
999
|
for (const rule of this.rules) {
|
|
989
1000
|
try {
|
|
990
1001
|
findings.push(...rule.check(ctx));
|
|
991
|
-
} catch {
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
brakitDebug(`rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
992
1004
|
}
|
|
993
1005
|
}
|
|
994
1006
|
return findings;
|
|
@@ -1027,7 +1039,7 @@ var SubscriptionBag = class {
|
|
|
1027
1039
|
// src/analysis/group.ts
|
|
1028
1040
|
import { randomUUID } from "crypto";
|
|
1029
1041
|
|
|
1030
|
-
// src/constants/
|
|
1042
|
+
// src/constants/labels.ts
|
|
1031
1043
|
var DASHBOARD_PREFIX = "/__brakit";
|
|
1032
1044
|
var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
|
|
1033
1045
|
var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
|
|
@@ -1059,13 +1071,40 @@ var VALID_TABS_TUPLE = [
|
|
|
1059
1071
|
];
|
|
1060
1072
|
var VALID_TABS = new Set(VALID_TABS_TUPLE);
|
|
1061
1073
|
|
|
1062
|
-
// src/constants/
|
|
1074
|
+
// src/constants/features.ts
|
|
1063
1075
|
var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
|
|
1064
1076
|
|
|
1077
|
+
// src/utils/endpoint.ts
|
|
1078
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1079
|
+
var NUMERIC_ID_RE = /^\d+$/;
|
|
1080
|
+
var HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
|
|
1081
|
+
var ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
|
|
1082
|
+
function isDynamicSegment(segment) {
|
|
1083
|
+
return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
|
|
1084
|
+
}
|
|
1085
|
+
var DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
|
|
1086
|
+
function normalizePath(path) {
|
|
1087
|
+
const qIdx = path.indexOf("?");
|
|
1088
|
+
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1089
|
+
return pathname.split("/").map((seg) => seg && isDynamicSegment(seg) ? DYNAMIC_SEGMENT_PLACEHOLDER : seg).join("/");
|
|
1090
|
+
}
|
|
1091
|
+
function getEndpointKey(method, path) {
|
|
1092
|
+
return `${method} ${normalizePath(path)}`;
|
|
1093
|
+
}
|
|
1094
|
+
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1095
|
+
function extractEndpointFromDesc(desc) {
|
|
1096
|
+
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1097
|
+
}
|
|
1098
|
+
function stripQueryString(path) {
|
|
1099
|
+
const i = path.indexOf("?");
|
|
1100
|
+
return i === -1 ? path : path.slice(0, i);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1065
1103
|
// src/analysis/categorize.ts
|
|
1066
1104
|
function detectCategory(req) {
|
|
1067
1105
|
const { method, url, statusCode, responseHeaders } = req;
|
|
1068
1106
|
if (req.isStatic) return "static";
|
|
1107
|
+
if (req.isHealthCheck) return "health-check";
|
|
1069
1108
|
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
1070
1109
|
return "auth-handshake";
|
|
1071
1110
|
}
|
|
@@ -1217,20 +1256,42 @@ function capitalize(s) {
|
|
|
1217
1256
|
}
|
|
1218
1257
|
|
|
1219
1258
|
// src/analysis/transforms.ts
|
|
1220
|
-
|
|
1259
|
+
var DUPLICATE_CATEGORIES = /* @__PURE__ */ new Set(["data-fetch", "auth-check"]);
|
|
1260
|
+
function isDuplicateCandidate(req) {
|
|
1261
|
+
return DUPLICATE_CATEGORIES.has(req.category);
|
|
1262
|
+
}
|
|
1263
|
+
function buildRequestKey(req) {
|
|
1264
|
+
return `${req.method} ${stripQueryString(getEffectivePath(req))}`;
|
|
1265
|
+
}
|
|
1266
|
+
function isStrictModePattern(requests, counts) {
|
|
1267
|
+
if (counts.size === 0 || ![...counts.values()].every((c) => c === 2)) {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
const firstByKey = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const req of requests) {
|
|
1272
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1273
|
+
const key = buildRequestKey(req);
|
|
1274
|
+
const first = firstByKey.get(key);
|
|
1275
|
+
if (!first) {
|
|
1276
|
+
firstByKey.set(key, req);
|
|
1277
|
+
} else if (Math.abs(req.startedAt - first.startedAt) > STRICT_MODE_MAX_GAP_MS) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
function flagDuplicateRequests(requests) {
|
|
1221
1284
|
const counts = /* @__PURE__ */ new Map();
|
|
1222
1285
|
for (const req of requests) {
|
|
1223
|
-
if (req
|
|
1224
|
-
|
|
1225
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1286
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1287
|
+
const key = buildRequestKey(req);
|
|
1226
1288
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1227
1289
|
}
|
|
1228
|
-
const isStrictMode =
|
|
1290
|
+
const isStrictMode = isStrictModePattern(requests, counts);
|
|
1229
1291
|
const seen = /* @__PURE__ */ new Set();
|
|
1230
1292
|
for (const req of requests) {
|
|
1231
|
-
if (req
|
|
1232
|
-
|
|
1233
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1293
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1294
|
+
const key = buildRequestKey(req);
|
|
1234
1295
|
if (seen.has(key)) {
|
|
1235
1296
|
if (isStrictMode) {
|
|
1236
1297
|
req.isStrictModeDupe = true;
|
|
@@ -1242,20 +1303,20 @@ function markDuplicates(requests) {
|
|
|
1242
1303
|
}
|
|
1243
1304
|
}
|
|
1244
1305
|
}
|
|
1245
|
-
function
|
|
1306
|
+
function mergePollingSequences(requests) {
|
|
1246
1307
|
const result = [];
|
|
1247
1308
|
let i = 0;
|
|
1248
1309
|
while (i < requests.length) {
|
|
1249
1310
|
const current = requests[i];
|
|
1250
|
-
const currentEffective = getEffectivePath(current)
|
|
1311
|
+
const currentEffective = stripQueryString(getEffectivePath(current));
|
|
1251
1312
|
if (current.method === "GET" && current.category === "data-fetch") {
|
|
1252
|
-
let
|
|
1253
|
-
while (
|
|
1254
|
-
|
|
1313
|
+
let nextIndex = i + 1;
|
|
1314
|
+
while (nextIndex < requests.length && requests[nextIndex].method === "GET" && stripQueryString(getEffectivePath(requests[nextIndex])) === currentEffective) {
|
|
1315
|
+
nextIndex++;
|
|
1255
1316
|
}
|
|
1256
|
-
const count =
|
|
1317
|
+
const count = nextIndex - i;
|
|
1257
1318
|
if (count >= MIN_POLLING_SEQUENCE) {
|
|
1258
|
-
const last = requests[
|
|
1319
|
+
const last = requests[nextIndex - 1];
|
|
1259
1320
|
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
1260
1321
|
const endpointName = prettifyEndpoint(currentEffective);
|
|
1261
1322
|
result.push({
|
|
@@ -1266,7 +1327,7 @@ function collapsePolling(requests) {
|
|
|
1266
1327
|
pollingDurationMs: pollingDuration,
|
|
1267
1328
|
isDuplicate: false
|
|
1268
1329
|
});
|
|
1269
|
-
i =
|
|
1330
|
+
i = nextIndex;
|
|
1270
1331
|
continue;
|
|
1271
1332
|
}
|
|
1272
1333
|
}
|
|
@@ -1279,18 +1340,18 @@ function formatDurationLabel(ms) {
|
|
|
1279
1340
|
if (ms < 1e3) return `${ms}ms`;
|
|
1280
1341
|
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1281
1342
|
}
|
|
1282
|
-
function
|
|
1343
|
+
function collectRequestWarnings(requests) {
|
|
1283
1344
|
const warnings = [];
|
|
1284
1345
|
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
1285
1346
|
if (duplicateCount > 0) {
|
|
1286
1347
|
const unique = new Set(
|
|
1287
|
-
requests.filter((r) => r.isDuplicate).map((r) =>
|
|
1348
|
+
requests.filter((r) => r.isDuplicate).map((r) => buildRequestKey(r))
|
|
1288
1349
|
);
|
|
1289
1350
|
const endpoints = unique.size;
|
|
1290
1351
|
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
1291
|
-
const key =
|
|
1352
|
+
const key = buildRequestKey(r);
|
|
1292
1353
|
const first = requests.find(
|
|
1293
|
-
(o) => !o.isDuplicate &&
|
|
1354
|
+
(o) => !o.isDuplicate && buildRequestKey(o) === key
|
|
1294
1355
|
);
|
|
1295
1356
|
return first && first.responseBody === r.responseBody;
|
|
1296
1357
|
});
|
|
@@ -1313,6 +1374,14 @@ function detectWarnings(requests) {
|
|
|
1313
1374
|
}
|
|
1314
1375
|
|
|
1315
1376
|
// src/analysis/group.ts
|
|
1377
|
+
function shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, startedAt) {
|
|
1378
|
+
if (currentRequests.length === 0) return false;
|
|
1379
|
+
const sourcePage = labeled.sourcePage;
|
|
1380
|
+
const isNewPage = sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1381
|
+
const isTimeGap = startedAt - lastEndTime > FLOW_GAP_MS;
|
|
1382
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1383
|
+
return isNewPage || isTimeGap || isPageLoad;
|
|
1384
|
+
}
|
|
1316
1385
|
function groupRequestsIntoFlows(requests) {
|
|
1317
1386
|
if (requests.length === 0) return [];
|
|
1318
1387
|
const flows = [];
|
|
@@ -1323,17 +1392,12 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1323
1392
|
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
1324
1393
|
const labeled = labelRequest(req);
|
|
1325
1394
|
if (labeled.category === "static") continue;
|
|
1326
|
-
|
|
1327
|
-
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
1328
|
-
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1329
|
-
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1330
|
-
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
1331
|
-
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
1395
|
+
if (shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, req.startedAt)) {
|
|
1332
1396
|
flows.push(buildFlow(currentRequests));
|
|
1333
1397
|
currentRequests = [];
|
|
1334
1398
|
}
|
|
1335
1399
|
currentRequests.push(labeled);
|
|
1336
|
-
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
1400
|
+
currentSourcePage = labeled.sourcePage ?? currentSourcePage;
|
|
1337
1401
|
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
1338
1402
|
}
|
|
1339
1403
|
if (currentRequests.length > 0) {
|
|
@@ -1342,8 +1406,8 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1342
1406
|
return flows;
|
|
1343
1407
|
}
|
|
1344
1408
|
function buildFlow(rawRequests) {
|
|
1345
|
-
|
|
1346
|
-
const requests =
|
|
1409
|
+
flagDuplicateRequests(rawRequests);
|
|
1410
|
+
const requests = mergePollingSequences(rawRequests);
|
|
1347
1411
|
const first = requests[0];
|
|
1348
1412
|
const startTime = first.startedAt;
|
|
1349
1413
|
const endTime = Math.max(
|
|
@@ -1362,7 +1426,7 @@ function buildFlow(rawRequests) {
|
|
|
1362
1426
|
startTime,
|
|
1363
1427
|
totalDurationMs: Math.round(endTime - startTime),
|
|
1364
1428
|
hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
|
|
1365
|
-
warnings:
|
|
1429
|
+
warnings: collectRequestWarnings(rawRequests),
|
|
1366
1430
|
sourcePage,
|
|
1367
1431
|
redundancyPct
|
|
1368
1432
|
};
|
|
@@ -1374,20 +1438,20 @@ function getDominantSourcePage(requests) {
|
|
|
1374
1438
|
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
1375
1439
|
}
|
|
1376
1440
|
}
|
|
1377
|
-
let
|
|
1378
|
-
let
|
|
1441
|
+
let mostCommonPage = "";
|
|
1442
|
+
let highestCount = 0;
|
|
1379
1443
|
for (const [page, count] of counts) {
|
|
1380
|
-
if (count >
|
|
1381
|
-
|
|
1382
|
-
|
|
1444
|
+
if (count > highestCount) {
|
|
1445
|
+
mostCommonPage = page;
|
|
1446
|
+
highestCount = count;
|
|
1383
1447
|
}
|
|
1384
1448
|
}
|
|
1385
|
-
return
|
|
1449
|
+
return mostCommonPage || (requests[0]?.path ? stripQueryString(requests[0].path) : "") || "/";
|
|
1386
1450
|
}
|
|
1387
1451
|
function deriveFlowLabel(requests, sourcePage) {
|
|
1388
1452
|
const trigger = requests.find((r) => r.category === "api-call") ?? requests.find((r) => r.category === "server-action") ?? requests.find((r) => r.category === "page-load") ?? requests.find((r) => r.category === "navigation") ?? requests.find((r) => r.category === "data-fetch") ?? requests[0];
|
|
1389
1453
|
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
1390
|
-
const pageName = prettifyPageName(trigger.path
|
|
1454
|
+
const pageName = prettifyPageName(stripQueryString(trigger.path));
|
|
1391
1455
|
return `${pageName} Page`;
|
|
1392
1456
|
}
|
|
1393
1457
|
if (trigger.category === "api-call") {
|
|
@@ -1412,67 +1476,23 @@ function deriveFlowLabel(requests, sourcePage) {
|
|
|
1412
1476
|
return trigger.label;
|
|
1413
1477
|
}
|
|
1414
1478
|
|
|
1415
|
-
// src/utils/collections.ts
|
|
1416
|
-
function groupBy(items, keyFn) {
|
|
1417
|
-
const map = /* @__PURE__ */ new Map();
|
|
1418
|
-
for (const item of items) {
|
|
1419
|
-
const key = keyFn(item);
|
|
1420
|
-
if (key == null) continue;
|
|
1421
|
-
let arr = map.get(key);
|
|
1422
|
-
if (!arr) {
|
|
1423
|
-
arr = [];
|
|
1424
|
-
map.set(key, arr);
|
|
1425
|
-
}
|
|
1426
|
-
arr.push(item);
|
|
1427
|
-
}
|
|
1428
|
-
return map;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// src/utils/endpoint.ts
|
|
1432
|
-
var DYNAMIC_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^\d+$|^[0-9a-f]{12,}$|^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/i;
|
|
1433
|
-
function normalizePath(path) {
|
|
1434
|
-
const qIdx = path.indexOf("?");
|
|
1435
|
-
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1436
|
-
return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
|
|
1437
|
-
}
|
|
1438
|
-
function getEndpointKey(method, path) {
|
|
1439
|
-
return `${method} ${normalizePath(path)}`;
|
|
1440
|
-
}
|
|
1441
|
-
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1442
|
-
function extractEndpointFromDesc(desc) {
|
|
1443
|
-
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
1479
|
// src/instrument/adapters/normalize.ts
|
|
1480
|
+
var VALID_OPS = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
1481
|
+
var TABLE_RE = /(?:FROM|INTO|UPDATE)\s+(?:"?\w+"?\.)?"?(\w+)"?/i;
|
|
1447
1482
|
function normalizeSQL(sql) {
|
|
1448
1483
|
if (!sql) return { op: "OTHER", table: "" };
|
|
1449
1484
|
const trimmed = sql.trim();
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
case "SELECT":
|
|
1459
|
-
return { op: "SELECT", table };
|
|
1460
|
-
case "INSERT":
|
|
1461
|
-
return { op: "INSERT", table };
|
|
1462
|
-
case "UPDATE":
|
|
1463
|
-
return { op: "UPDATE", table };
|
|
1464
|
-
case "DELETE":
|
|
1465
|
-
return { op: "DELETE", table };
|
|
1466
|
-
default:
|
|
1467
|
-
return { op: "OTHER", table };
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1485
|
+
const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
|
|
1486
|
+
const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
|
|
1487
|
+
const table = trimmed.match(TABLE_RE)?.[1] ?? "";
|
|
1488
|
+
return { op, table };
|
|
1489
|
+
}
|
|
1490
|
+
var SQL_PARAM_MARKER = /\$\d+/g;
|
|
1491
|
+
var SQL_STRING_LITERAL = /'[^']*'/g;
|
|
1492
|
+
var SQL_NUMBER_LITERAL = /\b\d+(\.\d+)?\b/g;
|
|
1470
1493
|
function normalizeQueryParams(sql) {
|
|
1471
1494
|
if (!sql) return null;
|
|
1472
|
-
|
|
1473
|
-
n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
|
|
1474
|
-
n = n.replace(/\$\d+/g, "?");
|
|
1475
|
-
return n;
|
|
1495
|
+
return sql.replace(SQL_PARAM_MARKER, "?").replace(SQL_STRING_LITERAL, "?").replace(SQL_NUMBER_LITERAL, "?");
|
|
1476
1496
|
}
|
|
1477
1497
|
|
|
1478
1498
|
// src/analysis/insights/query-helpers.ts
|
|
@@ -1489,7 +1509,7 @@ function getQueryInfo(q) {
|
|
|
1489
1509
|
}
|
|
1490
1510
|
|
|
1491
1511
|
// src/analysis/insights/prepare.ts
|
|
1492
|
-
function
|
|
1512
|
+
function emptyEndpointGroup() {
|
|
1493
1513
|
return {
|
|
1494
1514
|
total: 0,
|
|
1495
1515
|
errors: 0,
|
|
@@ -1501,16 +1521,12 @@ function createEndpointGroup() {
|
|
|
1501
1521
|
queryShapeDurations: /* @__PURE__ */ new Map()
|
|
1502
1522
|
};
|
|
1503
1523
|
}
|
|
1504
|
-
function
|
|
1524
|
+
function keepRecentPerEndpoint(requests) {
|
|
1505
1525
|
const byEndpoint = /* @__PURE__ */ new Map();
|
|
1506
|
-
for (const
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
list = [];
|
|
1511
|
-
byEndpoint.set(ep, list);
|
|
1512
|
-
}
|
|
1513
|
-
list.push(r);
|
|
1526
|
+
for (const request of requests) {
|
|
1527
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1528
|
+
const list = getOrCreate(byEndpoint, endpointKey, () => []);
|
|
1529
|
+
list.push(request);
|
|
1514
1530
|
}
|
|
1515
1531
|
const windowed = [];
|
|
1516
1532
|
for (const [, reqs] of byEndpoint) {
|
|
@@ -1518,54 +1534,67 @@ function windowByEndpoint(requests) {
|
|
|
1518
1534
|
}
|
|
1519
1535
|
return windowed;
|
|
1520
1536
|
}
|
|
1537
|
+
function filterUserRequests(requests) {
|
|
1538
|
+
return requests.filter(
|
|
1539
|
+
(request) => !request.isStatic && !request.isHealthCheck && (!request.path || !request.path.startsWith(DASHBOARD_PREFIX))
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1521
1542
|
function extractActiveEndpoints(requests) {
|
|
1522
1543
|
const endpoints = /* @__PURE__ */ new Set();
|
|
1523
|
-
for (const
|
|
1524
|
-
|
|
1525
|
-
endpoints.add(getEndpointKey(r.method, r.path));
|
|
1526
|
-
}
|
|
1544
|
+
for (const request of filterUserRequests(requests)) {
|
|
1545
|
+
endpoints.add(getEndpointKey(request.method, request.path));
|
|
1527
1546
|
}
|
|
1528
1547
|
return endpoints;
|
|
1529
1548
|
}
|
|
1530
|
-
function
|
|
1531
|
-
const nonStatic = ctx.requests.filter(
|
|
1532
|
-
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1533
|
-
);
|
|
1534
|
-
const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
|
|
1535
|
-
const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
|
|
1536
|
-
const reqById = new Map(nonStatic.map((r) => [r.id, r]));
|
|
1537
|
-
const recent = windowByEndpoint(nonStatic);
|
|
1549
|
+
function aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq) {
|
|
1538
1550
|
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1539
|
-
for (const
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1551
|
+
for (const request of recent) {
|
|
1552
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1553
|
+
const group = getOrCreate(endpointGroups, endpointKey, emptyEndpointGroup);
|
|
1554
|
+
group.total++;
|
|
1555
|
+
if (isErrorStatus(request.statusCode)) group.errors++;
|
|
1556
|
+
group.totalDuration += request.durationMs;
|
|
1557
|
+
group.totalSize += request.responseSize ?? 0;
|
|
1558
|
+
const reqQueries = queriesByReq.get(request.id) ?? [];
|
|
1559
|
+
group.queryCount += reqQueries.length;
|
|
1560
|
+
for (const query of reqQueries) {
|
|
1561
|
+
group.totalQueryTimeMs += query.durationMs;
|
|
1562
|
+
const shape = getQueryShape(query);
|
|
1563
|
+
const info = getQueryInfo(query);
|
|
1564
|
+
const shapeDuration = getOrCreate(group.queryShapeDurations, shape, () => ({
|
|
1565
|
+
totalMs: 0,
|
|
1566
|
+
count: 0,
|
|
1567
|
+
label: info.op + (info.table ? ` ${info.table}` : "")
|
|
1568
|
+
}));
|
|
1569
|
+
shapeDuration.totalMs += query.durationMs;
|
|
1570
|
+
shapeDuration.count++;
|
|
1571
|
+
}
|
|
1572
|
+
const reqFetches = fetchesByReq.get(request.id) ?? [];
|
|
1573
|
+
for (const fetch of reqFetches) {
|
|
1574
|
+
group.totalFetchTimeMs += fetch.durationMs;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return endpointGroups;
|
|
1578
|
+
}
|
|
1579
|
+
function collectStrictModeDupeIds(ctx) {
|
|
1580
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1581
|
+
for (const flow of ctx.flows) {
|
|
1582
|
+
for (const req of flow.requests) {
|
|
1583
|
+
if (req.isStrictModeDupe) ids.add(req.id);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return ids;
|
|
1587
|
+
}
|
|
1588
|
+
function buildInsightContext(ctx) {
|
|
1589
|
+
const strictModeDupeIds = collectStrictModeDupeIds(ctx);
|
|
1590
|
+
const nonStatic = filterUserRequests(ctx.requests).filter((req) => !strictModeDupeIds.has(req.id));
|
|
1591
|
+
const filteredQueries = strictModeDupeIds.size > 0 ? ctx.queries.filter((q) => !q.parentRequestId || !strictModeDupeIds.has(q.parentRequestId)) : ctx.queries;
|
|
1592
|
+
const filteredFetches = strictModeDupeIds.size > 0 ? ctx.fetches.filter((f) => !f.parentRequestId || !strictModeDupeIds.has(f.parentRequestId)) : ctx.fetches;
|
|
1593
|
+
const queriesByReq = groupBy(filteredQueries, (query) => query.parentRequestId);
|
|
1594
|
+
const fetchesByReq = groupBy(filteredFetches, (fetch) => fetch.parentRequestId);
|
|
1595
|
+
const reqById = new Map(nonStatic.map((request) => [request.id, request]));
|
|
1596
|
+
const recent = keepRecentPerEndpoint(nonStatic);
|
|
1597
|
+
const endpointGroups = aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq);
|
|
1569
1598
|
return {
|
|
1570
1599
|
...ctx,
|
|
1571
1600
|
nonStatic,
|
|
@@ -1586,12 +1615,13 @@ var InsightRunner = class {
|
|
|
1586
1615
|
this.rules.push(rule);
|
|
1587
1616
|
}
|
|
1588
1617
|
run(ctx) {
|
|
1589
|
-
const prepared =
|
|
1618
|
+
const prepared = buildInsightContext(ctx);
|
|
1590
1619
|
const insights = [];
|
|
1591
1620
|
for (const rule of this.rules) {
|
|
1592
1621
|
try {
|
|
1593
1622
|
insights.push(...rule.check(prepared));
|
|
1594
|
-
} catch {
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
brakitDebug(`insight rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
1595
1625
|
}
|
|
1596
1626
|
}
|
|
1597
1627
|
insights.sort(
|
|
@@ -1601,338 +1631,121 @@ var InsightRunner = class {
|
|
|
1601
1631
|
}
|
|
1602
1632
|
};
|
|
1603
1633
|
|
|
1604
|
-
// src/analysis/insights/rules/
|
|
1634
|
+
// src/analysis/insights/rules/query-rules.ts
|
|
1605
1635
|
var n1Rule = {
|
|
1606
1636
|
id: "n1",
|
|
1607
1637
|
check(ctx) {
|
|
1608
1638
|
const insights = [];
|
|
1609
|
-
const
|
|
1639
|
+
const reportedKeys = /* @__PURE__ */ new Set();
|
|
1610
1640
|
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1611
1641
|
const req = ctx.reqById.get(reqId);
|
|
1612
1642
|
if (!req) continue;
|
|
1613
1643
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1614
1644
|
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1615
|
-
for (const
|
|
1616
|
-
const shape = getQueryShape(
|
|
1645
|
+
for (const query of reqQueries) {
|
|
1646
|
+
const shape = getQueryShape(query);
|
|
1617
1647
|
let group = shapeGroups.get(shape);
|
|
1618
1648
|
if (!group) {
|
|
1619
|
-
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first:
|
|
1649
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: query };
|
|
1620
1650
|
shapeGroups.set(shape, group);
|
|
1621
1651
|
}
|
|
1622
1652
|
group.count++;
|
|
1623
|
-
group.distinctSql.add(
|
|
1653
|
+
group.distinctSql.add(query.sql ?? shape);
|
|
1624
1654
|
}
|
|
1625
|
-
for (const [,
|
|
1626
|
-
if (
|
|
1627
|
-
const info = getQueryInfo(
|
|
1655
|
+
for (const [, shapeGroup] of shapeGroups) {
|
|
1656
|
+
if (shapeGroup.count <= N1_QUERY_THRESHOLD || shapeGroup.distinctSql.size <= 1) continue;
|
|
1657
|
+
const info = getQueryInfo(shapeGroup.first);
|
|
1628
1658
|
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
|
|
1629
|
-
if (
|
|
1630
|
-
|
|
1659
|
+
if (reportedKeys.has(key)) continue;
|
|
1660
|
+
reportedKeys.add(key);
|
|
1631
1661
|
insights.push({
|
|
1632
1662
|
severity: "critical",
|
|
1633
1663
|
type: "n1",
|
|
1634
1664
|
title: "N+1 Query Pattern",
|
|
1635
|
-
desc: `${endpoint} runs ${
|
|
1665
|
+
desc: `${endpoint} runs ${shapeGroup.count}x ${info.op} ${info.table} with different params in a single request`,
|
|
1636
1666
|
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.",
|
|
1637
|
-
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
return insights;
|
|
1642
|
-
}
|
|
1643
|
-
};
|
|
1644
|
-
|
|
1645
|
-
// src/analysis/insights/rules/cross-endpoint.ts
|
|
1646
|
-
var crossEndpointRule = {
|
|
1647
|
-
id: "cross-endpoint",
|
|
1648
|
-
check(ctx) {
|
|
1649
|
-
const insights = [];
|
|
1650
|
-
const queryMap = /* @__PURE__ */ new Map();
|
|
1651
|
-
const allEndpoints = /* @__PURE__ */ new Set();
|
|
1652
|
-
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1653
|
-
const req = ctx.reqById.get(reqId);
|
|
1654
|
-
if (!req) continue;
|
|
1655
|
-
const endpoint = getEndpointKey(req.method, req.path);
|
|
1656
|
-
allEndpoints.add(endpoint);
|
|
1657
|
-
const seenInReq = /* @__PURE__ */ new Set();
|
|
1658
|
-
for (const q of reqQueries) {
|
|
1659
|
-
const shape = getQueryShape(q);
|
|
1660
|
-
let entry = queryMap.get(shape);
|
|
1661
|
-
if (!entry) {
|
|
1662
|
-
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1663
|
-
queryMap.set(shape, entry);
|
|
1664
|
-
}
|
|
1665
|
-
entry.count++;
|
|
1666
|
-
if (!seenInReq.has(shape)) {
|
|
1667
|
-
seenInReq.add(shape);
|
|
1668
|
-
entry.endpoints.add(endpoint);
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1673
|
-
for (const [, cem] of queryMap) {
|
|
1674
|
-
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1675
|
-
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1676
|
-
const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
|
|
1677
|
-
if (p < CROSS_ENDPOINT_PCT) continue;
|
|
1678
|
-
const info = getQueryInfo(cem.first);
|
|
1679
|
-
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1680
|
-
insights.push({
|
|
1681
|
-
severity: "warning",
|
|
1682
|
-
type: "cross-endpoint",
|
|
1683
|
-
title: "Repeated Query Across Endpoints",
|
|
1684
|
-
desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
|
|
1685
|
-
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1686
|
-
nav: "queries"
|
|
1667
|
+
detail: `${shapeGroup.count} queries with ${shapeGroup.distinctSql.size} distinct param variations. Example: ${[...shapeGroup.distinctSql][0]?.slice(0, 100) ?? info.op + " " + info.table}`
|
|
1687
1668
|
});
|
|
1688
1669
|
}
|
|
1689
1670
|
}
|
|
1690
1671
|
return insights;
|
|
1691
1672
|
}
|
|
1692
1673
|
};
|
|
1693
|
-
|
|
1694
|
-
// src/analysis/insights/rules/redundant-query.ts
|
|
1695
1674
|
var redundantQueryRule = {
|
|
1696
1675
|
id: "redundant-query",
|
|
1697
1676
|
check(ctx) {
|
|
1698
1677
|
const insights = [];
|
|
1699
|
-
const
|
|
1678
|
+
const reportedKeys = /* @__PURE__ */ new Set();
|
|
1700
1679
|
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1701
1680
|
const req = ctx.reqById.get(reqId);
|
|
1702
1681
|
if (!req) continue;
|
|
1703
1682
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1704
|
-
const
|
|
1705
|
-
for (const
|
|
1706
|
-
if (!
|
|
1707
|
-
let entry =
|
|
1683
|
+
const identicalQueryMap = /* @__PURE__ */ new Map();
|
|
1684
|
+
for (const query of reqQueries) {
|
|
1685
|
+
if (!query.sql) continue;
|
|
1686
|
+
let entry = identicalQueryMap.get(query.sql);
|
|
1708
1687
|
if (!entry) {
|
|
1709
|
-
entry = { count: 0, first:
|
|
1710
|
-
|
|
1688
|
+
entry = { count: 0, first: query };
|
|
1689
|
+
identicalQueryMap.set(query.sql, entry);
|
|
1711
1690
|
}
|
|
1712
1691
|
entry.count++;
|
|
1713
1692
|
}
|
|
1714
|
-
for (const [,
|
|
1715
|
-
if (
|
|
1716
|
-
const info = getQueryInfo(
|
|
1693
|
+
for (const [, entry] of identicalQueryMap) {
|
|
1694
|
+
if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1695
|
+
const info = getQueryInfo(entry.first);
|
|
1717
1696
|
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1718
|
-
const
|
|
1719
|
-
if (
|
|
1720
|
-
|
|
1697
|
+
const deduplicationKey = `${endpoint}:${label}`;
|
|
1698
|
+
if (reportedKeys.has(deduplicationKey)) continue;
|
|
1699
|
+
reportedKeys.add(deduplicationKey);
|
|
1721
1700
|
insights.push({
|
|
1722
1701
|
severity: "warning",
|
|
1723
1702
|
type: "redundant-query",
|
|
1724
1703
|
title: "Redundant Query",
|
|
1725
|
-
desc: `${label} runs ${
|
|
1704
|
+
desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
|
|
1726
1705
|
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.",
|
|
1727
|
-
|
|
1728
|
-
});
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
return insights;
|
|
1732
|
-
}
|
|
1733
|
-
};
|
|
1734
|
-
|
|
1735
|
-
// src/analysis/insights/rules/error.ts
|
|
1736
|
-
var errorRule = {
|
|
1737
|
-
id: "error",
|
|
1738
|
-
check(ctx) {
|
|
1739
|
-
if (ctx.errors.length === 0) return [];
|
|
1740
|
-
const insights = [];
|
|
1741
|
-
const groups = /* @__PURE__ */ new Map();
|
|
1742
|
-
for (const e of ctx.errors) {
|
|
1743
|
-
const name = e.name || "Error";
|
|
1744
|
-
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1745
|
-
}
|
|
1746
|
-
for (const [name, cnt] of groups) {
|
|
1747
|
-
insights.push({
|
|
1748
|
-
severity: "critical",
|
|
1749
|
-
type: "error",
|
|
1750
|
-
title: "Unhandled Error",
|
|
1751
|
-
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1752
|
-
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1753
|
-
nav: "errors"
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
return insights;
|
|
1757
|
-
}
|
|
1758
|
-
};
|
|
1759
|
-
|
|
1760
|
-
// src/analysis/insights/rules/error-hotspot.ts
|
|
1761
|
-
var errorHotspotRule = {
|
|
1762
|
-
id: "error-hotspot",
|
|
1763
|
-
check(ctx) {
|
|
1764
|
-
const insights = [];
|
|
1765
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1766
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1767
|
-
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1768
|
-
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1769
|
-
insights.push({
|
|
1770
|
-
severity: "critical",
|
|
1771
|
-
type: "error-hotspot",
|
|
1772
|
-
title: "Error Hotspot",
|
|
1773
|
-
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1774
|
-
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1775
|
-
nav: "requests"
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
return insights;
|
|
1780
|
-
}
|
|
1781
|
-
};
|
|
1782
|
-
|
|
1783
|
-
// src/analysis/insights/rules/duplicate.ts
|
|
1784
|
-
var duplicateRule = {
|
|
1785
|
-
id: "duplicate",
|
|
1786
|
-
check(ctx) {
|
|
1787
|
-
const dupCounts = /* @__PURE__ */ new Map();
|
|
1788
|
-
const flowCount = /* @__PURE__ */ new Map();
|
|
1789
|
-
for (const flow of ctx.flows) {
|
|
1790
|
-
if (!flow.requests) continue;
|
|
1791
|
-
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1792
|
-
for (const fr of flow.requests) {
|
|
1793
|
-
if (!fr.isDuplicate) continue;
|
|
1794
|
-
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1795
|
-
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1796
|
-
if (!seenInFlow.has(dupKey)) {
|
|
1797
|
-
seenInFlow.add(dupKey);
|
|
1798
|
-
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1803
|
-
const insights = [];
|
|
1804
|
-
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
1805
|
-
const d = dupEntries[i];
|
|
1806
|
-
insights.push({
|
|
1807
|
-
severity: "warning",
|
|
1808
|
-
type: "duplicate",
|
|
1809
|
-
title: "Duplicate API Call",
|
|
1810
|
-
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1811
|
-
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.",
|
|
1812
|
-
nav: "actions"
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
return insights;
|
|
1816
|
-
}
|
|
1817
|
-
};
|
|
1818
|
-
|
|
1819
|
-
// src/utils/format.ts
|
|
1820
|
-
function formatDuration(ms) {
|
|
1821
|
-
if (ms < 1e3) return `${ms}ms`;
|
|
1822
|
-
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1823
|
-
}
|
|
1824
|
-
function formatSize(bytes) {
|
|
1825
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
1826
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1827
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1828
|
-
}
|
|
1829
|
-
function pct(part, total) {
|
|
1830
|
-
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
// src/analysis/insights/rules/slow.ts
|
|
1834
|
-
var slowRule = {
|
|
1835
|
-
id: "slow",
|
|
1836
|
-
check(ctx) {
|
|
1837
|
-
const insights = [];
|
|
1838
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1839
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1840
|
-
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1841
|
-
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1842
|
-
const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
|
|
1843
|
-
const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
|
|
1844
|
-
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1845
|
-
const parts = [];
|
|
1846
|
-
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1847
|
-
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1848
|
-
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1849
|
-
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1850
|
-
let detail;
|
|
1851
|
-
let slowestMs = 0;
|
|
1852
|
-
for (const [, sd] of g.queryShapeDurations) {
|
|
1853
|
-
const avgShapeMs = sd.totalMs / sd.count;
|
|
1854
|
-
if (avgShapeMs > slowestMs) {
|
|
1855
|
-
slowestMs = avgShapeMs;
|
|
1856
|
-
detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
insights.push({
|
|
1860
|
-
severity: "warning",
|
|
1861
|
-
type: "slow",
|
|
1862
|
-
title: "Slow Endpoint",
|
|
1863
|
-
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1864
|
-
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.",
|
|
1865
|
-
detail,
|
|
1866
|
-
nav: "requests"
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
return insights;
|
|
1870
|
-
}
|
|
1871
|
-
};
|
|
1872
|
-
|
|
1873
|
-
// src/analysis/insights/rules/query-heavy.ts
|
|
1874
|
-
var queryHeavyRule = {
|
|
1875
|
-
id: "query-heavy",
|
|
1876
|
-
check(ctx) {
|
|
1877
|
-
const insights = [];
|
|
1878
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1879
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1880
|
-
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1881
|
-
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1882
|
-
insights.push({
|
|
1883
|
-
severity: "warning",
|
|
1884
|
-
type: "query-heavy",
|
|
1885
|
-
title: "Query-Heavy Endpoint",
|
|
1886
|
-
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1887
|
-
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1888
|
-
nav: "queries"
|
|
1706
|
+
detail: entry.first.sql ? `Query: ${entry.first.sql.slice(0, 120)}` : void 0
|
|
1889
1707
|
});
|
|
1890
1708
|
}
|
|
1891
1709
|
}
|
|
1892
1710
|
return insights;
|
|
1893
1711
|
}
|
|
1894
1712
|
};
|
|
1895
|
-
|
|
1896
|
-
// src/analysis/insights/rules/select-star.ts
|
|
1897
1713
|
var selectStarRule = {
|
|
1898
1714
|
id: "select-star",
|
|
1899
1715
|
check(ctx) {
|
|
1900
|
-
const
|
|
1716
|
+
const tableCounts = /* @__PURE__ */ new Map();
|
|
1901
1717
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1902
|
-
for (const
|
|
1903
|
-
if (!
|
|
1904
|
-
const isSelectStar = SELECT_STAR_RE.test(
|
|
1718
|
+
for (const query of reqQueries) {
|
|
1719
|
+
if (!query.sql) continue;
|
|
1720
|
+
const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
|
|
1905
1721
|
if (!isSelectStar) continue;
|
|
1906
|
-
const info = getQueryInfo(
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1722
|
+
const info = getQueryInfo(query);
|
|
1723
|
+
const table = info.table || "unknown";
|
|
1724
|
+
tableCounts.set(table, (tableCounts.get(table) ?? 0) + 1);
|
|
1909
1725
|
}
|
|
1910
1726
|
}
|
|
1911
1727
|
const insights = [];
|
|
1912
|
-
for (const [table, count] of
|
|
1728
|
+
for (const [table, count] of tableCounts) {
|
|
1913
1729
|
if (count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1914
1730
|
insights.push({
|
|
1915
1731
|
severity: "warning",
|
|
1916
1732
|
type: "select-star",
|
|
1917
1733
|
title: "SELECT * Query",
|
|
1918
1734
|
desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1919
|
-
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage."
|
|
1920
|
-
nav: "queries"
|
|
1735
|
+
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage."
|
|
1921
1736
|
});
|
|
1922
1737
|
}
|
|
1923
1738
|
return insights;
|
|
1924
1739
|
}
|
|
1925
1740
|
};
|
|
1926
|
-
|
|
1927
|
-
// src/analysis/insights/rules/high-rows.ts
|
|
1928
1741
|
var highRowsRule = {
|
|
1929
1742
|
id: "high-rows",
|
|
1930
1743
|
check(ctx) {
|
|
1931
1744
|
const seen = /* @__PURE__ */ new Map();
|
|
1932
1745
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1933
|
-
for (const
|
|
1934
|
-
if (!
|
|
1935
|
-
const info = getQueryInfo(
|
|
1746
|
+
for (const query of reqQueries) {
|
|
1747
|
+
if (!query.rowCount || query.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1748
|
+
const info = getQueryInfo(query);
|
|
1936
1749
|
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1937
1750
|
let entry = seen.get(key);
|
|
1938
1751
|
if (!entry) {
|
|
@@ -1940,7 +1753,7 @@ var highRowsRule = {
|
|
|
1940
1753
|
seen.set(key, entry);
|
|
1941
1754
|
}
|
|
1942
1755
|
entry.count++;
|
|
1943
|
-
if (
|
|
1756
|
+
if (query.rowCount > entry.max) entry.max = query.rowCount;
|
|
1944
1757
|
}
|
|
1945
1758
|
}
|
|
1946
1759
|
const insights = [];
|
|
@@ -1951,28 +1764,62 @@ var highRowsRule = {
|
|
|
1951
1764
|
type: "high-rows",
|
|
1952
1765
|
title: "Large Result Set",
|
|
1953
1766
|
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
|
|
1954
|
-
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition."
|
|
1955
|
-
nav: "queries"
|
|
1767
|
+
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition."
|
|
1956
1768
|
});
|
|
1957
1769
|
}
|
|
1958
1770
|
return insights;
|
|
1959
1771
|
}
|
|
1960
1772
|
};
|
|
1773
|
+
var queryHeavyRule = {
|
|
1774
|
+
id: "query-heavy",
|
|
1775
|
+
check(ctx) {
|
|
1776
|
+
const insights = [];
|
|
1777
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1778
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1779
|
+
const avgQueries = Math.round(group.queryCount / group.total);
|
|
1780
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1781
|
+
insights.push({
|
|
1782
|
+
severity: "warning",
|
|
1783
|
+
type: "query-heavy",
|
|
1784
|
+
title: "Query-Heavy Endpoint",
|
|
1785
|
+
desc: `${endpointKey} \u2014 avg ${avgQueries} queries/request`,
|
|
1786
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches."
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return insights;
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1961
1793
|
|
|
1962
|
-
// src/
|
|
1794
|
+
// src/utils/format.ts
|
|
1795
|
+
function formatDuration(ms) {
|
|
1796
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1797
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1798
|
+
}
|
|
1799
|
+
function formatSize(bytes) {
|
|
1800
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1801
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1802
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1803
|
+
}
|
|
1804
|
+
function pct(part, total) {
|
|
1805
|
+
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// src/analysis/insights/rules/response-rules.ts
|
|
1963
1809
|
var responseOverfetchRule = {
|
|
1964
1810
|
id: "response-overfetch",
|
|
1965
1811
|
check(ctx) {
|
|
1966
1812
|
const insights = [];
|
|
1967
1813
|
const seen = /* @__PURE__ */ new Set();
|
|
1968
|
-
for (const
|
|
1969
|
-
if (isErrorStatus(
|
|
1970
|
-
const
|
|
1971
|
-
if (seen.has(
|
|
1814
|
+
for (const request of ctx.nonStatic) {
|
|
1815
|
+
if (isErrorStatus(request.statusCode) || !request.responseBody) continue;
|
|
1816
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1817
|
+
if (seen.has(endpointKey)) continue;
|
|
1972
1818
|
let parsed;
|
|
1973
1819
|
try {
|
|
1974
|
-
parsed = JSON.parse(
|
|
1975
|
-
} catch {
|
|
1820
|
+
parsed = JSON.parse(request.responseBody);
|
|
1821
|
+
} catch (e) {
|
|
1822
|
+
brakitDebug(`json parse: ${getErrorMessage(e)}`);
|
|
1976
1823
|
continue;
|
|
1977
1824
|
}
|
|
1978
1825
|
const target = unwrapResponse(parsed);
|
|
@@ -1995,37 +1842,33 @@ var responseOverfetchRule = {
|
|
|
1995
1842
|
reasons.push(`${fields.length} fields returned`);
|
|
1996
1843
|
}
|
|
1997
1844
|
if (reasons.length > 0) {
|
|
1998
|
-
seen.add(
|
|
1845
|
+
seen.add(endpointKey);
|
|
1999
1846
|
insights.push({
|
|
2000
1847
|
severity: "info",
|
|
2001
1848
|
type: "response-overfetch",
|
|
2002
1849
|
title: "Response Overfetch",
|
|
2003
|
-
desc: `${
|
|
2004
|
-
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."
|
|
2005
|
-
nav: "requests"
|
|
1850
|
+
desc: `${endpointKey} \u2014 ${reasons.join(", ")}`,
|
|
1851
|
+
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."
|
|
2006
1852
|
});
|
|
2007
1853
|
}
|
|
2008
1854
|
}
|
|
2009
1855
|
return insights;
|
|
2010
1856
|
}
|
|
2011
1857
|
};
|
|
2012
|
-
|
|
2013
|
-
// src/analysis/insights/rules/large-response.ts
|
|
2014
1858
|
var largeResponseRule = {
|
|
2015
1859
|
id: "large-response",
|
|
2016
1860
|
check(ctx) {
|
|
2017
1861
|
const insights = [];
|
|
2018
|
-
for (const [
|
|
2019
|
-
if (
|
|
2020
|
-
const avgSize = Math.round(
|
|
1862
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1863
|
+
if (group.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1864
|
+
const avgSize = Math.round(group.totalSize / group.total);
|
|
2021
1865
|
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
2022
1866
|
insights.push({
|
|
2023
1867
|
severity: "info",
|
|
2024
1868
|
type: "large-response",
|
|
2025
1869
|
title: "Large Response",
|
|
2026
|
-
desc: `${
|
|
2027
|
-
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression."
|
|
2028
|
-
nav: "requests"
|
|
1870
|
+
desc: `${endpointKey} \u2014 avg ${formatSize(avgSize)} response`,
|
|
1871
|
+
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression."
|
|
2029
1872
|
});
|
|
2030
1873
|
}
|
|
2031
1874
|
}
|
|
@@ -2033,7 +1876,50 @@ var largeResponseRule = {
|
|
|
2033
1876
|
}
|
|
2034
1877
|
};
|
|
2035
1878
|
|
|
2036
|
-
// src/analysis/insights/rules/
|
|
1879
|
+
// src/analysis/insights/rules/reliability-rules.ts
|
|
1880
|
+
var errorRule = {
|
|
1881
|
+
id: "error",
|
|
1882
|
+
check(ctx) {
|
|
1883
|
+
if (ctx.errors.length === 0) return [];
|
|
1884
|
+
const insights = [];
|
|
1885
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1886
|
+
for (const error of ctx.errors) {
|
|
1887
|
+
const name = error.name || "Error";
|
|
1888
|
+
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1889
|
+
}
|
|
1890
|
+
for (const [name, cnt] of groups) {
|
|
1891
|
+
insights.push({
|
|
1892
|
+
severity: "critical",
|
|
1893
|
+
type: "error",
|
|
1894
|
+
title: "Unhandled Error",
|
|
1895
|
+
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1896
|
+
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1897
|
+
detail: ctx.errors.find((e) => e.name === name)?.message
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
return insights;
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
var errorHotspotRule = {
|
|
1904
|
+
id: "error-hotspot",
|
|
1905
|
+
check(ctx) {
|
|
1906
|
+
const insights = [];
|
|
1907
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1908
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1909
|
+
const errorRate = Math.round(group.errors / group.total * 100);
|
|
1910
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1911
|
+
insights.push({
|
|
1912
|
+
severity: "critical",
|
|
1913
|
+
type: "error-hotspot",
|
|
1914
|
+
title: "Error Hotspot",
|
|
1915
|
+
desc: `${endpointKey} \u2014 ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
|
|
1916
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces."
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return insights;
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
2037
1923
|
var regressionRule = {
|
|
2038
1924
|
id: "regression",
|
|
2039
1925
|
check(ctx) {
|
|
@@ -2052,8 +1938,7 @@ var regressionRule = {
|
|
|
2052
1938
|
type: "regression",
|
|
2053
1939
|
title: "Performance Regression",
|
|
2054
1940
|
desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
|
|
2055
|
-
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing."
|
|
2056
|
-
nav: "graph"
|
|
1941
|
+
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing."
|
|
2057
1942
|
});
|
|
2058
1943
|
}
|
|
2059
1944
|
if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
|
|
@@ -2062,8 +1947,137 @@ var regressionRule = {
|
|
|
2062
1947
|
type: "regression",
|
|
2063
1948
|
title: "Query Count Regression",
|
|
2064
1949
|
desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
|
|
2065
|
-
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations."
|
|
2066
|
-
|
|
1950
|
+
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations."
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return insights;
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
function getAdaptiveSlowThreshold(endpointKey, previousMetrics) {
|
|
1958
|
+
if (!previousMetrics) return null;
|
|
1959
|
+
const ep = previousMetrics.find((m) => m.endpoint === endpointKey);
|
|
1960
|
+
if (!ep || ep.sessions.length < BASELINE_MIN_SESSIONS) return null;
|
|
1961
|
+
const valid = ep.sessions.filter((s) => s.requestCount >= BASELINE_MIN_REQUESTS_PER_SESSION);
|
|
1962
|
+
if (valid.length < BASELINE_MIN_SESSIONS) return null;
|
|
1963
|
+
const p95s = valid.map((s) => s.p95DurationMs).sort((a, b) => a - b);
|
|
1964
|
+
const medianP95 = p95s[Math.floor(p95s.length / 2)];
|
|
1965
|
+
return medianP95 * 2;
|
|
1966
|
+
}
|
|
1967
|
+
var slowRule = {
|
|
1968
|
+
id: "slow",
|
|
1969
|
+
check(ctx) {
|
|
1970
|
+
const insights = [];
|
|
1971
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1972
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1973
|
+
const avgMs = Math.round(group.totalDuration / group.total);
|
|
1974
|
+
const threshold = getAdaptiveSlowThreshold(endpointKey, ctx.previousMetrics);
|
|
1975
|
+
if (threshold === null || avgMs < threshold) continue;
|
|
1976
|
+
const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
|
|
1977
|
+
const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
|
|
1978
|
+
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1979
|
+
const parts = [];
|
|
1980
|
+
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1981
|
+
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1982
|
+
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1983
|
+
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1984
|
+
let detail;
|
|
1985
|
+
let slowestMs = 0;
|
|
1986
|
+
for (const [, shapeDuration] of group.queryShapeDurations) {
|
|
1987
|
+
const avgShapeMs = shapeDuration.totalMs / shapeDuration.count;
|
|
1988
|
+
if (avgShapeMs > slowestMs) {
|
|
1989
|
+
slowestMs = avgShapeMs;
|
|
1990
|
+
detail = `Slowest query: ${shapeDuration.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${shapeDuration.count}x)`;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
insights.push({
|
|
1994
|
+
severity: "warning",
|
|
1995
|
+
type: "slow",
|
|
1996
|
+
title: "Slow Endpoint",
|
|
1997
|
+
desc: `${endpointKey} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1998
|
+
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.",
|
|
1999
|
+
detail
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
return insights;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
// src/analysis/insights/rules/pattern-rules.ts
|
|
2007
|
+
var duplicateRule = {
|
|
2008
|
+
id: "duplicate",
|
|
2009
|
+
check(ctx) {
|
|
2010
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
2011
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
2012
|
+
for (const flow of ctx.flows) {
|
|
2013
|
+
if (!flow.requests) continue;
|
|
2014
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
2015
|
+
for (const request of flow.requests) {
|
|
2016
|
+
if (!request.isDuplicate) continue;
|
|
2017
|
+
const deduplicationKey = `${request.method} ${request.label ?? request.path ?? request.url}`;
|
|
2018
|
+
dupCounts.set(deduplicationKey, (dupCounts.get(deduplicationKey) ?? 0) + 1);
|
|
2019
|
+
if (!seenInFlow.has(deduplicationKey)) {
|
|
2020
|
+
seenInFlow.add(deduplicationKey);
|
|
2021
|
+
flowCount.set(deduplicationKey, (flowCount.get(deduplicationKey) ?? 0) + 1);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
2026
|
+
const insights = [];
|
|
2027
|
+
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
2028
|
+
const duplicate = dupEntries[i];
|
|
2029
|
+
insights.push({
|
|
2030
|
+
severity: "warning",
|
|
2031
|
+
type: "duplicate",
|
|
2032
|
+
title: "Duplicate API Call",
|
|
2033
|
+
desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
|
|
2034
|
+
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."
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
return insights;
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
var crossEndpointRule = {
|
|
2041
|
+
id: "cross-endpoint",
|
|
2042
|
+
check(ctx) {
|
|
2043
|
+
const insights = [];
|
|
2044
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
2045
|
+
const allEndpoints = /* @__PURE__ */ new Set();
|
|
2046
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
2047
|
+
const req = ctx.reqById.get(reqId);
|
|
2048
|
+
if (!req) continue;
|
|
2049
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
2050
|
+
allEndpoints.add(endpoint);
|
|
2051
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
2052
|
+
for (const query of reqQueries) {
|
|
2053
|
+
const shape = getQueryShape(query);
|
|
2054
|
+
let entry = queryMap.get(shape);
|
|
2055
|
+
if (!entry) {
|
|
2056
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: query };
|
|
2057
|
+
queryMap.set(shape, entry);
|
|
2058
|
+
}
|
|
2059
|
+
entry.count++;
|
|
2060
|
+
if (!seenInReq.has(shape)) {
|
|
2061
|
+
seenInReq.add(shape);
|
|
2062
|
+
entry.endpoints.add(endpoint);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
2067
|
+
for (const [, queryMetric] of queryMap) {
|
|
2068
|
+
if (queryMetric.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
2069
|
+
if (queryMetric.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
2070
|
+
const coveragePct = Math.round(queryMetric.endpoints.size / allEndpoints.size * 100);
|
|
2071
|
+
if (coveragePct < CROSS_ENDPOINT_PCT) continue;
|
|
2072
|
+
const info = getQueryInfo(queryMetric.first);
|
|
2073
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
2074
|
+
insights.push({
|
|
2075
|
+
severity: "warning",
|
|
2076
|
+
type: "cross-endpoint",
|
|
2077
|
+
title: "Repeated Query Across Endpoints",
|
|
2078
|
+
desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
|
|
2079
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
2080
|
+
detail: `Endpoints: ${[...queryMetric.endpoints].slice(0, 5).join(", ")}${queryMetric.endpoints.size > 5 ? ` +${queryMetric.endpoints.size - 5} more` : ""}. Total: ${queryMetric.count} executions.`
|
|
2067
2081
|
});
|
|
2068
2082
|
}
|
|
2069
2083
|
}
|
|
@@ -2076,13 +2090,13 @@ var securityRule = {
|
|
|
2076
2090
|
id: "security",
|
|
2077
2091
|
check(ctx) {
|
|
2078
2092
|
if (!ctx.securityFindings) return [];
|
|
2079
|
-
return ctx.securityFindings.map((
|
|
2080
|
-
severity:
|
|
2093
|
+
return ctx.securityFindings.map((finding) => ({
|
|
2094
|
+
severity: finding.severity,
|
|
2081
2095
|
type: "security",
|
|
2082
|
-
title:
|
|
2083
|
-
desc:
|
|
2084
|
-
hint:
|
|
2085
|
-
|
|
2096
|
+
title: finding.title,
|
|
2097
|
+
desc: finding.desc,
|
|
2098
|
+
hint: finding.hint,
|
|
2099
|
+
detail: finding.detail
|
|
2086
2100
|
}));
|
|
2087
2101
|
}
|
|
2088
2102
|
};
|
|
@@ -2125,8 +2139,7 @@ function insightToIssue(insight) {
|
|
|
2125
2139
|
desc: insight.desc,
|
|
2126
2140
|
hint: insight.hint,
|
|
2127
2141
|
detail: insight.detail,
|
|
2128
|
-
endpoint: extractEndpointFromDesc(insight.desc) ?? void 0
|
|
2129
|
-
nav: insight.nav
|
|
2142
|
+
endpoint: extractEndpointFromDesc(insight.desc) ?? void 0
|
|
2130
2143
|
};
|
|
2131
2144
|
}
|
|
2132
2145
|
function securityFindingToIssue(finding) {
|
|
@@ -2137,15 +2150,15 @@ function securityFindingToIssue(finding) {
|
|
|
2137
2150
|
title: finding.title,
|
|
2138
2151
|
desc: finding.desc,
|
|
2139
2152
|
hint: finding.hint,
|
|
2140
|
-
|
|
2141
|
-
|
|
2153
|
+
detail: finding.detail,
|
|
2154
|
+
endpoint: finding.endpoint
|
|
2142
2155
|
};
|
|
2143
2156
|
}
|
|
2144
2157
|
|
|
2145
2158
|
// src/analysis/engine.ts
|
|
2146
2159
|
var AnalysisEngine = class {
|
|
2147
|
-
constructor(
|
|
2148
|
-
this.
|
|
2160
|
+
constructor(services, debounceMs = ANALYSIS_DEBOUNCE_MS) {
|
|
2161
|
+
this.services = services;
|
|
2149
2162
|
this.debounceMs = debounceMs;
|
|
2150
2163
|
this.cachedInsights = [];
|
|
2151
2164
|
this.cachedFindings = [];
|
|
@@ -2154,7 +2167,7 @@ var AnalysisEngine = class {
|
|
|
2154
2167
|
this.scanner = createDefaultScanner();
|
|
2155
2168
|
}
|
|
2156
2169
|
start() {
|
|
2157
|
-
const bus = this.
|
|
2170
|
+
const bus = this.services.bus;
|
|
2158
2171
|
this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
|
|
2159
2172
|
this.subs.add(bus.on("telemetry:query", () => this.scheduleRecompute()));
|
|
2160
2173
|
this.subs.add(bus.on("telemetry:error", () => this.scheduleRecompute()));
|
|
@@ -2181,12 +2194,12 @@ var AnalysisEngine = class {
|
|
|
2181
2194
|
}, this.debounceMs);
|
|
2182
2195
|
}
|
|
2183
2196
|
recompute() {
|
|
2184
|
-
const allRequests = this.
|
|
2185
|
-
const queries = this.
|
|
2186
|
-
const errors = this.
|
|
2187
|
-
const logs = this.
|
|
2188
|
-
const fetches = this.
|
|
2189
|
-
const requests =
|
|
2197
|
+
const allRequests = this.services.requestStore.getAll();
|
|
2198
|
+
const queries = this.services.queryStore.getAll();
|
|
2199
|
+
const errors = this.services.errorStore.getAll();
|
|
2200
|
+
const logs = this.services.logStore.getAll();
|
|
2201
|
+
const fetches = this.services.fetchStore.getAll();
|
|
2202
|
+
const requests = keepRecentPerEndpoint(allRequests);
|
|
2190
2203
|
const flows = groupRequestsIntoFlows(requests);
|
|
2191
2204
|
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
2192
2205
|
this.cachedInsights = computeInsights({
|
|
@@ -2195,38 +2208,34 @@ var AnalysisEngine = class {
|
|
|
2195
2208
|
errors,
|
|
2196
2209
|
flows,
|
|
2197
2210
|
fetches,
|
|
2198
|
-
previousMetrics: this.
|
|
2211
|
+
previousMetrics: this.services.metricsStore.getAll(),
|
|
2199
2212
|
securityFindings: this.cachedFindings
|
|
2200
2213
|
});
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
const
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
issues: issueStore.getAll()
|
|
2222
|
-
};
|
|
2223
|
-
this.registry.get("event-bus").emit("analysis:updated", update);
|
|
2224
|
-
}
|
|
2214
|
+
const issueStore = this.services.issueStore;
|
|
2215
|
+
const currentIssueIds = /* @__PURE__ */ new Set();
|
|
2216
|
+
for (const finding of this.cachedFindings) {
|
|
2217
|
+
const issue = securityFindingToIssue(finding);
|
|
2218
|
+
issueStore.upsert(issue, "passive");
|
|
2219
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2220
|
+
}
|
|
2221
|
+
for (const insight of this.cachedInsights) {
|
|
2222
|
+
const issue = insightToIssue(insight);
|
|
2223
|
+
issueStore.upsert(issue, "passive");
|
|
2224
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2225
|
+
}
|
|
2226
|
+
const activeEndpoints = extractActiveEndpoints(allRequests);
|
|
2227
|
+
issueStore.reconcile(currentIssueIds, activeEndpoints);
|
|
2228
|
+
const update = {
|
|
2229
|
+
insights: this.cachedInsights,
|
|
2230
|
+
findings: this.cachedFindings,
|
|
2231
|
+
issues: issueStore.getAll()
|
|
2232
|
+
};
|
|
2233
|
+
this.services.bus.emit("analysis:updated", update);
|
|
2225
2234
|
}
|
|
2226
2235
|
};
|
|
2227
2236
|
|
|
2228
2237
|
// src/index.ts
|
|
2229
|
-
var VERSION = "0.
|
|
2238
|
+
var VERSION = "0.9.0";
|
|
2230
2239
|
export {
|
|
2231
2240
|
AdapterRegistry,
|
|
2232
2241
|
AnalysisEngine,
|