brakit 0.8.6 → 9.0.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 +22 -4
- package/dist/api.d.ts +70 -48
- package/dist/api.js +773 -753
- package/dist/bin/brakit.js +316 -419
- package/dist/dashboard-client.global.js +826 -0
- package/dist/dashboard.html +1064 -2179
- package/dist/mcp/server.js +7 -15
- package/dist/runtime/index.js +2561 -5003
- package/package.json +4 -2
package/dist/api.js
CHANGED
|
@@ -10,17 +10,49 @@ 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;
|
|
17
17
|
var SECRET_SCAN_ARRAY_LIMIT = 5;
|
|
18
18
|
var PII_SCAN_ARRAY_LIMIT = 10;
|
|
19
19
|
var MIN_SECRET_VALUE_LENGTH = 8;
|
|
20
|
-
var FULL_RECORD_MIN_FIELDS =
|
|
20
|
+
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 SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
31
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
32
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
33
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
34
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
35
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
36
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
37
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
38
|
+
var HIGH_ROW_COUNT = 100;
|
|
39
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
40
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
41
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
42
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
43
|
+
var REGRESSION_PCT_THRESHOLD = 50;
|
|
44
|
+
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
45
|
+
var REGRESSION_MIN_REQUESTS = 5;
|
|
46
|
+
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
47
|
+
var OVERFETCH_MANY_FIELDS = 12;
|
|
48
|
+
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
49
|
+
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
50
|
+
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
51
|
+
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
52
|
+
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
53
|
+
var STRICT_MODE_MAX_GAP_MS = 2e3;
|
|
54
|
+
var ISSUES_FILE = "issues.json";
|
|
55
|
+
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
24
56
|
|
|
25
57
|
// src/utils/log.ts
|
|
26
58
|
var PREFIX = "[brakit]";
|
|
@@ -42,7 +74,9 @@ function getErrorMessage(err) {
|
|
|
42
74
|
return String(err);
|
|
43
75
|
}
|
|
44
76
|
function validateIssuesData(parsed) {
|
|
45
|
-
if (parsed
|
|
77
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
78
|
+
const obj = parsed;
|
|
79
|
+
if (obj.version === ISSUES_DATA_VERSION && Array.isArray(obj.issues)) {
|
|
46
80
|
return parsed;
|
|
47
81
|
}
|
|
48
82
|
return null;
|
|
@@ -86,41 +120,6 @@ async function ensureGitignoreAsync(dir, entry) {
|
|
|
86
120
|
}
|
|
87
121
|
}
|
|
88
122
|
|
|
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
123
|
// src/utils/atomic-writer.ts
|
|
125
124
|
import {
|
|
126
125
|
writeFileSync as writeFileSync2,
|
|
@@ -132,11 +131,10 @@ import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
|
|
|
132
131
|
var AtomicWriter = class {
|
|
133
132
|
constructor(opts) {
|
|
134
133
|
this.opts = opts;
|
|
134
|
+
this.writing = false;
|
|
135
|
+
this.pendingContent = null;
|
|
135
136
|
this.tmpPath = opts.filePath + ".tmp";
|
|
136
137
|
}
|
|
137
|
-
tmpPath;
|
|
138
|
-
writing = false;
|
|
139
|
-
pendingContent = null;
|
|
140
138
|
writeSync(content) {
|
|
141
139
|
try {
|
|
142
140
|
this.ensureDir();
|
|
@@ -198,6 +196,9 @@ function computeIssueId(issue) {
|
|
|
198
196
|
var IssueStore = class {
|
|
199
197
|
constructor(dataDir) {
|
|
200
198
|
this.dataDir = dataDir;
|
|
199
|
+
this.issues = /* @__PURE__ */ new Map();
|
|
200
|
+
this.flushTimer = null;
|
|
201
|
+
this.dirty = false;
|
|
201
202
|
this.issuesPath = resolve2(dataDir, ISSUES_FILE);
|
|
202
203
|
this.writer = new AtomicWriter({
|
|
203
204
|
dir: dataDir,
|
|
@@ -205,11 +206,6 @@ var IssueStore = class {
|
|
|
205
206
|
label: "issues"
|
|
206
207
|
});
|
|
207
208
|
}
|
|
208
|
-
issues = /* @__PURE__ */ new Map();
|
|
209
|
-
flushTimer = null;
|
|
210
|
-
dirty = false;
|
|
211
|
-
writer;
|
|
212
|
-
issuesPath;
|
|
213
209
|
start() {
|
|
214
210
|
this.loadAsync().catch((err) => brakitDebug(`IssueStore: async load failed: ${err}`));
|
|
215
211
|
this.flushTimer = setInterval(
|
|
@@ -429,8 +425,10 @@ function detectFrameworkFromDeps(allDeps) {
|
|
|
429
425
|
|
|
430
426
|
// src/instrument/adapter-registry.ts
|
|
431
427
|
var AdapterRegistry = class {
|
|
432
|
-
|
|
433
|
-
|
|
428
|
+
constructor() {
|
|
429
|
+
this.adapters = [];
|
|
430
|
+
this.active = [];
|
|
431
|
+
}
|
|
434
432
|
register(adapter) {
|
|
435
433
|
this.adapters.push(adapter);
|
|
436
434
|
}
|
|
@@ -468,11 +466,12 @@ function tryParseJson(body) {
|
|
|
468
466
|
return null;
|
|
469
467
|
}
|
|
470
468
|
}
|
|
469
|
+
var MAX_WRAPPER_KEYS = 3;
|
|
471
470
|
function unwrapResponse(parsed) {
|
|
472
471
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
473
472
|
const obj = parsed;
|
|
474
473
|
const keys = Object.keys(obj);
|
|
475
|
-
if (keys.length >
|
|
474
|
+
if (keys.length > MAX_WRAPPER_KEYS) return parsed;
|
|
476
475
|
let best = null;
|
|
477
476
|
let bestSize = 0;
|
|
478
477
|
for (const key of keys) {
|
|
@@ -504,6 +503,8 @@ var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
|
504
503
|
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
505
504
|
var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
|
|
506
505
|
var INTERNAL_ID_SUFFIX = /Id$|_id$/;
|
|
506
|
+
var SELF_SERVICE_PATH = /\/(?:me|account|profile|settings|self)(?=\/|\?|#|$)/i;
|
|
507
|
+
var SENSITIVE_FIELD_NAMES = /^(phone|phoneNumber|phone_number|ssn|socialSecurityNumber|social_security_number|dateOfBirth|date_of_birth|dob|address|streetAddress|street_address|creditCard|credit_card|cardNumber|card_number|bankAccount|bank_account|passport|passportNumber|passport_number|nationalId|national_id)$/i;
|
|
507
508
|
var SELECT_STAR_RE = /^SELECT\s+\*/i;
|
|
508
509
|
var SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
|
|
509
510
|
var RULE_HINTS = {
|
|
@@ -528,27 +529,87 @@ function isRedirect(code) {
|
|
|
528
529
|
return code >= 300 && code < 400;
|
|
529
530
|
}
|
|
530
531
|
|
|
531
|
-
// src/
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
532
|
+
// src/utils/collections.ts
|
|
533
|
+
function getOrCreate(map, key, create) {
|
|
534
|
+
let value = map.get(key);
|
|
535
|
+
if (value === void 0) {
|
|
536
|
+
value = create();
|
|
537
|
+
map.set(key, value);
|
|
538
|
+
}
|
|
539
|
+
return value;
|
|
540
|
+
}
|
|
541
|
+
function deduplicateFindings(items, extract) {
|
|
542
|
+
const seen = /* @__PURE__ */ new Map();
|
|
543
|
+
const findings = [];
|
|
544
|
+
for (const item of items) {
|
|
545
|
+
const result = extract(item);
|
|
546
|
+
if (!result) continue;
|
|
547
|
+
const existing = seen.get(result.key);
|
|
548
|
+
if (existing) {
|
|
549
|
+
existing.count++;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
seen.set(result.key, result.finding);
|
|
553
|
+
findings.push(result.finding);
|
|
554
|
+
}
|
|
555
|
+
return findings;
|
|
556
|
+
}
|
|
557
|
+
function groupBy(items, keyFn) {
|
|
558
|
+
const map = /* @__PURE__ */ new Map();
|
|
559
|
+
for (const item of items) {
|
|
560
|
+
const key = keyFn(item);
|
|
561
|
+
if (key == null) continue;
|
|
562
|
+
let arr = map.get(key);
|
|
563
|
+
if (!arr) {
|
|
564
|
+
arr = [];
|
|
565
|
+
map.set(key, arr);
|
|
539
566
|
}
|
|
540
|
-
|
|
567
|
+
arr.push(item);
|
|
541
568
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
569
|
+
return map;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/utils/object-scan.ts
|
|
573
|
+
var DEFAULTS = {
|
|
574
|
+
maxDepth: MAX_OBJECT_SCAN_DEPTH,
|
|
575
|
+
arrayLimit: SECRET_SCAN_ARRAY_LIMIT
|
|
576
|
+
};
|
|
577
|
+
function walkObject(obj, visitor, options) {
|
|
578
|
+
const opts = { ...DEFAULTS, ...options };
|
|
579
|
+
walk(obj, visitor, opts, 0);
|
|
580
|
+
}
|
|
581
|
+
function walk(obj, visitor, opts, depth) {
|
|
582
|
+
if (depth >= opts.maxDepth) return;
|
|
583
|
+
if (!obj || typeof obj !== "object") return;
|
|
584
|
+
if (Array.isArray(obj)) {
|
|
585
|
+
for (let i = 0; i < Math.min(obj.length, opts.arrayLimit); i++) {
|
|
586
|
+
walk(obj[i], visitor, opts, depth + 1);
|
|
546
587
|
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
for (const key of Object.keys(obj)) {
|
|
591
|
+
const val = obj[key];
|
|
592
|
+
visitor(key, val, depth);
|
|
547
593
|
if (typeof val === "object" && val !== null) {
|
|
548
|
-
|
|
594
|
+
walk(val, visitor, opts, depth + 1);
|
|
549
595
|
}
|
|
550
596
|
}
|
|
551
|
-
|
|
597
|
+
}
|
|
598
|
+
function collectFromObject(obj, match, options) {
|
|
599
|
+
const results = [];
|
|
600
|
+
walkObject(obj, (key, value) => {
|
|
601
|
+
const result = match(key, value);
|
|
602
|
+
if (result !== null) results.push(result);
|
|
603
|
+
}, options);
|
|
604
|
+
return results;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/analysis/rules/auth-rules.ts
|
|
608
|
+
function findSecretKeys(obj) {
|
|
609
|
+
return collectFromObject(
|
|
610
|
+
obj,
|
|
611
|
+
(key, val) => SECRET_KEYS.test(key) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val) ? key : null
|
|
612
|
+
);
|
|
552
613
|
}
|
|
553
614
|
var exposedSecretRule = {
|
|
554
615
|
id: "exposed-secret",
|
|
@@ -556,50 +617,38 @@ var exposedSecretRule = {
|
|
|
556
617
|
name: "Exposed Secret in Response",
|
|
557
618
|
hint: RULE_HINTS["exposed-secret"],
|
|
558
619
|
check(ctx) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (
|
|
563
|
-
const
|
|
564
|
-
if (
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
title: "Exposed Secret in Response",
|
|
578
|
-
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
579
|
-
hint: this.hint,
|
|
580
|
-
endpoint: ep,
|
|
581
|
-
count: 1
|
|
620
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
621
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
622
|
+
const parsed = ctx.parsedBodies.response.get(request.id);
|
|
623
|
+
if (!parsed) return null;
|
|
624
|
+
const keys = findSecretKeys(parsed);
|
|
625
|
+
if (keys.length === 0) return null;
|
|
626
|
+
const ep = `${request.method} ${request.path}`;
|
|
627
|
+
return {
|
|
628
|
+
key: `${ep}:${keys.sort().join(",")}`,
|
|
629
|
+
finding: {
|
|
630
|
+
severity: "critical",
|
|
631
|
+
rule: "exposed-secret",
|
|
632
|
+
title: "Exposed Secret in Response",
|
|
633
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
634
|
+
hint: this.hint,
|
|
635
|
+
endpoint: ep,
|
|
636
|
+
count: 1
|
|
637
|
+
}
|
|
582
638
|
};
|
|
583
|
-
|
|
584
|
-
findings.push(finding);
|
|
585
|
-
}
|
|
586
|
-
return findings;
|
|
639
|
+
});
|
|
587
640
|
}
|
|
588
641
|
};
|
|
589
|
-
|
|
590
|
-
// src/analysis/rules/token-in-url.ts
|
|
591
642
|
var tokenInUrlRule = {
|
|
592
643
|
id: "token-in-url",
|
|
593
644
|
severity: "critical",
|
|
594
645
|
name: "Auth Token in URL",
|
|
595
646
|
hint: RULE_HINTS["token-in-url"],
|
|
596
647
|
check(ctx) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
if (qIdx === -1) continue;
|
|
602
|
-
const params = r.url.substring(qIdx + 1).split("&");
|
|
648
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
649
|
+
const qIdx = request.url.indexOf("?");
|
|
650
|
+
if (qIdx === -1) return null;
|
|
651
|
+
const params = request.url.substring(qIdx + 1).split("&");
|
|
603
652
|
const flagged = [];
|
|
604
653
|
for (const param of params) {
|
|
605
654
|
const [name, ...rest] = param.split("=");
|
|
@@ -609,65 +658,124 @@ var tokenInUrlRule = {
|
|
|
609
658
|
flagged.push(name);
|
|
610
659
|
}
|
|
611
660
|
}
|
|
612
|
-
if (flagged.length === 0)
|
|
613
|
-
const ep = `${
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
661
|
+
if (flagged.length === 0) return null;
|
|
662
|
+
const ep = `${request.method} ${request.path}`;
|
|
663
|
+
return {
|
|
664
|
+
key: `${ep}:${flagged.sort().join(",")}`,
|
|
665
|
+
finding: {
|
|
666
|
+
severity: "critical",
|
|
667
|
+
rule: "token-in-url",
|
|
668
|
+
title: "Auth Token in URL",
|
|
669
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
670
|
+
hint: this.hint,
|
|
671
|
+
endpoint: ep,
|
|
672
|
+
count: 1
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function isFrameworkResponse(request) {
|
|
679
|
+
if (isRedirect(request.statusCode)) return true;
|
|
680
|
+
if (request.path?.startsWith("/__")) return true;
|
|
681
|
+
if (request.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
var insecureCookieRule = {
|
|
685
|
+
id: "insecure-cookie",
|
|
686
|
+
severity: "warning",
|
|
687
|
+
name: "Insecure Cookie",
|
|
688
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
689
|
+
check(ctx) {
|
|
690
|
+
const cookieEntries = [];
|
|
691
|
+
for (const request of ctx.requests) {
|
|
692
|
+
if (!request.responseHeaders) continue;
|
|
693
|
+
if (isFrameworkResponse(request)) continue;
|
|
694
|
+
const setCookie = request.responseHeaders["set-cookie"];
|
|
695
|
+
if (!setCookie) continue;
|
|
696
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
697
|
+
for (const cookie of cookies) {
|
|
698
|
+
cookieEntries.push({ cookie });
|
|
619
699
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
700
|
+
}
|
|
701
|
+
return deduplicateFindings(cookieEntries, ({ cookie }) => {
|
|
702
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
703
|
+
const lower = cookie.toLowerCase();
|
|
704
|
+
const issues = [];
|
|
705
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
706
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
707
|
+
if (issues.length === 0) return null;
|
|
708
|
+
return {
|
|
709
|
+
key: `${cookieName}:${issues.join(",")}`,
|
|
710
|
+
finding: {
|
|
711
|
+
severity: "warning",
|
|
712
|
+
rule: "insecure-cookie",
|
|
713
|
+
title: "Insecure Cookie",
|
|
714
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
715
|
+
hint: this.hint,
|
|
716
|
+
endpoint: cookieName,
|
|
717
|
+
count: 1
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
var corsCredentialsRule = {
|
|
724
|
+
id: "cors-credentials",
|
|
725
|
+
severity: "warning",
|
|
726
|
+
name: "CORS Credentials with Wildcard",
|
|
727
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
728
|
+
check(ctx) {
|
|
729
|
+
const findings = [];
|
|
730
|
+
const seen = /* @__PURE__ */ new Set();
|
|
731
|
+
for (const request of ctx.requests) {
|
|
732
|
+
if (!request.responseHeaders) continue;
|
|
733
|
+
const origin = request.responseHeaders["access-control-allow-origin"];
|
|
734
|
+
const creds = request.responseHeaders["access-control-allow-credentials"];
|
|
735
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
736
|
+
const ep = `${request.method} ${request.path}`;
|
|
737
|
+
if (seen.has(ep)) continue;
|
|
738
|
+
seen.add(ep);
|
|
739
|
+
findings.push({
|
|
740
|
+
severity: "warning",
|
|
741
|
+
rule: "cors-credentials",
|
|
742
|
+
title: "CORS Credentials with Wildcard",
|
|
743
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
625
744
|
hint: this.hint,
|
|
626
745
|
endpoint: ep,
|
|
627
746
|
count: 1
|
|
628
|
-
};
|
|
629
|
-
seen.set(dedupKey, finding);
|
|
630
|
-
findings.push(finding);
|
|
747
|
+
});
|
|
631
748
|
}
|
|
632
749
|
return findings;
|
|
633
750
|
}
|
|
634
751
|
};
|
|
635
752
|
|
|
636
|
-
// src/analysis/rules/
|
|
753
|
+
// src/analysis/rules/data-rules.ts
|
|
637
754
|
var stackTraceLeakRule = {
|
|
638
755
|
id: "stack-trace-leak",
|
|
639
756
|
severity: "critical",
|
|
640
757
|
name: "Stack Trace Leaked to Client",
|
|
641
758
|
hint: RULE_HINTS["stack-trace-leak"],
|
|
642
759
|
check(ctx) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
659
|
-
hint: this.hint,
|
|
660
|
-
endpoint: ep,
|
|
661
|
-
count: 1
|
|
760
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
761
|
+
if (!request.responseBody) return null;
|
|
762
|
+
if (!STACK_TRACE_RE.test(request.responseBody)) return null;
|
|
763
|
+
const ep = `${request.method} ${request.path}`;
|
|
764
|
+
return {
|
|
765
|
+
key: ep,
|
|
766
|
+
finding: {
|
|
767
|
+
severity: "critical",
|
|
768
|
+
rule: "stack-trace-leak",
|
|
769
|
+
title: "Stack Trace Leaked to Client",
|
|
770
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
771
|
+
hint: this.hint,
|
|
772
|
+
endpoint: ep,
|
|
773
|
+
count: 1
|
|
774
|
+
}
|
|
662
775
|
};
|
|
663
|
-
|
|
664
|
-
findings.push(finding);
|
|
665
|
-
}
|
|
666
|
-
return findings;
|
|
776
|
+
});
|
|
667
777
|
}
|
|
668
778
|
};
|
|
669
|
-
|
|
670
|
-
// src/analysis/rules/error-info-leak.ts
|
|
671
779
|
var CRITICAL_PATTERNS = [
|
|
672
780
|
{ re: DB_CONN_RE, label: "database connection string" },
|
|
673
781
|
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
@@ -679,90 +787,34 @@ var errorInfoLeakRule = {
|
|
|
679
787
|
name: "Sensitive Data in Error Response",
|
|
680
788
|
hint: RULE_HINTS["error-info-leak"],
|
|
681
789
|
check(ctx) {
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const dedupKey = `${ep}:${p.label}`;
|
|
692
|
-
const existing = seen.get(dedupKey);
|
|
693
|
-
if (existing) {
|
|
694
|
-
existing.count++;
|
|
695
|
-
continue;
|
|
790
|
+
const entries = [];
|
|
791
|
+
for (const request of ctx.requests) {
|
|
792
|
+
if (request.statusCode < 400) continue;
|
|
793
|
+
if (!request.responseBody) continue;
|
|
794
|
+
if (request.responseHeaders["x-nextjs-error"] || request.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
795
|
+
const ep = `${request.method} ${request.path}`;
|
|
796
|
+
for (const pattern of CRITICAL_PATTERNS) {
|
|
797
|
+
if (pattern.re.test(request.responseBody)) {
|
|
798
|
+
entries.push({ ep, pattern, body: request.responseBody });
|
|
696
799
|
}
|
|
697
|
-
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return deduplicateFindings(entries, ({ ep, pattern }) => {
|
|
803
|
+
return {
|
|
804
|
+
key: `${ep}:${pattern.label}`,
|
|
805
|
+
finding: {
|
|
698
806
|
severity: "critical",
|
|
699
807
|
rule: "error-info-leak",
|
|
700
808
|
title: "Sensitive Data in Error Response",
|
|
701
|
-
desc: `${ep} \u2014 error response exposes ${
|
|
809
|
+
desc: `${ep} \u2014 error response exposes ${pattern.label}`,
|
|
702
810
|
hint: this.hint,
|
|
703
811
|
endpoint: ep,
|
|
704
812
|
count: 1
|
|
705
|
-
};
|
|
706
|
-
seen.set(dedupKey, finding);
|
|
707
|
-
findings.push(finding);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
return findings;
|
|
711
|
-
}
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
// src/analysis/rules/insecure-cookie.ts
|
|
715
|
-
function isFrameworkResponse(r) {
|
|
716
|
-
if (isRedirect(r.statusCode)) return true;
|
|
717
|
-
if (r.path?.startsWith("/__")) return true;
|
|
718
|
-
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
var insecureCookieRule = {
|
|
722
|
-
id: "insecure-cookie",
|
|
723
|
-
severity: "warning",
|
|
724
|
-
name: "Insecure Cookie",
|
|
725
|
-
hint: RULE_HINTS["insecure-cookie"],
|
|
726
|
-
check(ctx) {
|
|
727
|
-
const findings = [];
|
|
728
|
-
const seen = /* @__PURE__ */ new Map();
|
|
729
|
-
for (const r of ctx.requests) {
|
|
730
|
-
if (!r.responseHeaders) continue;
|
|
731
|
-
if (isFrameworkResponse(r)) continue;
|
|
732
|
-
const setCookie = r.responseHeaders["set-cookie"];
|
|
733
|
-
if (!setCookie) continue;
|
|
734
|
-
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
735
|
-
for (const cookie of cookies) {
|
|
736
|
-
const cookieName = cookie.trim().split("=")[0].trim();
|
|
737
|
-
const lower = cookie.toLowerCase();
|
|
738
|
-
const issues = [];
|
|
739
|
-
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
740
|
-
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
741
|
-
if (issues.length === 0) continue;
|
|
742
|
-
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
743
|
-
const existing = seen.get(dedupKey);
|
|
744
|
-
if (existing) {
|
|
745
|
-
existing.count++;
|
|
746
|
-
continue;
|
|
747
813
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
rule: "insecure-cookie",
|
|
751
|
-
title: "Insecure Cookie",
|
|
752
|
-
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
753
|
-
hint: this.hint,
|
|
754
|
-
endpoint: cookieName,
|
|
755
|
-
count: 1
|
|
756
|
-
};
|
|
757
|
-
seen.set(dedupKey, finding);
|
|
758
|
-
findings.push(finding);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
return findings;
|
|
814
|
+
};
|
|
815
|
+
});
|
|
762
816
|
}
|
|
763
817
|
};
|
|
764
|
-
|
|
765
|
-
// src/analysis/rules/sensitive-logs.ts
|
|
766
818
|
var sensitiveLogsRule = {
|
|
767
819
|
id: "sensitive-logs",
|
|
768
820
|
severity: "warning",
|
|
@@ -787,60 +839,15 @@ var sensitiveLogsRule = {
|
|
|
787
839
|
}];
|
|
788
840
|
}
|
|
789
841
|
};
|
|
790
|
-
|
|
791
|
-
// src/analysis/rules/cors-credentials.ts
|
|
792
|
-
var corsCredentialsRule = {
|
|
793
|
-
id: "cors-credentials",
|
|
794
|
-
severity: "warning",
|
|
795
|
-
name: "CORS Credentials with Wildcard",
|
|
796
|
-
hint: RULE_HINTS["cors-credentials"],
|
|
797
|
-
check(ctx) {
|
|
798
|
-
const findings = [];
|
|
799
|
-
const seen = /* @__PURE__ */ new Set();
|
|
800
|
-
for (const r of ctx.requests) {
|
|
801
|
-
if (!r.responseHeaders) continue;
|
|
802
|
-
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
803
|
-
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
804
|
-
if (origin !== "*" || creds !== "true") continue;
|
|
805
|
-
const ep = `${r.method} ${r.path}`;
|
|
806
|
-
if (seen.has(ep)) continue;
|
|
807
|
-
seen.add(ep);
|
|
808
|
-
findings.push({
|
|
809
|
-
severity: "warning",
|
|
810
|
-
rule: "cors-credentials",
|
|
811
|
-
title: "CORS Credentials with Wildcard",
|
|
812
|
-
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
813
|
-
hint: this.hint,
|
|
814
|
-
endpoint: ep,
|
|
815
|
-
count: 1
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
return findings;
|
|
819
|
-
}
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
// src/analysis/rules/response-pii-leak.ts
|
|
823
842
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
824
|
-
function findEmails(obj
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return emails;
|
|
833
|
-
}
|
|
834
|
-
for (const v of Object.values(obj)) {
|
|
835
|
-
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
836
|
-
emails.push(v);
|
|
837
|
-
} else if (typeof v === "object" && v !== null) {
|
|
838
|
-
emails.push(...findEmails(v, depth + 1));
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
return emails;
|
|
842
|
-
}
|
|
843
|
-
function topLevelFieldCount(obj) {
|
|
843
|
+
function findEmails(obj) {
|
|
844
|
+
return collectFromObject(
|
|
845
|
+
obj,
|
|
846
|
+
(_key, val) => typeof val === "string" && EMAIL_RE.test(val) ? val : null,
|
|
847
|
+
{ arrayLimit: PII_SCAN_ARRAY_LIMIT }
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
function topLevelFieldCount(obj) {
|
|
844
851
|
if (Array.isArray(obj)) {
|
|
845
852
|
return obj.length > 0 ? topLevelFieldCount(obj[0]) : 0;
|
|
846
853
|
}
|
|
@@ -854,6 +861,15 @@ function hasInternalIds(obj) {
|
|
|
854
861
|
}
|
|
855
862
|
return false;
|
|
856
863
|
}
|
|
864
|
+
function hasSensitiveFieldNames(obj, depth = 0) {
|
|
865
|
+
if (depth >= MAX_OBJECT_SCAN_DEPTH) return false;
|
|
866
|
+
if (!obj || typeof obj !== "object") return false;
|
|
867
|
+
if (Array.isArray(obj)) return obj.length > 0 && hasSensitiveFieldNames(obj[0], depth + 1);
|
|
868
|
+
for (const key of Object.keys(obj)) {
|
|
869
|
+
if (SENSITIVE_FIELD_NAMES.test(key)) return true;
|
|
870
|
+
}
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
857
873
|
function detectEchoPII(method, reqBody, target) {
|
|
858
874
|
if (!WRITE_METHODS.has(method) || !reqBody || typeof reqBody !== "object") return null;
|
|
859
875
|
const reqEmails = findEmails(reqBody);
|
|
@@ -875,6 +891,13 @@ function detectFullRecordPII(target) {
|
|
|
875
891
|
if (emails.length === 0) return null;
|
|
876
892
|
return { reason: "full-record", emailCount: emails.length };
|
|
877
893
|
}
|
|
894
|
+
function detectSensitiveFieldPII(target) {
|
|
895
|
+
const inspect = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
896
|
+
if (!inspect || typeof inspect !== "object" || Array.isArray(inspect)) return null;
|
|
897
|
+
if (!hasSensitiveFieldNames(inspect)) return null;
|
|
898
|
+
if (!hasInternalIds(inspect) && topLevelFieldCount(inspect) < FULL_RECORD_MIN_FIELDS) return null;
|
|
899
|
+
return { reason: "sensitive-fields", emailCount: 0 };
|
|
900
|
+
}
|
|
878
901
|
function detectListPII(target) {
|
|
879
902
|
if (!Array.isArray(target) || target.length < LIST_PII_MIN_ITEMS) return null;
|
|
880
903
|
let itemsWithEmail = 0;
|
|
@@ -893,12 +916,13 @@ function detectListPII(target) {
|
|
|
893
916
|
}
|
|
894
917
|
function detectPII(method, reqBody, resBody) {
|
|
895
918
|
const target = unwrapResponse(resBody);
|
|
896
|
-
return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target);
|
|
919
|
+
return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target) ?? detectSensitiveFieldPII(target);
|
|
897
920
|
}
|
|
898
921
|
var REASON_LABELS = {
|
|
899
922
|
echo: "echoes back PII from the request body",
|
|
900
923
|
"full-record": "returns a full record with email and internal IDs",
|
|
901
|
-
"list-pii": "returns a list of records containing email addresses"
|
|
924
|
+
"list-pii": "returns a list of records containing email addresses",
|
|
925
|
+
"sensitive-fields": "contains sensitive personal data fields (phone, SSN, date of birth, address, etc.)"
|
|
902
926
|
};
|
|
903
927
|
var responsePiiLeakRule = {
|
|
904
928
|
id: "response-pii-leak",
|
|
@@ -906,35 +930,28 @@ var responsePiiLeakRule = {
|
|
|
906
930
|
name: "PII Leak in Response",
|
|
907
931
|
hint: RULE_HINTS["response-pii-leak"],
|
|
908
932
|
check(ctx) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
desc: `${ep} \u2014 ${REASON_LABELS[detection.reason]}`,
|
|
930
|
-
hint: this.hint,
|
|
931
|
-
endpoint: ep,
|
|
932
|
-
count: 1
|
|
933
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
934
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
935
|
+
if (SELF_SERVICE_PATH.test(request.path)) return null;
|
|
936
|
+
const resJson = ctx.parsedBodies.response.get(request.id);
|
|
937
|
+
if (!resJson) return null;
|
|
938
|
+
const reqJson = ctx.parsedBodies.request.get(request.id) ?? null;
|
|
939
|
+
const detection = detectPII(request.method, reqJson, resJson);
|
|
940
|
+
if (!detection) return null;
|
|
941
|
+
const ep = `${request.method} ${request.path}`;
|
|
942
|
+
return {
|
|
943
|
+
key: ep,
|
|
944
|
+
finding: {
|
|
945
|
+
severity: "warning",
|
|
946
|
+
rule: "response-pii-leak",
|
|
947
|
+
title: "PII Leak in Response",
|
|
948
|
+
desc: `${ep} \u2014 exposes PII in response`,
|
|
949
|
+
hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
|
|
950
|
+
endpoint: ep,
|
|
951
|
+
count: 1
|
|
952
|
+
}
|
|
933
953
|
};
|
|
934
|
-
|
|
935
|
-
findings.push(finding);
|
|
936
|
-
}
|
|
937
|
-
return findings;
|
|
954
|
+
});
|
|
938
955
|
}
|
|
939
956
|
};
|
|
940
957
|
|
|
@@ -942,20 +959,22 @@ var responsePiiLeakRule = {
|
|
|
942
959
|
function buildBodyCache(requests) {
|
|
943
960
|
const response = /* @__PURE__ */ new Map();
|
|
944
961
|
const request = /* @__PURE__ */ new Map();
|
|
945
|
-
for (const
|
|
946
|
-
if (
|
|
947
|
-
const parsed = tryParseJson(
|
|
948
|
-
if (parsed != null) response.set(
|
|
962
|
+
for (const req of requests) {
|
|
963
|
+
if (req.responseBody) {
|
|
964
|
+
const parsed = tryParseJson(req.responseBody);
|
|
965
|
+
if (parsed != null) response.set(req.id, parsed);
|
|
949
966
|
}
|
|
950
|
-
if (
|
|
951
|
-
const parsed = tryParseJson(
|
|
952
|
-
if (parsed != null) request.set(
|
|
967
|
+
if (req.requestBody) {
|
|
968
|
+
const parsed = tryParseJson(req.requestBody);
|
|
969
|
+
if (parsed != null) request.set(req.id, parsed);
|
|
953
970
|
}
|
|
954
971
|
}
|
|
955
972
|
return { response, request };
|
|
956
973
|
}
|
|
957
974
|
var SecurityScanner = class {
|
|
958
|
-
|
|
975
|
+
constructor() {
|
|
976
|
+
this.rules = [];
|
|
977
|
+
}
|
|
959
978
|
register(rule) {
|
|
960
979
|
this.rules.push(rule);
|
|
961
980
|
}
|
|
@@ -968,7 +987,8 @@ var SecurityScanner = class {
|
|
|
968
987
|
for (const rule of this.rules) {
|
|
969
988
|
try {
|
|
970
989
|
findings.push(...rule.check(ctx));
|
|
971
|
-
} catch {
|
|
990
|
+
} catch (e) {
|
|
991
|
+
brakitDebug(`rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
972
992
|
}
|
|
973
993
|
}
|
|
974
994
|
return findings;
|
|
@@ -992,7 +1012,9 @@ function createDefaultScanner() {
|
|
|
992
1012
|
|
|
993
1013
|
// src/core/disposable.ts
|
|
994
1014
|
var SubscriptionBag = class {
|
|
995
|
-
|
|
1015
|
+
constructor() {
|
|
1016
|
+
this.items = [];
|
|
1017
|
+
}
|
|
996
1018
|
add(teardown) {
|
|
997
1019
|
this.items.push(typeof teardown === "function" ? { dispose: teardown } : teardown);
|
|
998
1020
|
}
|
|
@@ -1005,7 +1027,7 @@ var SubscriptionBag = class {
|
|
|
1005
1027
|
// src/analysis/group.ts
|
|
1006
1028
|
import { randomUUID } from "crypto";
|
|
1007
1029
|
|
|
1008
|
-
// src/constants/
|
|
1030
|
+
// src/constants/labels.ts
|
|
1009
1031
|
var DASHBOARD_PREFIX = "/__brakit";
|
|
1010
1032
|
var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
|
|
1011
1033
|
var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
|
|
@@ -1037,13 +1059,40 @@ var VALID_TABS_TUPLE = [
|
|
|
1037
1059
|
];
|
|
1038
1060
|
var VALID_TABS = new Set(VALID_TABS_TUPLE);
|
|
1039
1061
|
|
|
1040
|
-
// src/constants/
|
|
1062
|
+
// src/constants/features.ts
|
|
1041
1063
|
var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
|
|
1042
1064
|
|
|
1065
|
+
// src/utils/endpoint.ts
|
|
1066
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1067
|
+
var NUMERIC_ID_RE = /^\d+$/;
|
|
1068
|
+
var HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
|
|
1069
|
+
var ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
|
|
1070
|
+
function isDynamicSegment(segment) {
|
|
1071
|
+
return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
|
|
1072
|
+
}
|
|
1073
|
+
var DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
|
|
1074
|
+
function normalizePath(path) {
|
|
1075
|
+
const qIdx = path.indexOf("?");
|
|
1076
|
+
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1077
|
+
return pathname.split("/").map((seg) => seg && isDynamicSegment(seg) ? DYNAMIC_SEGMENT_PLACEHOLDER : seg).join("/");
|
|
1078
|
+
}
|
|
1079
|
+
function getEndpointKey(method, path) {
|
|
1080
|
+
return `${method} ${normalizePath(path)}`;
|
|
1081
|
+
}
|
|
1082
|
+
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1083
|
+
function extractEndpointFromDesc(desc) {
|
|
1084
|
+
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1085
|
+
}
|
|
1086
|
+
function stripQueryString(path) {
|
|
1087
|
+
const i = path.indexOf("?");
|
|
1088
|
+
return i === -1 ? path : path.slice(0, i);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1043
1091
|
// src/analysis/categorize.ts
|
|
1044
1092
|
function detectCategory(req) {
|
|
1045
1093
|
const { method, url, statusCode, responseHeaders } = req;
|
|
1046
1094
|
if (req.isStatic) return "static";
|
|
1095
|
+
if (req.isHealthCheck) return "health-check";
|
|
1047
1096
|
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
1048
1097
|
return "auth-handshake";
|
|
1049
1098
|
}
|
|
@@ -1195,20 +1244,42 @@ function capitalize(s) {
|
|
|
1195
1244
|
}
|
|
1196
1245
|
|
|
1197
1246
|
// src/analysis/transforms.ts
|
|
1198
|
-
|
|
1247
|
+
var DUPLICATE_CATEGORIES = /* @__PURE__ */ new Set(["data-fetch", "auth-check"]);
|
|
1248
|
+
function isDuplicateCandidate(req) {
|
|
1249
|
+
return DUPLICATE_CATEGORIES.has(req.category);
|
|
1250
|
+
}
|
|
1251
|
+
function buildRequestKey(req) {
|
|
1252
|
+
return `${req.method} ${stripQueryString(getEffectivePath(req))}`;
|
|
1253
|
+
}
|
|
1254
|
+
function isStrictModePattern(requests, counts) {
|
|
1255
|
+
if (counts.size === 0 || ![...counts.values()].every((c) => c === 2)) {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
const firstByKey = /* @__PURE__ */ new Map();
|
|
1259
|
+
for (const req of requests) {
|
|
1260
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1261
|
+
const key = buildRequestKey(req);
|
|
1262
|
+
const first = firstByKey.get(key);
|
|
1263
|
+
if (!first) {
|
|
1264
|
+
firstByKey.set(key, req);
|
|
1265
|
+
} else if (Math.abs(req.startedAt - first.startedAt) > STRICT_MODE_MAX_GAP_MS) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
function flagDuplicateRequests(requests) {
|
|
1199
1272
|
const counts = /* @__PURE__ */ new Map();
|
|
1200
1273
|
for (const req of requests) {
|
|
1201
|
-
if (req
|
|
1202
|
-
|
|
1203
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1274
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1275
|
+
const key = buildRequestKey(req);
|
|
1204
1276
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1205
1277
|
}
|
|
1206
|
-
const isStrictMode =
|
|
1278
|
+
const isStrictMode = isStrictModePattern(requests, counts);
|
|
1207
1279
|
const seen = /* @__PURE__ */ new Set();
|
|
1208
1280
|
for (const req of requests) {
|
|
1209
|
-
if (req
|
|
1210
|
-
|
|
1211
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1281
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1282
|
+
const key = buildRequestKey(req);
|
|
1212
1283
|
if (seen.has(key)) {
|
|
1213
1284
|
if (isStrictMode) {
|
|
1214
1285
|
req.isStrictModeDupe = true;
|
|
@@ -1220,20 +1291,20 @@ function markDuplicates(requests) {
|
|
|
1220
1291
|
}
|
|
1221
1292
|
}
|
|
1222
1293
|
}
|
|
1223
|
-
function
|
|
1294
|
+
function mergePollingSequences(requests) {
|
|
1224
1295
|
const result = [];
|
|
1225
1296
|
let i = 0;
|
|
1226
1297
|
while (i < requests.length) {
|
|
1227
1298
|
const current = requests[i];
|
|
1228
|
-
const currentEffective = getEffectivePath(current)
|
|
1299
|
+
const currentEffective = stripQueryString(getEffectivePath(current));
|
|
1229
1300
|
if (current.method === "GET" && current.category === "data-fetch") {
|
|
1230
|
-
let
|
|
1231
|
-
while (
|
|
1232
|
-
|
|
1301
|
+
let nextIndex = i + 1;
|
|
1302
|
+
while (nextIndex < requests.length && requests[nextIndex].method === "GET" && stripQueryString(getEffectivePath(requests[nextIndex])) === currentEffective) {
|
|
1303
|
+
nextIndex++;
|
|
1233
1304
|
}
|
|
1234
|
-
const count =
|
|
1305
|
+
const count = nextIndex - i;
|
|
1235
1306
|
if (count >= MIN_POLLING_SEQUENCE) {
|
|
1236
|
-
const last = requests[
|
|
1307
|
+
const last = requests[nextIndex - 1];
|
|
1237
1308
|
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
1238
1309
|
const endpointName = prettifyEndpoint(currentEffective);
|
|
1239
1310
|
result.push({
|
|
@@ -1244,7 +1315,7 @@ function collapsePolling(requests) {
|
|
|
1244
1315
|
pollingDurationMs: pollingDuration,
|
|
1245
1316
|
isDuplicate: false
|
|
1246
1317
|
});
|
|
1247
|
-
i =
|
|
1318
|
+
i = nextIndex;
|
|
1248
1319
|
continue;
|
|
1249
1320
|
}
|
|
1250
1321
|
}
|
|
@@ -1257,18 +1328,18 @@ function formatDurationLabel(ms) {
|
|
|
1257
1328
|
if (ms < 1e3) return `${ms}ms`;
|
|
1258
1329
|
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1259
1330
|
}
|
|
1260
|
-
function
|
|
1331
|
+
function collectRequestWarnings(requests) {
|
|
1261
1332
|
const warnings = [];
|
|
1262
1333
|
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
1263
1334
|
if (duplicateCount > 0) {
|
|
1264
1335
|
const unique = new Set(
|
|
1265
|
-
requests.filter((r) => r.isDuplicate).map((r) =>
|
|
1336
|
+
requests.filter((r) => r.isDuplicate).map((r) => buildRequestKey(r))
|
|
1266
1337
|
);
|
|
1267
1338
|
const endpoints = unique.size;
|
|
1268
1339
|
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
1269
|
-
const key =
|
|
1340
|
+
const key = buildRequestKey(r);
|
|
1270
1341
|
const first = requests.find(
|
|
1271
|
-
(o) => !o.isDuplicate &&
|
|
1342
|
+
(o) => !o.isDuplicate && buildRequestKey(o) === key
|
|
1272
1343
|
);
|
|
1273
1344
|
return first && first.responseBody === r.responseBody;
|
|
1274
1345
|
});
|
|
@@ -1291,6 +1362,14 @@ function detectWarnings(requests) {
|
|
|
1291
1362
|
}
|
|
1292
1363
|
|
|
1293
1364
|
// src/analysis/group.ts
|
|
1365
|
+
function shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, startedAt) {
|
|
1366
|
+
if (currentRequests.length === 0) return false;
|
|
1367
|
+
const sourcePage = labeled.sourcePage;
|
|
1368
|
+
const isNewPage = sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1369
|
+
const isTimeGap = startedAt - lastEndTime > FLOW_GAP_MS;
|
|
1370
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1371
|
+
return isNewPage || isTimeGap || isPageLoad;
|
|
1372
|
+
}
|
|
1294
1373
|
function groupRequestsIntoFlows(requests) {
|
|
1295
1374
|
if (requests.length === 0) return [];
|
|
1296
1375
|
const flows = [];
|
|
@@ -1301,17 +1380,12 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1301
1380
|
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
1302
1381
|
const labeled = labelRequest(req);
|
|
1303
1382
|
if (labeled.category === "static") continue;
|
|
1304
|
-
|
|
1305
|
-
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
1306
|
-
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1307
|
-
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1308
|
-
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
1309
|
-
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
1383
|
+
if (shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, req.startedAt)) {
|
|
1310
1384
|
flows.push(buildFlow(currentRequests));
|
|
1311
1385
|
currentRequests = [];
|
|
1312
1386
|
}
|
|
1313
1387
|
currentRequests.push(labeled);
|
|
1314
|
-
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
1388
|
+
currentSourcePage = labeled.sourcePage ?? currentSourcePage;
|
|
1315
1389
|
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
1316
1390
|
}
|
|
1317
1391
|
if (currentRequests.length > 0) {
|
|
@@ -1320,8 +1394,8 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1320
1394
|
return flows;
|
|
1321
1395
|
}
|
|
1322
1396
|
function buildFlow(rawRequests) {
|
|
1323
|
-
|
|
1324
|
-
const requests =
|
|
1397
|
+
flagDuplicateRequests(rawRequests);
|
|
1398
|
+
const requests = mergePollingSequences(rawRequests);
|
|
1325
1399
|
const first = requests[0];
|
|
1326
1400
|
const startTime = first.startedAt;
|
|
1327
1401
|
const endTime = Math.max(
|
|
@@ -1340,7 +1414,7 @@ function buildFlow(rawRequests) {
|
|
|
1340
1414
|
startTime,
|
|
1341
1415
|
totalDurationMs: Math.round(endTime - startTime),
|
|
1342
1416
|
hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
|
|
1343
|
-
warnings:
|
|
1417
|
+
warnings: collectRequestWarnings(rawRequests),
|
|
1344
1418
|
sourcePage,
|
|
1345
1419
|
redundancyPct
|
|
1346
1420
|
};
|
|
@@ -1352,20 +1426,20 @@ function getDominantSourcePage(requests) {
|
|
|
1352
1426
|
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
1353
1427
|
}
|
|
1354
1428
|
}
|
|
1355
|
-
let
|
|
1356
|
-
let
|
|
1429
|
+
let mostCommonPage = "";
|
|
1430
|
+
let highestCount = 0;
|
|
1357
1431
|
for (const [page, count] of counts) {
|
|
1358
|
-
if (count >
|
|
1359
|
-
|
|
1360
|
-
|
|
1432
|
+
if (count > highestCount) {
|
|
1433
|
+
mostCommonPage = page;
|
|
1434
|
+
highestCount = count;
|
|
1361
1435
|
}
|
|
1362
1436
|
}
|
|
1363
|
-
return
|
|
1437
|
+
return mostCommonPage || (requests[0]?.path ? stripQueryString(requests[0].path) : "") || "/";
|
|
1364
1438
|
}
|
|
1365
1439
|
function deriveFlowLabel(requests, sourcePage) {
|
|
1366
1440
|
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];
|
|
1367
1441
|
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
1368
|
-
const pageName = prettifyPageName(trigger.path
|
|
1442
|
+
const pageName = prettifyPageName(stripQueryString(trigger.path));
|
|
1369
1443
|
return `${pageName} Page`;
|
|
1370
1444
|
}
|
|
1371
1445
|
if (trigger.category === "api-call") {
|
|
@@ -1390,67 +1464,23 @@ function deriveFlowLabel(requests, sourcePage) {
|
|
|
1390
1464
|
return trigger.label;
|
|
1391
1465
|
}
|
|
1392
1466
|
|
|
1393
|
-
// src/utils/collections.ts
|
|
1394
|
-
function groupBy(items, keyFn) {
|
|
1395
|
-
const map = /* @__PURE__ */ new Map();
|
|
1396
|
-
for (const item of items) {
|
|
1397
|
-
const key = keyFn(item);
|
|
1398
|
-
if (key == null) continue;
|
|
1399
|
-
let arr = map.get(key);
|
|
1400
|
-
if (!arr) {
|
|
1401
|
-
arr = [];
|
|
1402
|
-
map.set(key, arr);
|
|
1403
|
-
}
|
|
1404
|
-
arr.push(item);
|
|
1405
|
-
}
|
|
1406
|
-
return map;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// src/utils/endpoint.ts
|
|
1410
|
-
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;
|
|
1411
|
-
function normalizePath(path) {
|
|
1412
|
-
const qIdx = path.indexOf("?");
|
|
1413
|
-
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1414
|
-
return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
|
|
1415
|
-
}
|
|
1416
|
-
function getEndpointKey(method, path) {
|
|
1417
|
-
return `${method} ${normalizePath(path)}`;
|
|
1418
|
-
}
|
|
1419
|
-
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1420
|
-
function extractEndpointFromDesc(desc) {
|
|
1421
|
-
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
1467
|
// src/instrument/adapters/normalize.ts
|
|
1468
|
+
var VALID_OPS = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
1469
|
+
var TABLE_RE = /(?:FROM|INTO|UPDATE)\s+(?:"?\w+"?\.)?"?(\w+)"?/i;
|
|
1425
1470
|
function normalizeSQL(sql) {
|
|
1426
1471
|
if (!sql) return { op: "OTHER", table: "" };
|
|
1427
1472
|
const trimmed = sql.trim();
|
|
1428
|
-
const
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
case "SELECT":
|
|
1437
|
-
return { op: "SELECT", table };
|
|
1438
|
-
case "INSERT":
|
|
1439
|
-
return { op: "INSERT", table };
|
|
1440
|
-
case "UPDATE":
|
|
1441
|
-
return { op: "UPDATE", table };
|
|
1442
|
-
case "DELETE":
|
|
1443
|
-
return { op: "DELETE", table };
|
|
1444
|
-
default:
|
|
1445
|
-
return { op: "OTHER", table };
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1473
|
+
const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
|
|
1474
|
+
const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
|
|
1475
|
+
const table = trimmed.match(TABLE_RE)?.[1] ?? "";
|
|
1476
|
+
return { op, table };
|
|
1477
|
+
}
|
|
1478
|
+
var SQL_PARAM_MARKER = /\$\d+/g;
|
|
1479
|
+
var SQL_STRING_LITERAL = /'[^']*'/g;
|
|
1480
|
+
var SQL_NUMBER_LITERAL = /\b\d+(\.\d+)?\b/g;
|
|
1448
1481
|
function normalizeQueryParams(sql) {
|
|
1449
1482
|
if (!sql) return null;
|
|
1450
|
-
|
|
1451
|
-
n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
|
|
1452
|
-
n = n.replace(/\$\d+/g, "?");
|
|
1453
|
-
return n;
|
|
1483
|
+
return sql.replace(SQL_PARAM_MARKER, "?").replace(SQL_STRING_LITERAL, "?").replace(SQL_NUMBER_LITERAL, "?");
|
|
1454
1484
|
}
|
|
1455
1485
|
|
|
1456
1486
|
// src/analysis/insights/query-helpers.ts
|
|
@@ -1467,7 +1497,7 @@ function getQueryInfo(q) {
|
|
|
1467
1497
|
}
|
|
1468
1498
|
|
|
1469
1499
|
// src/analysis/insights/prepare.ts
|
|
1470
|
-
function
|
|
1500
|
+
function emptyEndpointGroup() {
|
|
1471
1501
|
return {
|
|
1472
1502
|
total: 0,
|
|
1473
1503
|
errors: 0,
|
|
@@ -1479,16 +1509,12 @@ function createEndpointGroup() {
|
|
|
1479
1509
|
queryShapeDurations: /* @__PURE__ */ new Map()
|
|
1480
1510
|
};
|
|
1481
1511
|
}
|
|
1482
|
-
function
|
|
1512
|
+
function keepRecentPerEndpoint(requests) {
|
|
1483
1513
|
const byEndpoint = /* @__PURE__ */ new Map();
|
|
1484
|
-
for (const
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
list = [];
|
|
1489
|
-
byEndpoint.set(ep, list);
|
|
1490
|
-
}
|
|
1491
|
-
list.push(r);
|
|
1514
|
+
for (const request of requests) {
|
|
1515
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1516
|
+
const list = getOrCreate(byEndpoint, endpointKey, () => []);
|
|
1517
|
+
list.push(request);
|
|
1492
1518
|
}
|
|
1493
1519
|
const windowed = [];
|
|
1494
1520
|
for (const [, reqs] of byEndpoint) {
|
|
@@ -1496,54 +1522,67 @@ function windowByEndpoint(requests) {
|
|
|
1496
1522
|
}
|
|
1497
1523
|
return windowed;
|
|
1498
1524
|
}
|
|
1525
|
+
function filterUserRequests(requests) {
|
|
1526
|
+
return requests.filter(
|
|
1527
|
+
(request) => !request.isStatic && !request.isHealthCheck && (!request.path || !request.path.startsWith(DASHBOARD_PREFIX))
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1499
1530
|
function extractActiveEndpoints(requests) {
|
|
1500
1531
|
const endpoints = /* @__PURE__ */ new Set();
|
|
1501
|
-
for (const
|
|
1502
|
-
|
|
1503
|
-
endpoints.add(getEndpointKey(r.method, r.path));
|
|
1504
|
-
}
|
|
1532
|
+
for (const request of filterUserRequests(requests)) {
|
|
1533
|
+
endpoints.add(getEndpointKey(request.method, request.path));
|
|
1505
1534
|
}
|
|
1506
1535
|
return endpoints;
|
|
1507
1536
|
}
|
|
1508
|
-
function
|
|
1509
|
-
const nonStatic = ctx.requests.filter(
|
|
1510
|
-
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1511
|
-
);
|
|
1512
|
-
const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
|
|
1513
|
-
const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
|
|
1514
|
-
const reqById = new Map(nonStatic.map((r) => [r.id, r]));
|
|
1515
|
-
const recent = windowByEndpoint(nonStatic);
|
|
1537
|
+
function aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq) {
|
|
1516
1538
|
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1517
|
-
for (const
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1539
|
+
for (const request of recent) {
|
|
1540
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1541
|
+
const group = getOrCreate(endpointGroups, endpointKey, emptyEndpointGroup);
|
|
1542
|
+
group.total++;
|
|
1543
|
+
if (isErrorStatus(request.statusCode)) group.errors++;
|
|
1544
|
+
group.totalDuration += request.durationMs;
|
|
1545
|
+
group.totalSize += request.responseSize ?? 0;
|
|
1546
|
+
const reqQueries = queriesByReq.get(request.id) ?? [];
|
|
1547
|
+
group.queryCount += reqQueries.length;
|
|
1548
|
+
for (const query of reqQueries) {
|
|
1549
|
+
group.totalQueryTimeMs += query.durationMs;
|
|
1550
|
+
const shape = getQueryShape(query);
|
|
1551
|
+
const info = getQueryInfo(query);
|
|
1552
|
+
const shapeDuration = getOrCreate(group.queryShapeDurations, shape, () => ({
|
|
1553
|
+
totalMs: 0,
|
|
1554
|
+
count: 0,
|
|
1555
|
+
label: info.op + (info.table ? ` ${info.table}` : "")
|
|
1556
|
+
}));
|
|
1557
|
+
shapeDuration.totalMs += query.durationMs;
|
|
1558
|
+
shapeDuration.count++;
|
|
1559
|
+
}
|
|
1560
|
+
const reqFetches = fetchesByReq.get(request.id) ?? [];
|
|
1561
|
+
for (const fetch of reqFetches) {
|
|
1562
|
+
group.totalFetchTimeMs += fetch.durationMs;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return endpointGroups;
|
|
1566
|
+
}
|
|
1567
|
+
function collectStrictModeDupeIds(ctx) {
|
|
1568
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1569
|
+
for (const flow of ctx.flows) {
|
|
1570
|
+
for (const req of flow.requests) {
|
|
1571
|
+
if (req.isStrictModeDupe) ids.add(req.id);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return ids;
|
|
1575
|
+
}
|
|
1576
|
+
function buildInsightContext(ctx) {
|
|
1577
|
+
const strictModeDupeIds = collectStrictModeDupeIds(ctx);
|
|
1578
|
+
const nonStatic = filterUserRequests(ctx.requests).filter((req) => !strictModeDupeIds.has(req.id));
|
|
1579
|
+
const filteredQueries = strictModeDupeIds.size > 0 ? ctx.queries.filter((q) => !q.parentRequestId || !strictModeDupeIds.has(q.parentRequestId)) : ctx.queries;
|
|
1580
|
+
const filteredFetches = strictModeDupeIds.size > 0 ? ctx.fetches.filter((f) => !f.parentRequestId || !strictModeDupeIds.has(f.parentRequestId)) : ctx.fetches;
|
|
1581
|
+
const queriesByReq = groupBy(filteredQueries, (query) => query.parentRequestId);
|
|
1582
|
+
const fetchesByReq = groupBy(filteredFetches, (fetch) => fetch.parentRequestId);
|
|
1583
|
+
const reqById = new Map(nonStatic.map((request) => [request.id, request]));
|
|
1584
|
+
const recent = keepRecentPerEndpoint(nonStatic);
|
|
1585
|
+
const endpointGroups = aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq);
|
|
1547
1586
|
return {
|
|
1548
1587
|
...ctx,
|
|
1549
1588
|
nonStatic,
|
|
@@ -1557,17 +1596,20 @@ function prepareContext(ctx) {
|
|
|
1557
1596
|
// src/analysis/insights/runner.ts
|
|
1558
1597
|
var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
1559
1598
|
var InsightRunner = class {
|
|
1560
|
-
|
|
1599
|
+
constructor() {
|
|
1600
|
+
this.rules = [];
|
|
1601
|
+
}
|
|
1561
1602
|
register(rule) {
|
|
1562
1603
|
this.rules.push(rule);
|
|
1563
1604
|
}
|
|
1564
1605
|
run(ctx) {
|
|
1565
|
-
const prepared =
|
|
1606
|
+
const prepared = buildInsightContext(ctx);
|
|
1566
1607
|
const insights = [];
|
|
1567
1608
|
for (const rule of this.rules) {
|
|
1568
1609
|
try {
|
|
1569
1610
|
insights.push(...rule.check(prepared));
|
|
1570
|
-
} catch {
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
brakitDebug(`insight rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
1571
1613
|
}
|
|
1572
1614
|
}
|
|
1573
1615
|
insights.sort(
|
|
@@ -1577,7 +1619,7 @@ var InsightRunner = class {
|
|
|
1577
1619
|
}
|
|
1578
1620
|
};
|
|
1579
1621
|
|
|
1580
|
-
// src/analysis/insights/rules/
|
|
1622
|
+
// src/analysis/insights/rules/query-rules.ts
|
|
1581
1623
|
var n1Rule = {
|
|
1582
1624
|
id: "n1",
|
|
1583
1625
|
check(ctx) {
|
|
@@ -1588,15 +1630,15 @@ var n1Rule = {
|
|
|
1588
1630
|
if (!req) continue;
|
|
1589
1631
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1590
1632
|
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1591
|
-
for (const
|
|
1592
|
-
const shape = getQueryShape(
|
|
1633
|
+
for (const query of reqQueries) {
|
|
1634
|
+
const shape = getQueryShape(query);
|
|
1593
1635
|
let group = shapeGroups.get(shape);
|
|
1594
1636
|
if (!group) {
|
|
1595
|
-
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first:
|
|
1637
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: query };
|
|
1596
1638
|
shapeGroups.set(shape, group);
|
|
1597
1639
|
}
|
|
1598
1640
|
group.count++;
|
|
1599
|
-
group.distinctSql.add(
|
|
1641
|
+
group.distinctSql.add(query.sql ?? shape);
|
|
1600
1642
|
}
|
|
1601
1643
|
for (const [, sg] of shapeGroups) {
|
|
1602
1644
|
if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
|
|
@@ -1617,57 +1659,6 @@ var n1Rule = {
|
|
|
1617
1659
|
return insights;
|
|
1618
1660
|
}
|
|
1619
1661
|
};
|
|
1620
|
-
|
|
1621
|
-
// src/analysis/insights/rules/cross-endpoint.ts
|
|
1622
|
-
var crossEndpointRule = {
|
|
1623
|
-
id: "cross-endpoint",
|
|
1624
|
-
check(ctx) {
|
|
1625
|
-
const insights = [];
|
|
1626
|
-
const queryMap = /* @__PURE__ */ new Map();
|
|
1627
|
-
const allEndpoints = /* @__PURE__ */ new Set();
|
|
1628
|
-
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1629
|
-
const req = ctx.reqById.get(reqId);
|
|
1630
|
-
if (!req) continue;
|
|
1631
|
-
const endpoint = getEndpointKey(req.method, req.path);
|
|
1632
|
-
allEndpoints.add(endpoint);
|
|
1633
|
-
const seenInReq = /* @__PURE__ */ new Set();
|
|
1634
|
-
for (const q of reqQueries) {
|
|
1635
|
-
const shape = getQueryShape(q);
|
|
1636
|
-
let entry = queryMap.get(shape);
|
|
1637
|
-
if (!entry) {
|
|
1638
|
-
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1639
|
-
queryMap.set(shape, entry);
|
|
1640
|
-
}
|
|
1641
|
-
entry.count++;
|
|
1642
|
-
if (!seenInReq.has(shape)) {
|
|
1643
|
-
seenInReq.add(shape);
|
|
1644
|
-
entry.endpoints.add(endpoint);
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1649
|
-
for (const [, cem] of queryMap) {
|
|
1650
|
-
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1651
|
-
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1652
|
-
const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
|
|
1653
|
-
if (p < CROSS_ENDPOINT_PCT) continue;
|
|
1654
|
-
const info = getQueryInfo(cem.first);
|
|
1655
|
-
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1656
|
-
insights.push({
|
|
1657
|
-
severity: "warning",
|
|
1658
|
-
type: "cross-endpoint",
|
|
1659
|
-
title: "Repeated Query Across Endpoints",
|
|
1660
|
-
desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
|
|
1661
|
-
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1662
|
-
nav: "queries"
|
|
1663
|
-
});
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
return insights;
|
|
1667
|
-
}
|
|
1668
|
-
};
|
|
1669
|
-
|
|
1670
|
-
// src/analysis/insights/rules/redundant-query.ts
|
|
1671
1662
|
var redundantQueryRule = {
|
|
1672
1663
|
id: "redundant-query",
|
|
1673
1664
|
check(ctx) {
|
|
@@ -1678,27 +1669,27 @@ var redundantQueryRule = {
|
|
|
1678
1669
|
if (!req) continue;
|
|
1679
1670
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1680
1671
|
const exact = /* @__PURE__ */ new Map();
|
|
1681
|
-
for (const
|
|
1682
|
-
if (!
|
|
1683
|
-
let entry = exact.get(
|
|
1672
|
+
for (const query of reqQueries) {
|
|
1673
|
+
if (!query.sql) continue;
|
|
1674
|
+
let entry = exact.get(query.sql);
|
|
1684
1675
|
if (!entry) {
|
|
1685
|
-
entry = { count: 0, first:
|
|
1686
|
-
exact.set(
|
|
1676
|
+
entry = { count: 0, first: query };
|
|
1677
|
+
exact.set(query.sql, entry);
|
|
1687
1678
|
}
|
|
1688
1679
|
entry.count++;
|
|
1689
1680
|
}
|
|
1690
|
-
for (const [,
|
|
1691
|
-
if (
|
|
1692
|
-
const info = getQueryInfo(
|
|
1681
|
+
for (const [, entry] of exact) {
|
|
1682
|
+
if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1683
|
+
const info = getQueryInfo(entry.first);
|
|
1693
1684
|
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1694
|
-
const
|
|
1695
|
-
if (seen.has(
|
|
1696
|
-
seen.add(
|
|
1685
|
+
const deduplicationKey = `${endpoint}:${label}`;
|
|
1686
|
+
if (seen.has(deduplicationKey)) continue;
|
|
1687
|
+
seen.add(deduplicationKey);
|
|
1697
1688
|
insights.push({
|
|
1698
1689
|
severity: "warning",
|
|
1699
1690
|
type: "redundant-query",
|
|
1700
1691
|
title: "Redundant Query",
|
|
1701
|
-
desc: `${label} runs ${
|
|
1692
|
+
desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
|
|
1702
1693
|
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.",
|
|
1703
1694
|
nav: "queries"
|
|
1704
1695
|
});
|
|
@@ -1707,179 +1698,16 @@ var redundantQueryRule = {
|
|
|
1707
1698
|
return insights;
|
|
1708
1699
|
}
|
|
1709
1700
|
};
|
|
1710
|
-
|
|
1711
|
-
// src/analysis/insights/rules/error.ts
|
|
1712
|
-
var errorRule = {
|
|
1713
|
-
id: "error",
|
|
1714
|
-
check(ctx) {
|
|
1715
|
-
if (ctx.errors.length === 0) return [];
|
|
1716
|
-
const insights = [];
|
|
1717
|
-
const groups = /* @__PURE__ */ new Map();
|
|
1718
|
-
for (const e of ctx.errors) {
|
|
1719
|
-
const name = e.name || "Error";
|
|
1720
|
-
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1721
|
-
}
|
|
1722
|
-
for (const [name, cnt] of groups) {
|
|
1723
|
-
insights.push({
|
|
1724
|
-
severity: "critical",
|
|
1725
|
-
type: "error",
|
|
1726
|
-
title: "Unhandled Error",
|
|
1727
|
-
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1728
|
-
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1729
|
-
nav: "errors"
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
return insights;
|
|
1733
|
-
}
|
|
1734
|
-
};
|
|
1735
|
-
|
|
1736
|
-
// src/analysis/insights/rules/error-hotspot.ts
|
|
1737
|
-
var errorHotspotRule = {
|
|
1738
|
-
id: "error-hotspot",
|
|
1739
|
-
check(ctx) {
|
|
1740
|
-
const insights = [];
|
|
1741
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1742
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1743
|
-
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1744
|
-
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1745
|
-
insights.push({
|
|
1746
|
-
severity: "critical",
|
|
1747
|
-
type: "error-hotspot",
|
|
1748
|
-
title: "Error Hotspot",
|
|
1749
|
-
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1750
|
-
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1751
|
-
nav: "requests"
|
|
1752
|
-
});
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
return insights;
|
|
1756
|
-
}
|
|
1757
|
-
};
|
|
1758
|
-
|
|
1759
|
-
// src/analysis/insights/rules/duplicate.ts
|
|
1760
|
-
var duplicateRule = {
|
|
1761
|
-
id: "duplicate",
|
|
1762
|
-
check(ctx) {
|
|
1763
|
-
const dupCounts = /* @__PURE__ */ new Map();
|
|
1764
|
-
const flowCount = /* @__PURE__ */ new Map();
|
|
1765
|
-
for (const flow of ctx.flows) {
|
|
1766
|
-
if (!flow.requests) continue;
|
|
1767
|
-
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1768
|
-
for (const fr of flow.requests) {
|
|
1769
|
-
if (!fr.isDuplicate) continue;
|
|
1770
|
-
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1771
|
-
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1772
|
-
if (!seenInFlow.has(dupKey)) {
|
|
1773
|
-
seenInFlow.add(dupKey);
|
|
1774
|
-
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1779
|
-
const insights = [];
|
|
1780
|
-
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
1781
|
-
const d = dupEntries[i];
|
|
1782
|
-
insights.push({
|
|
1783
|
-
severity: "warning",
|
|
1784
|
-
type: "duplicate",
|
|
1785
|
-
title: "Duplicate API Call",
|
|
1786
|
-
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1787
|
-
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.",
|
|
1788
|
-
nav: "actions"
|
|
1789
|
-
});
|
|
1790
|
-
}
|
|
1791
|
-
return insights;
|
|
1792
|
-
}
|
|
1793
|
-
};
|
|
1794
|
-
|
|
1795
|
-
// src/utils/format.ts
|
|
1796
|
-
function formatDuration(ms) {
|
|
1797
|
-
if (ms < 1e3) return `${ms}ms`;
|
|
1798
|
-
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1799
|
-
}
|
|
1800
|
-
function formatSize(bytes) {
|
|
1801
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
1802
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1803
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1804
|
-
}
|
|
1805
|
-
function pct(part, total) {
|
|
1806
|
-
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
// src/analysis/insights/rules/slow.ts
|
|
1810
|
-
var slowRule = {
|
|
1811
|
-
id: "slow",
|
|
1812
|
-
check(ctx) {
|
|
1813
|
-
const insights = [];
|
|
1814
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1815
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1816
|
-
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1817
|
-
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1818
|
-
const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
|
|
1819
|
-
const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
|
|
1820
|
-
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1821
|
-
const parts = [];
|
|
1822
|
-
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1823
|
-
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1824
|
-
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1825
|
-
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1826
|
-
let detail;
|
|
1827
|
-
let slowestMs = 0;
|
|
1828
|
-
for (const [, sd] of g.queryShapeDurations) {
|
|
1829
|
-
const avgShapeMs = sd.totalMs / sd.count;
|
|
1830
|
-
if (avgShapeMs > slowestMs) {
|
|
1831
|
-
slowestMs = avgShapeMs;
|
|
1832
|
-
detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
insights.push({
|
|
1836
|
-
severity: "warning",
|
|
1837
|
-
type: "slow",
|
|
1838
|
-
title: "Slow Endpoint",
|
|
1839
|
-
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1840
|
-
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.",
|
|
1841
|
-
detail,
|
|
1842
|
-
nav: "requests"
|
|
1843
|
-
});
|
|
1844
|
-
}
|
|
1845
|
-
return insights;
|
|
1846
|
-
}
|
|
1847
|
-
};
|
|
1848
|
-
|
|
1849
|
-
// src/analysis/insights/rules/query-heavy.ts
|
|
1850
|
-
var queryHeavyRule = {
|
|
1851
|
-
id: "query-heavy",
|
|
1852
|
-
check(ctx) {
|
|
1853
|
-
const insights = [];
|
|
1854
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1855
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1856
|
-
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1857
|
-
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1858
|
-
insights.push({
|
|
1859
|
-
severity: "warning",
|
|
1860
|
-
type: "query-heavy",
|
|
1861
|
-
title: "Query-Heavy Endpoint",
|
|
1862
|
-
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1863
|
-
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1864
|
-
nav: "queries"
|
|
1865
|
-
});
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
return insights;
|
|
1869
|
-
}
|
|
1870
|
-
};
|
|
1871
|
-
|
|
1872
|
-
// src/analysis/insights/rules/select-star.ts
|
|
1873
1701
|
var selectStarRule = {
|
|
1874
1702
|
id: "select-star",
|
|
1875
1703
|
check(ctx) {
|
|
1876
1704
|
const seen = /* @__PURE__ */ new Map();
|
|
1877
1705
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1878
|
-
for (const
|
|
1879
|
-
if (!
|
|
1880
|
-
const isSelectStar = SELECT_STAR_RE.test(
|
|
1706
|
+
for (const query of reqQueries) {
|
|
1707
|
+
if (!query.sql) continue;
|
|
1708
|
+
const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
|
|
1881
1709
|
if (!isSelectStar) continue;
|
|
1882
|
-
const info = getQueryInfo(
|
|
1710
|
+
const info = getQueryInfo(query);
|
|
1883
1711
|
const key = info.table || "unknown";
|
|
1884
1712
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
1885
1713
|
}
|
|
@@ -1899,16 +1727,14 @@ var selectStarRule = {
|
|
|
1899
1727
|
return insights;
|
|
1900
1728
|
}
|
|
1901
1729
|
};
|
|
1902
|
-
|
|
1903
|
-
// src/analysis/insights/rules/high-rows.ts
|
|
1904
1730
|
var highRowsRule = {
|
|
1905
1731
|
id: "high-rows",
|
|
1906
1732
|
check(ctx) {
|
|
1907
1733
|
const seen = /* @__PURE__ */ new Map();
|
|
1908
1734
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1909
|
-
for (const
|
|
1910
|
-
if (!
|
|
1911
|
-
const info = getQueryInfo(
|
|
1735
|
+
for (const query of reqQueries) {
|
|
1736
|
+
if (!query.rowCount || query.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1737
|
+
const info = getQueryInfo(query);
|
|
1912
1738
|
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1913
1739
|
let entry = seen.get(key);
|
|
1914
1740
|
if (!entry) {
|
|
@@ -1916,7 +1742,7 @@ var highRowsRule = {
|
|
|
1916
1742
|
seen.set(key, entry);
|
|
1917
1743
|
}
|
|
1918
1744
|
entry.count++;
|
|
1919
|
-
if (
|
|
1745
|
+
if (query.rowCount > entry.max) entry.max = query.rowCount;
|
|
1920
1746
|
}
|
|
1921
1747
|
}
|
|
1922
1748
|
const insights = [];
|
|
@@ -1934,21 +1760,57 @@ var highRowsRule = {
|
|
|
1934
1760
|
return insights;
|
|
1935
1761
|
}
|
|
1936
1762
|
};
|
|
1763
|
+
var queryHeavyRule = {
|
|
1764
|
+
id: "query-heavy",
|
|
1765
|
+
check(ctx) {
|
|
1766
|
+
const insights = [];
|
|
1767
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1768
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1769
|
+
const avgQueries = Math.round(group.queryCount / group.total);
|
|
1770
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1771
|
+
insights.push({
|
|
1772
|
+
severity: "warning",
|
|
1773
|
+
type: "query-heavy",
|
|
1774
|
+
title: "Query-Heavy Endpoint",
|
|
1775
|
+
desc: `${endpointKey} \u2014 avg ${avgQueries} queries/request`,
|
|
1776
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1777
|
+
nav: "queries"
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return insights;
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
// src/utils/format.ts
|
|
1786
|
+
function formatDuration(ms) {
|
|
1787
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1788
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1789
|
+
}
|
|
1790
|
+
function formatSize(bytes) {
|
|
1791
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1792
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1793
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1794
|
+
}
|
|
1795
|
+
function pct(part, total) {
|
|
1796
|
+
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1797
|
+
}
|
|
1937
1798
|
|
|
1938
|
-
// src/analysis/insights/rules/response-
|
|
1799
|
+
// src/analysis/insights/rules/response-rules.ts
|
|
1939
1800
|
var responseOverfetchRule = {
|
|
1940
1801
|
id: "response-overfetch",
|
|
1941
1802
|
check(ctx) {
|
|
1942
1803
|
const insights = [];
|
|
1943
1804
|
const seen = /* @__PURE__ */ new Set();
|
|
1944
|
-
for (const
|
|
1945
|
-
if (isErrorStatus(
|
|
1946
|
-
const
|
|
1947
|
-
if (seen.has(
|
|
1805
|
+
for (const request of ctx.nonStatic) {
|
|
1806
|
+
if (isErrorStatus(request.statusCode) || !request.responseBody) continue;
|
|
1807
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1808
|
+
if (seen.has(endpointKey)) continue;
|
|
1948
1809
|
let parsed;
|
|
1949
1810
|
try {
|
|
1950
|
-
parsed = JSON.parse(
|
|
1951
|
-
} catch {
|
|
1811
|
+
parsed = JSON.parse(request.responseBody);
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
brakitDebug(`json parse: ${getErrorMessage(e)}`);
|
|
1952
1814
|
continue;
|
|
1953
1815
|
}
|
|
1954
1816
|
const target = unwrapResponse(parsed);
|
|
@@ -1971,12 +1833,12 @@ var responseOverfetchRule = {
|
|
|
1971
1833
|
reasons.push(`${fields.length} fields returned`);
|
|
1972
1834
|
}
|
|
1973
1835
|
if (reasons.length > 0) {
|
|
1974
|
-
seen.add(
|
|
1836
|
+
seen.add(endpointKey);
|
|
1975
1837
|
insights.push({
|
|
1976
1838
|
severity: "info",
|
|
1977
1839
|
type: "response-overfetch",
|
|
1978
1840
|
title: "Response Overfetch",
|
|
1979
|
-
desc: `${
|
|
1841
|
+
desc: `${endpointKey} \u2014 ${reasons.join(", ")}`,
|
|
1980
1842
|
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.",
|
|
1981
1843
|
nav: "requests"
|
|
1982
1844
|
});
|
|
@@ -1985,21 +1847,19 @@ var responseOverfetchRule = {
|
|
|
1985
1847
|
return insights;
|
|
1986
1848
|
}
|
|
1987
1849
|
};
|
|
1988
|
-
|
|
1989
|
-
// src/analysis/insights/rules/large-response.ts
|
|
1990
1850
|
var largeResponseRule = {
|
|
1991
1851
|
id: "large-response",
|
|
1992
1852
|
check(ctx) {
|
|
1993
1853
|
const insights = [];
|
|
1994
|
-
for (const [
|
|
1995
|
-
if (
|
|
1996
|
-
const avgSize = Math.round(
|
|
1854
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1855
|
+
if (group.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1856
|
+
const avgSize = Math.round(group.totalSize / group.total);
|
|
1997
1857
|
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
1998
1858
|
insights.push({
|
|
1999
1859
|
severity: "info",
|
|
2000
1860
|
type: "large-response",
|
|
2001
1861
|
title: "Large Response",
|
|
2002
|
-
desc: `${
|
|
1862
|
+
desc: `${endpointKey} \u2014 avg ${formatSize(avgSize)} response`,
|
|
2003
1863
|
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
|
|
2004
1864
|
nav: "requests"
|
|
2005
1865
|
});
|
|
@@ -2009,7 +1869,51 @@ var largeResponseRule = {
|
|
|
2009
1869
|
}
|
|
2010
1870
|
};
|
|
2011
1871
|
|
|
2012
|
-
// src/analysis/insights/rules/
|
|
1872
|
+
// src/analysis/insights/rules/reliability-rules.ts
|
|
1873
|
+
var errorRule = {
|
|
1874
|
+
id: "error",
|
|
1875
|
+
check(ctx) {
|
|
1876
|
+
if (ctx.errors.length === 0) return [];
|
|
1877
|
+
const insights = [];
|
|
1878
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1879
|
+
for (const error of ctx.errors) {
|
|
1880
|
+
const name = error.name || "Error";
|
|
1881
|
+
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1882
|
+
}
|
|
1883
|
+
for (const [name, cnt] of groups) {
|
|
1884
|
+
insights.push({
|
|
1885
|
+
severity: "critical",
|
|
1886
|
+
type: "error",
|
|
1887
|
+
title: "Unhandled Error",
|
|
1888
|
+
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1889
|
+
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1890
|
+
nav: "errors"
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
return insights;
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
var errorHotspotRule = {
|
|
1897
|
+
id: "error-hotspot",
|
|
1898
|
+
check(ctx) {
|
|
1899
|
+
const insights = [];
|
|
1900
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1901
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1902
|
+
const errorRate = Math.round(group.errors / group.total * 100);
|
|
1903
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1904
|
+
insights.push({
|
|
1905
|
+
severity: "critical",
|
|
1906
|
+
type: "error-hotspot",
|
|
1907
|
+
title: "Error Hotspot",
|
|
1908
|
+
desc: `${endpointKey} \u2014 ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
|
|
1909
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1910
|
+
nav: "requests"
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return insights;
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
2013
1917
|
var regressionRule = {
|
|
2014
1918
|
id: "regression",
|
|
2015
1919
|
check(ctx) {
|
|
@@ -2046,6 +1950,127 @@ var regressionRule = {
|
|
|
2046
1950
|
return insights;
|
|
2047
1951
|
}
|
|
2048
1952
|
};
|
|
1953
|
+
var slowRule = {
|
|
1954
|
+
id: "slow",
|
|
1955
|
+
check(ctx) {
|
|
1956
|
+
const insights = [];
|
|
1957
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1958
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1959
|
+
const avgMs = Math.round(group.totalDuration / group.total);
|
|
1960
|
+
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1961
|
+
const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
|
|
1962
|
+
const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
|
|
1963
|
+
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1964
|
+
const parts = [];
|
|
1965
|
+
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1966
|
+
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1967
|
+
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1968
|
+
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1969
|
+
let detail;
|
|
1970
|
+
let slowestMs = 0;
|
|
1971
|
+
for (const [, shapeDuration] of group.queryShapeDurations) {
|
|
1972
|
+
const avgShapeMs = shapeDuration.totalMs / shapeDuration.count;
|
|
1973
|
+
if (avgShapeMs > slowestMs) {
|
|
1974
|
+
slowestMs = avgShapeMs;
|
|
1975
|
+
detail = `Slowest query: ${shapeDuration.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${shapeDuration.count}x)`;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
insights.push({
|
|
1979
|
+
severity: "warning",
|
|
1980
|
+
type: "slow",
|
|
1981
|
+
title: "Slow Endpoint",
|
|
1982
|
+
desc: `${endpointKey} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1983
|
+
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.",
|
|
1984
|
+
detail,
|
|
1985
|
+
nav: "requests"
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
return insights;
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
// src/analysis/insights/rules/pattern-rules.ts
|
|
1993
|
+
var duplicateRule = {
|
|
1994
|
+
id: "duplicate",
|
|
1995
|
+
check(ctx) {
|
|
1996
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
1997
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
1998
|
+
for (const flow of ctx.flows) {
|
|
1999
|
+
if (!flow.requests) continue;
|
|
2000
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
2001
|
+
for (const request of flow.requests) {
|
|
2002
|
+
if (!request.isDuplicate) continue;
|
|
2003
|
+
const deduplicationKey = `${request.method} ${request.label ?? request.path ?? request.url}`;
|
|
2004
|
+
dupCounts.set(deduplicationKey, (dupCounts.get(deduplicationKey) ?? 0) + 1);
|
|
2005
|
+
if (!seenInFlow.has(deduplicationKey)) {
|
|
2006
|
+
seenInFlow.add(deduplicationKey);
|
|
2007
|
+
flowCount.set(deduplicationKey, (flowCount.get(deduplicationKey) ?? 0) + 1);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
2012
|
+
const insights = [];
|
|
2013
|
+
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
2014
|
+
const duplicate = dupEntries[i];
|
|
2015
|
+
insights.push({
|
|
2016
|
+
severity: "warning",
|
|
2017
|
+
type: "duplicate",
|
|
2018
|
+
title: "Duplicate API Call",
|
|
2019
|
+
desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
|
|
2020
|
+
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.",
|
|
2021
|
+
nav: "actions"
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
return insights;
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
var crossEndpointRule = {
|
|
2028
|
+
id: "cross-endpoint",
|
|
2029
|
+
check(ctx) {
|
|
2030
|
+
const insights = [];
|
|
2031
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
2032
|
+
const allEndpoints = /* @__PURE__ */ new Set();
|
|
2033
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
2034
|
+
const req = ctx.reqById.get(reqId);
|
|
2035
|
+
if (!req) continue;
|
|
2036
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
2037
|
+
allEndpoints.add(endpoint);
|
|
2038
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
2039
|
+
for (const query of reqQueries) {
|
|
2040
|
+
const shape = getQueryShape(query);
|
|
2041
|
+
let entry = queryMap.get(shape);
|
|
2042
|
+
if (!entry) {
|
|
2043
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: query };
|
|
2044
|
+
queryMap.set(shape, entry);
|
|
2045
|
+
}
|
|
2046
|
+
entry.count++;
|
|
2047
|
+
if (!seenInReq.has(shape)) {
|
|
2048
|
+
seenInReq.add(shape);
|
|
2049
|
+
entry.endpoints.add(endpoint);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
2054
|
+
for (const [, queryMetric] of queryMap) {
|
|
2055
|
+
if (queryMetric.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
2056
|
+
if (queryMetric.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
2057
|
+
const coveragePct = Math.round(queryMetric.endpoints.size / allEndpoints.size * 100);
|
|
2058
|
+
if (coveragePct < CROSS_ENDPOINT_PCT) continue;
|
|
2059
|
+
const info = getQueryInfo(queryMetric.first);
|
|
2060
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
2061
|
+
insights.push({
|
|
2062
|
+
severity: "warning",
|
|
2063
|
+
type: "cross-endpoint",
|
|
2064
|
+
title: "Repeated Query Across Endpoints",
|
|
2065
|
+
desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
|
|
2066
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
2067
|
+
nav: "queries"
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return insights;
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2049
2074
|
|
|
2050
2075
|
// src/analysis/insights/rules/security.ts
|
|
2051
2076
|
var securityRule = {
|
|
@@ -2120,18 +2145,17 @@ function securityFindingToIssue(finding) {
|
|
|
2120
2145
|
|
|
2121
2146
|
// src/analysis/engine.ts
|
|
2122
2147
|
var AnalysisEngine = class {
|
|
2123
|
-
constructor(
|
|
2124
|
-
this.
|
|
2148
|
+
constructor(services, debounceMs = ANALYSIS_DEBOUNCE_MS) {
|
|
2149
|
+
this.services = services;
|
|
2125
2150
|
this.debounceMs = debounceMs;
|
|
2151
|
+
this.cachedInsights = [];
|
|
2152
|
+
this.cachedFindings = [];
|
|
2153
|
+
this.debounceTimer = null;
|
|
2154
|
+
this.subs = new SubscriptionBag();
|
|
2126
2155
|
this.scanner = createDefaultScanner();
|
|
2127
2156
|
}
|
|
2128
|
-
scanner;
|
|
2129
|
-
cachedInsights = [];
|
|
2130
|
-
cachedFindings = [];
|
|
2131
|
-
debounceTimer = null;
|
|
2132
|
-
subs = new SubscriptionBag();
|
|
2133
2157
|
start() {
|
|
2134
|
-
const bus = this.
|
|
2158
|
+
const bus = this.services.bus;
|
|
2135
2159
|
this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
|
|
2136
2160
|
this.subs.add(bus.on("telemetry:query", () => this.scheduleRecompute()));
|
|
2137
2161
|
this.subs.add(bus.on("telemetry:error", () => this.scheduleRecompute()));
|
|
@@ -2158,12 +2182,12 @@ var AnalysisEngine = class {
|
|
|
2158
2182
|
}, this.debounceMs);
|
|
2159
2183
|
}
|
|
2160
2184
|
recompute() {
|
|
2161
|
-
const allRequests = this.
|
|
2162
|
-
const queries = this.
|
|
2163
|
-
const errors = this.
|
|
2164
|
-
const logs = this.
|
|
2165
|
-
const fetches = this.
|
|
2166
|
-
const requests =
|
|
2185
|
+
const allRequests = this.services.requestStore.getAll();
|
|
2186
|
+
const queries = this.services.queryStore.getAll();
|
|
2187
|
+
const errors = this.services.errorStore.getAll();
|
|
2188
|
+
const logs = this.services.logStore.getAll();
|
|
2189
|
+
const fetches = this.services.fetchStore.getAll();
|
|
2190
|
+
const requests = keepRecentPerEndpoint(allRequests);
|
|
2167
2191
|
const flows = groupRequestsIntoFlows(requests);
|
|
2168
2192
|
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
2169
2193
|
this.cachedInsights = computeInsights({
|
|
@@ -2172,38 +2196,34 @@ var AnalysisEngine = class {
|
|
|
2172
2196
|
errors,
|
|
2173
2197
|
flows,
|
|
2174
2198
|
fetches,
|
|
2175
|
-
previousMetrics: this.
|
|
2199
|
+
previousMetrics: this.services.metricsStore.getAll(),
|
|
2176
2200
|
securityFindings: this.cachedFindings
|
|
2177
2201
|
});
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
const
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
issues: issueStore.getAll()
|
|
2199
|
-
};
|
|
2200
|
-
this.registry.get("event-bus").emit("analysis:updated", update);
|
|
2201
|
-
}
|
|
2202
|
+
const issueStore = this.services.issueStore;
|
|
2203
|
+
const currentIssueIds = /* @__PURE__ */ new Set();
|
|
2204
|
+
for (const finding of this.cachedFindings) {
|
|
2205
|
+
const issue = securityFindingToIssue(finding);
|
|
2206
|
+
issueStore.upsert(issue, "passive");
|
|
2207
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2208
|
+
}
|
|
2209
|
+
for (const insight of this.cachedInsights) {
|
|
2210
|
+
const issue = insightToIssue(insight);
|
|
2211
|
+
issueStore.upsert(issue, "passive");
|
|
2212
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2213
|
+
}
|
|
2214
|
+
const activeEndpoints = extractActiveEndpoints(allRequests);
|
|
2215
|
+
issueStore.reconcile(currentIssueIds, activeEndpoints);
|
|
2216
|
+
const update = {
|
|
2217
|
+
insights: this.cachedInsights,
|
|
2218
|
+
findings: this.cachedFindings,
|
|
2219
|
+
issues: issueStore.getAll()
|
|
2220
|
+
};
|
|
2221
|
+
this.services.bus.emit("analysis:updated", update);
|
|
2202
2222
|
}
|
|
2203
2223
|
};
|
|
2204
2224
|
|
|
2205
2225
|
// src/index.ts
|
|
2206
|
-
var VERSION = "0.
|
|
2226
|
+
var VERSION = "9.0.0";
|
|
2207
2227
|
export {
|
|
2208
2228
|
AdapterRegistry,
|
|
2209
2229
|
AnalysisEngine,
|