rails-profiler 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler.css +211 -0
- data/app/assets/builds/profiler.js +195 -10
- data/app/controllers/profiler/api/explain_controller.rb +31 -0
- data/config/routes.rb +1 -0
- data/lib/profiler/explain_runner.rb +82 -0
- data/lib/profiler/mcp/body_formatter.rb +50 -0
- data/lib/profiler/mcp/file_cache.rb +31 -0
- data/lib/profiler/mcp/path_extractor.rb +30 -0
- data/lib/profiler/mcp/resources/n1_patterns.rb +7 -6
- data/lib/profiler/mcp/server.rb +35 -5
- data/lib/profiler/mcp/tools/analyze_queries.rb +50 -48
- data/lib/profiler/mcp/tools/explain_query.rb +50 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +268 -208
- data/lib/profiler/mcp/tools/get_profile_http.rb +23 -16
- data/lib/profiler/mcp/tools/query_jobs.rb +37 -14
- data/lib/profiler/mcp/tools/query_profiles.rb +42 -27
- data/lib/profiler/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a118e9ba5a79bc87f82ee566e0e2af65a7199fff6694561ad080f990fb7bf8cd
|
|
4
|
+
data.tar.gz: 44e2391b361cbcda4ea4a133fefb7db653cd2f0a48e72859216c368ce1867ded
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c69ecc008b9b9c79161cdc18326c9b0c43e99b7a7284b2681980c0c2d2b7a94c10f77dc186b2826f2cf111f53063e10a3790c4245e4c8a4a07bdde7f49b02e7a
|
|
7
|
+
data.tar.gz: 22031e28d3fc173b52dab71ed8f1fe8f0faf350134ed683b2847d1ad3a4e44e5de438b6de76d83a3f2b554aa22175cee5b578af0aacc4a8b247f2fd32390d431
|
|
@@ -867,6 +867,217 @@ tr:hover .btn-row-delete {
|
|
|
867
867
|
line-height: 1.5;
|
|
868
868
|
}
|
|
869
869
|
|
|
870
|
+
.profiler-query-card--n1 {
|
|
871
|
+
border-left: 3px solid var(--profiler-warning);
|
|
872
|
+
background: var(--profiler-warning-bg);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.profiler-n1-group {
|
|
876
|
+
border: 1px solid var(--profiler-warning);
|
|
877
|
+
border-radius: var(--profiler-radius-md);
|
|
878
|
+
overflow: hidden;
|
|
879
|
+
}
|
|
880
|
+
.profiler-n1-group__header {
|
|
881
|
+
display: flex;
|
|
882
|
+
align-items: center;
|
|
883
|
+
gap: var(--profiler-space-3);
|
|
884
|
+
padding: var(--profiler-space-2) var(--profiler-space-3);
|
|
885
|
+
background: var(--profiler-warning-bg);
|
|
886
|
+
cursor: pointer;
|
|
887
|
+
user-select: none;
|
|
888
|
+
}
|
|
889
|
+
.profiler-n1-group__header:hover {
|
|
890
|
+
filter: brightness(0.97);
|
|
891
|
+
}
|
|
892
|
+
.profiler-n1-group__count {
|
|
893
|
+
font-size: var(--profiler-text-xs);
|
|
894
|
+
font-weight: 700;
|
|
895
|
+
font-family: var(--profiler-font-mono);
|
|
896
|
+
color: var(--profiler-warning);
|
|
897
|
+
white-space: nowrap;
|
|
898
|
+
}
|
|
899
|
+
.profiler-n1-group__pattern {
|
|
900
|
+
flex: 1;
|
|
901
|
+
font-size: var(--profiler-text-xs);
|
|
902
|
+
font-family: var(--profiler-font-mono);
|
|
903
|
+
color: var(--profiler-text);
|
|
904
|
+
overflow: hidden;
|
|
905
|
+
text-overflow: ellipsis;
|
|
906
|
+
white-space: nowrap;
|
|
907
|
+
}
|
|
908
|
+
.profiler-n1-group__toggle {
|
|
909
|
+
font-size: var(--profiler-text-xs);
|
|
910
|
+
color: var(--profiler-text-muted);
|
|
911
|
+
white-space: nowrap;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.profiler-n1-backtrace {
|
|
915
|
+
background: var(--profiler-bg-lighter);
|
|
916
|
+
border-top: 1px solid var(--profiler-border);
|
|
917
|
+
padding: var(--profiler-space-2) var(--profiler-space-3);
|
|
918
|
+
}
|
|
919
|
+
.profiler-n1-backtrace__frame {
|
|
920
|
+
font-size: var(--profiler-text-xs);
|
|
921
|
+
font-family: var(--profiler-font-mono);
|
|
922
|
+
color: var(--profiler-text-muted);
|
|
923
|
+
line-height: 1.8;
|
|
924
|
+
overflow: hidden;
|
|
925
|
+
text-overflow: ellipsis;
|
|
926
|
+
white-space: nowrap;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
.profiler-alert-banner {
|
|
930
|
+
display: flex;
|
|
931
|
+
align-items: flex-start;
|
|
932
|
+
gap: var(--profiler-space-3);
|
|
933
|
+
padding: var(--profiler-space-3) var(--profiler-space-4);
|
|
934
|
+
border-radius: var(--profiler-radius-md);
|
|
935
|
+
border: 1px solid;
|
|
936
|
+
font-size: var(--profiler-text-sm);
|
|
937
|
+
}
|
|
938
|
+
.profiler-alert-banner--warning {
|
|
939
|
+
background: var(--profiler-warning-bg);
|
|
940
|
+
border-color: var(--profiler-warning);
|
|
941
|
+
color: var(--profiler-text);
|
|
942
|
+
}
|
|
943
|
+
.profiler-alert-banner--error {
|
|
944
|
+
background: var(--profiler-error-bg);
|
|
945
|
+
border-color: var(--profiler-error);
|
|
946
|
+
}
|
|
947
|
+
.profiler-alert-banner__icon {
|
|
948
|
+
flex-shrink: 0;
|
|
949
|
+
font-size: var(--profiler-text-base);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.profiler-badge {
|
|
953
|
+
display: inline-block;
|
|
954
|
+
padding: 1px 6px;
|
|
955
|
+
border-radius: var(--profiler-radius-sm);
|
|
956
|
+
font-size: var(--profiler-text-xs);
|
|
957
|
+
font-weight: 600;
|
|
958
|
+
font-family: var(--profiler-font-mono);
|
|
959
|
+
line-height: 1.6;
|
|
960
|
+
}
|
|
961
|
+
.profiler-badge--warning {
|
|
962
|
+
background: var(--profiler-warning-bg);
|
|
963
|
+
color: var(--profiler-warning);
|
|
964
|
+
border: 1px solid rgba(251, 146, 60, 0.3);
|
|
965
|
+
}
|
|
966
|
+
.profiler-badge--error {
|
|
967
|
+
background: var(--profiler-error-bg);
|
|
968
|
+
color: var(--profiler-error);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.profiler-btn {
|
|
972
|
+
display: inline-flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
gap: var(--profiler-space-1);
|
|
975
|
+
border: 1px solid var(--profiler-border);
|
|
976
|
+
border-radius: var(--profiler-radius-sm);
|
|
977
|
+
background: var(--profiler-bg-elevated);
|
|
978
|
+
color: var(--profiler-text-muted);
|
|
979
|
+
font-size: var(--profiler-text-sm);
|
|
980
|
+
font-family: var(--profiler-font-mono);
|
|
981
|
+
cursor: pointer;
|
|
982
|
+
transition: all var(--profiler-transition-base);
|
|
983
|
+
}
|
|
984
|
+
.profiler-btn:hover {
|
|
985
|
+
border-color: var(--profiler-accent);
|
|
986
|
+
color: var(--profiler-accent);
|
|
987
|
+
}
|
|
988
|
+
.profiler-btn--sm {
|
|
989
|
+
padding: 1px 8px;
|
|
990
|
+
font-size: var(--profiler-text-xs);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.profiler-modal__overlay {
|
|
994
|
+
position: fixed;
|
|
995
|
+
inset: 0;
|
|
996
|
+
background: rgba(0, 0, 0, 0.6);
|
|
997
|
+
z-index: 9000;
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
justify-content: center;
|
|
1001
|
+
padding: var(--profiler-space-4);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.profiler-modal {
|
|
1005
|
+
background: var(--profiler-bg-elevated);
|
|
1006
|
+
border: 1px solid var(--profiler-border);
|
|
1007
|
+
border-radius: var(--profiler-radius-lg, var(--profiler-radius-md));
|
|
1008
|
+
box-shadow: var(--profiler-shadow-lg, var(--profiler-shadow-sm));
|
|
1009
|
+
width: 100%;
|
|
1010
|
+
max-width: 760px;
|
|
1011
|
+
max-height: 80vh;
|
|
1012
|
+
display: flex;
|
|
1013
|
+
flex-direction: column;
|
|
1014
|
+
overflow: hidden;
|
|
1015
|
+
}
|
|
1016
|
+
.profiler-modal__header {
|
|
1017
|
+
display: flex;
|
|
1018
|
+
align-items: center;
|
|
1019
|
+
gap: var(--profiler-space-3);
|
|
1020
|
+
padding: var(--profiler-space-3) var(--profiler-space-4);
|
|
1021
|
+
border-bottom: 1px solid var(--profiler-border);
|
|
1022
|
+
background: var(--profiler-bg-light);
|
|
1023
|
+
flex-shrink: 0;
|
|
1024
|
+
}
|
|
1025
|
+
.profiler-modal__title {
|
|
1026
|
+
font-weight: 600;
|
|
1027
|
+
font-family: var(--profiler-font-mono);
|
|
1028
|
+
font-size: var(--profiler-text-sm);
|
|
1029
|
+
flex: 1;
|
|
1030
|
+
}
|
|
1031
|
+
.profiler-modal__close {
|
|
1032
|
+
background: none;
|
|
1033
|
+
border: none;
|
|
1034
|
+
color: var(--profiler-text-muted);
|
|
1035
|
+
font-size: var(--profiler-text-lg);
|
|
1036
|
+
cursor: pointer;
|
|
1037
|
+
padding: 0 var(--profiler-space-1);
|
|
1038
|
+
line-height: 1;
|
|
1039
|
+
}
|
|
1040
|
+
.profiler-modal__close:hover {
|
|
1041
|
+
color: var(--profiler-text);
|
|
1042
|
+
}
|
|
1043
|
+
.profiler-modal__body {
|
|
1044
|
+
padding: var(--profiler-space-4);
|
|
1045
|
+
overflow-y: auto;
|
|
1046
|
+
flex: 1;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.profiler-explain-result {
|
|
1050
|
+
font-family: var(--profiler-font-mono);
|
|
1051
|
+
font-size: var(--profiler-text-xs);
|
|
1052
|
+
color: var(--profiler-text);
|
|
1053
|
+
white-space: pre-wrap;
|
|
1054
|
+
word-break: break-all;
|
|
1055
|
+
line-height: 1.6;
|
|
1056
|
+
margin: 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.profiler-explain-node {
|
|
1060
|
+
padding: var(--profiler-space-1) var(--profiler-space-2);
|
|
1061
|
+
margin-bottom: 2px;
|
|
1062
|
+
border-radius: var(--profiler-radius-sm);
|
|
1063
|
+
font-family: var(--profiler-font-mono);
|
|
1064
|
+
font-size: var(--profiler-text-xs);
|
|
1065
|
+
}
|
|
1066
|
+
.profiler-explain-node--expensive {
|
|
1067
|
+
background: var(--profiler-error-bg);
|
|
1068
|
+
border-left: 2px solid var(--profiler-error);
|
|
1069
|
+
}
|
|
1070
|
+
.profiler-explain-node__type {
|
|
1071
|
+
font-weight: 600;
|
|
1072
|
+
color: var(--profiler-accent);
|
|
1073
|
+
}
|
|
1074
|
+
.profiler-explain-node__stats {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
gap: var(--profiler-space-3);
|
|
1077
|
+
margin-top: 1px;
|
|
1078
|
+
flex-wrap: wrap;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
870
1081
|
.profiler-dump-card {
|
|
871
1082
|
background: var(--profiler-bg-light);
|
|
872
1083
|
border: 1px solid var(--profiler-border);
|
|
@@ -1263,10 +1263,154 @@
|
|
|
1263
1263
|
}
|
|
1264
1264
|
|
|
1265
1265
|
// app/assets/typescript/profiler/components/dashboard/tabs/DatabaseTab.tsx
|
|
1266
|
-
function
|
|
1266
|
+
function normalizeSql(sql) {
|
|
1267
|
+
return sql.replace(/\$\d+/g, "?").replace(/\b\d+\b/g, "?").replace(/'[^']*'/g, "?").replace(/"[^"]*"/g, "?").trim();
|
|
1268
|
+
}
|
|
1269
|
+
function computeN1Groups(queries) {
|
|
1270
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1271
|
+
queries.forEach((query, index) => {
|
|
1272
|
+
if (query.cached || query.transaction) return;
|
|
1273
|
+
const pattern = normalizeSql(query.sql);
|
|
1274
|
+
if (!groups.has(pattern)) {
|
|
1275
|
+
groups.set(pattern, { indices: [], backtrace: query.backtrace ?? [] });
|
|
1276
|
+
}
|
|
1277
|
+
groups.get(pattern).indices.push(index);
|
|
1278
|
+
});
|
|
1279
|
+
const result = [];
|
|
1280
|
+
groups.forEach(({ indices, backtrace }, pattern) => {
|
|
1281
|
+
if (indices.length >= 3) {
|
|
1282
|
+
result.push({ pattern, indices, backtrace });
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
return result.sort((a3, b) => b.indices.length - a3.indices.length);
|
|
1286
|
+
}
|
|
1287
|
+
function renderPlanNode(node, depth = 0) {
|
|
1288
|
+
const type = node["Node Type"] ?? "";
|
|
1289
|
+
const actualRows = node["Actual Rows"] ?? 0;
|
|
1290
|
+
const planRows = node["Plan Rows"] ?? 1;
|
|
1291
|
+
const isExpensive = type.includes("Seq Scan") || type.includes("Hash Join") || type.includes("Nested Loop") || type.includes("Filter") || actualRows > 0 && planRows > 0 && actualRows > planRows * 10;
|
|
1292
|
+
const children = node["Plans"] ?? [];
|
|
1293
|
+
return /* @__PURE__ */ u3("div", { class: `profiler-explain-node${isExpensive ? " profiler-explain-node--expensive" : ""}`, style: `margin-left:${depth * 14}px`, children: [
|
|
1294
|
+
/* @__PURE__ */ u3("span", { class: "profiler-explain-node__type", children: type }),
|
|
1295
|
+
node["Relation Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1296
|
+
" on ",
|
|
1297
|
+
node["Relation Name"]
|
|
1298
|
+
] }),
|
|
1299
|
+
node["Index Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1300
|
+
" using ",
|
|
1301
|
+
node["Index Name"]
|
|
1302
|
+
] }),
|
|
1303
|
+
/* @__PURE__ */ u3("div", { class: "profiler-explain-node__stats profiler-text--xs profiler-text--muted", children: [
|
|
1304
|
+
node["Actual Total Time"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1305
|
+
"time: ",
|
|
1306
|
+
node["Actual Total Time"].toFixed(3),
|
|
1307
|
+
"ms"
|
|
1308
|
+
] }),
|
|
1309
|
+
node["Actual Rows"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1310
|
+
"rows: ",
|
|
1311
|
+
actualRows,
|
|
1312
|
+
" (est: ",
|
|
1313
|
+
planRows,
|
|
1314
|
+
")"
|
|
1315
|
+
] }),
|
|
1316
|
+
node["Total Cost"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1317
|
+
"cost: ",
|
|
1318
|
+
node["Total Cost"].toFixed(2)
|
|
1319
|
+
] }),
|
|
1320
|
+
node["Filter"] && /* @__PURE__ */ u3("span", { class: "profiler-text--warning", children: [
|
|
1321
|
+
"filter: ",
|
|
1322
|
+
node["Filter"]
|
|
1323
|
+
] })
|
|
1324
|
+
] }),
|
|
1325
|
+
children.map((child, i3) => renderPlanNode(child, depth + 1))
|
|
1326
|
+
] });
|
|
1327
|
+
}
|
|
1328
|
+
function ExplainModal({ state, onClose }) {
|
|
1329
|
+
if (!state.open) return null;
|
|
1330
|
+
const renderResult = () => {
|
|
1331
|
+
if (state.loading) return /* @__PURE__ */ u3("div", { class: "profiler-text--muted", children: "Running EXPLAIN ANALYZE\u2026" });
|
|
1332
|
+
if (state.error) return /* @__PURE__ */ u3("div", { class: "profiler-text--error", children: state.error });
|
|
1333
|
+
if (!state.result) return null;
|
|
1334
|
+
if (state.format === "json") {
|
|
1335
|
+
try {
|
|
1336
|
+
const parsed = typeof state.result === "string" ? JSON.parse(state.result) : state.result;
|
|
1337
|
+
const plans = Array.isArray(parsed) ? parsed : [parsed];
|
|
1338
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-explain-result", children: plans.map((entry, i3) => /* @__PURE__ */ u3("div", { children: [
|
|
1339
|
+
renderPlanNode(entry["Plan"] ?? entry),
|
|
1340
|
+
entry["Planning Time"] != null && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
|
|
1341
|
+
"Planning: ",
|
|
1342
|
+
entry["Planning Time"].toFixed(3),
|
|
1343
|
+
"ms",
|
|
1344
|
+
entry["Execution Time"] != null && /* @__PURE__ */ u3(k, { children: [
|
|
1345
|
+
" \xB7 Execution: ",
|
|
1346
|
+
entry["Execution Time"].toFixed(3),
|
|
1347
|
+
"ms"
|
|
1348
|
+
] })
|
|
1349
|
+
] })
|
|
1350
|
+
] }, i3)) });
|
|
1351
|
+
} catch {
|
|
1352
|
+
return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
|
|
1356
|
+
};
|
|
1357
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-modal__overlay", onClick: onClose, children: /* @__PURE__ */ u3("div", { class: "profiler-modal", onClick: (e3) => e3.stopPropagation(), children: [
|
|
1358
|
+
/* @__PURE__ */ u3("div", { class: "profiler-modal__header", children: [
|
|
1359
|
+
/* @__PURE__ */ u3("span", { class: "profiler-modal__title", children: "EXPLAIN ANALYZE" }),
|
|
1360
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: state.adapter }),
|
|
1361
|
+
/* @__PURE__ */ u3("button", { class: "profiler-modal__close", onClick: onClose, children: "\xD7" })
|
|
1362
|
+
] }),
|
|
1363
|
+
/* @__PURE__ */ u3("div", { class: "profiler-modal__body", children: renderResult() })
|
|
1364
|
+
] }) });
|
|
1365
|
+
}
|
|
1366
|
+
function DatabaseTab({ dbData, token }) {
|
|
1367
|
+
const [openBacktraces, setOpenBacktraces] = d2(/* @__PURE__ */ new Set());
|
|
1368
|
+
const [explainState, setExplainState] = d2({
|
|
1369
|
+
open: false,
|
|
1370
|
+
loading: false,
|
|
1371
|
+
result: null,
|
|
1372
|
+
format: "text",
|
|
1373
|
+
adapter: "",
|
|
1374
|
+
error: null
|
|
1375
|
+
});
|
|
1267
1376
|
if (!dbData?.queries) {
|
|
1268
1377
|
return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No database queries recorded" }) });
|
|
1269
1378
|
}
|
|
1379
|
+
const n1Groups = computeN1Groups(dbData.queries);
|
|
1380
|
+
const n1IndexSet = new Set(n1Groups.flatMap((g2) => g2.indices));
|
|
1381
|
+
const toggleBacktrace = (pattern) => {
|
|
1382
|
+
setOpenBacktraces((prev) => {
|
|
1383
|
+
const next = new Set(prev);
|
|
1384
|
+
next.has(pattern) ? next.delete(pattern) : next.add(pattern);
|
|
1385
|
+
return next;
|
|
1386
|
+
});
|
|
1387
|
+
};
|
|
1388
|
+
const runExplain = async (queryIndex) => {
|
|
1389
|
+
setExplainState({ open: true, loading: true, result: null, format: "text", adapter: "", error: null });
|
|
1390
|
+
try {
|
|
1391
|
+
const res = await fetch("/_profiler/api/explain", {
|
|
1392
|
+
method: "POST",
|
|
1393
|
+
headers: { "Content-Type": "application/json" },
|
|
1394
|
+
body: JSON.stringify({ token, query_index: queryIndex })
|
|
1395
|
+
});
|
|
1396
|
+
if (!res.ok) {
|
|
1397
|
+
const body = await res.json().catch(() => ({}));
|
|
1398
|
+
setExplainState((s3) => ({ ...s3, loading: false, error: body.error ?? `HTTP ${res.status}` }));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
const data = await res.json();
|
|
1402
|
+
setExplainState((s3) => ({
|
|
1403
|
+
...s3,
|
|
1404
|
+
loading: false,
|
|
1405
|
+
result: data.result,
|
|
1406
|
+
format: data.format ?? "text",
|
|
1407
|
+
adapter: data.adapter ?? ""
|
|
1408
|
+
}));
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
setExplainState((s3) => ({ ...s3, loading: false, error: err.message ?? "Request failed" }));
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
const closeExplain = () => setExplainState((s3) => ({ ...s3, open: false }));
|
|
1270
1414
|
return /* @__PURE__ */ u3(k, { children: [
|
|
1271
1415
|
/* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
|
|
1272
1416
|
"Database Queries (",
|
|
@@ -1290,19 +1434,60 @@
|
|
|
1290
1434
|
/* @__PURE__ */ u3("strong", { children: dbData.cached_queries })
|
|
1291
1435
|
] })
|
|
1292
1436
|
] }),
|
|
1293
|
-
|
|
1437
|
+
n1Groups.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
|
|
1438
|
+
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
1439
|
+
/* @__PURE__ */ u3("div", { children: [
|
|
1440
|
+
/* @__PURE__ */ u3("strong", { children: "Potential N+1 detected" }),
|
|
1441
|
+
" \u2014 ",
|
|
1442
|
+
n1Groups.length,
|
|
1443
|
+
" pattern",
|
|
1444
|
+
n1Groups.length > 1 ? "s" : "",
|
|
1445
|
+
" repeated ",
|
|
1446
|
+
n1Groups.reduce((sum, g2) => sum + g2.indices.length, 0),
|
|
1447
|
+
" times total",
|
|
1448
|
+
/* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-1", children: "Queries causing N+1 are highlighted below. Expand each group to see the call stack." })
|
|
1449
|
+
] })
|
|
1450
|
+
] }),
|
|
1451
|
+
n1Groups.map((group) => /* @__PURE__ */ u3("div", { class: "profiler-n1-group profiler-mb-4", children: [
|
|
1452
|
+
/* @__PURE__ */ u3("div", { class: "profiler-n1-group__header", onClick: () => toggleBacktrace(group.pattern), children: [
|
|
1453
|
+
/* @__PURE__ */ u3("span", { class: "profiler-n1-group__count", children: [
|
|
1454
|
+
"N+1 \xB7 ",
|
|
1455
|
+
group.indices.length,
|
|
1456
|
+
"\xD7"
|
|
1457
|
+
] }),
|
|
1458
|
+
/* @__PURE__ */ u3("code", { class: "profiler-n1-group__pattern", children: group.pattern }),
|
|
1459
|
+
/* @__PURE__ */ u3("span", { class: "profiler-n1-group__toggle", children: [
|
|
1460
|
+
openBacktraces.has(group.pattern) ? "\u25B2" : "\u25BC",
|
|
1461
|
+
" backtrace"
|
|
1462
|
+
] })
|
|
1463
|
+
] }),
|
|
1464
|
+
openBacktraces.has(group.pattern) && group.backtrace.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-n1-backtrace", children: group.backtrace.map((frame, i3) => /* @__PURE__ */ u3("div", { class: "profiler-n1-backtrace__frame", children: frame }, i3)) })
|
|
1465
|
+
] }, group.pattern)),
|
|
1466
|
+
dbData.queries.map((query, index) => /* @__PURE__ */ u3("div", { class: [
|
|
1467
|
+
"profiler-query-card",
|
|
1468
|
+
query.slow ? "profiler-query-card--slow" : "",
|
|
1469
|
+
n1IndexSet.has(index) ? "profiler-query-card--n1" : ""
|
|
1470
|
+
].filter(Boolean).join(" "), children: [
|
|
1294
1471
|
/* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
|
|
1295
|
-
/* @__PURE__ */ u3("
|
|
1296
|
-
"
|
|
1297
|
-
|
|
1472
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
|
|
1473
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1474
|
+
"#",
|
|
1475
|
+
index + 1
|
|
1476
|
+
] }),
|
|
1477
|
+
n1IndexSet.has(index) && /* @__PURE__ */ u3("span", { class: "profiler-badge profiler-badge--warning", children: "N+1" }),
|
|
1478
|
+
query.name && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: query.name })
|
|
1298
1479
|
] }),
|
|
1299
|
-
/* @__PURE__ */ u3("
|
|
1300
|
-
query.
|
|
1301
|
-
|
|
1480
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
|
|
1481
|
+
/* @__PURE__ */ u3("span", { class: `profiler-query-card__duration ${query.slow ? "profiler-query-card__duration--slow" : "profiler-query-card__duration--fast"}`, children: [
|
|
1482
|
+
query.duration.toFixed(2),
|
|
1483
|
+
" ms"
|
|
1484
|
+
] }),
|
|
1485
|
+
!query.cached && !query.transaction && /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", onClick: () => runExplain(index), children: "Explain" })
|
|
1302
1486
|
] })
|
|
1303
1487
|
] }),
|
|
1304
1488
|
/* @__PURE__ */ u3("code", { class: "profiler-query-card__code", children: query.sql })
|
|
1305
|
-
] }, index))
|
|
1489
|
+
] }, index)),
|
|
1490
|
+
/* @__PURE__ */ u3(ExplainModal, { state: explainState, onClose: closeExplain })
|
|
1306
1491
|
] });
|
|
1307
1492
|
}
|
|
1308
1493
|
|
|
@@ -2545,7 +2730,7 @@
|
|
|
2545
2730
|
activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
|
|
2546
2731
|
activeTab === "request" && /* @__PURE__ */ u3(RequestTab, { profile }),
|
|
2547
2732
|
activeTab === "dump" && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
|
|
2548
|
-
activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"] }),
|
|
2733
|
+
activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
|
|
2549
2734
|
activeTab === "ajax" && /* @__PURE__ */ u3(AjaxTab, { ajaxData: cd["ajax"] }),
|
|
2550
2735
|
activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
|
|
2551
2736
|
activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"] }),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "profiler/explain_runner"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Api
|
|
7
|
+
class ExplainController < ApplicationController
|
|
8
|
+
skip_before_action :verify_authenticity_token
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
unless Profiler.configuration.enabled
|
|
12
|
+
return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
token = params[:token].to_s
|
|
16
|
+
query_index = params[:query_index].to_i
|
|
17
|
+
|
|
18
|
+
if token.blank?
|
|
19
|
+
return render json: { error: "token is required" }, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result = Profiler::ExplainRunner.run(token, query_index)
|
|
23
|
+
render json: result
|
|
24
|
+
rescue ArgumentError => e
|
|
25
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
26
|
+
rescue => e
|
|
27
|
+
render json: { error: e.message }, status: :internal_server_error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
# Shared service for running EXPLAIN ANALYZE on a stored query.
|
|
5
|
+
# Used by both the HTTP API controller and the MCP explain_query tool.
|
|
6
|
+
module ExplainRunner
|
|
7
|
+
# @param profile_token [String]
|
|
8
|
+
# @param query_index [Integer]
|
|
9
|
+
# @return [Hash] { result:, format: "json"|"text", adapter: String }
|
|
10
|
+
# or raises ArgumentError / RuntimeError
|
|
11
|
+
def self.run(profile_token, query_index)
|
|
12
|
+
unless Profiler.configuration.enabled
|
|
13
|
+
raise SecurityError, "EXPLAIN is only available when the profiler is enabled"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
profile = Profiler.storage.load(profile_token)
|
|
17
|
+
raise ArgumentError, "Profile not found: #{profile_token}" unless profile
|
|
18
|
+
|
|
19
|
+
db_data = profile.collector_data("database")
|
|
20
|
+
raise ArgumentError, "No database data in this profile" unless db_data && db_data["queries"]
|
|
21
|
+
|
|
22
|
+
queries = db_data["queries"]
|
|
23
|
+
query_index = query_index.to_i
|
|
24
|
+
unless query_index >= 0 && query_index < queries.size
|
|
25
|
+
raise ArgumentError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
query = queries[query_index]
|
|
29
|
+
sql = query["sql"].to_s
|
|
30
|
+
binds = Array(query["binds"])
|
|
31
|
+
|
|
32
|
+
conn = ActiveRecord::Base.connection
|
|
33
|
+
adapter = conn.adapter_name.downcase
|
|
34
|
+
full_sql = reconstruct_sql(sql, binds, conn, adapter)
|
|
35
|
+
|
|
36
|
+
explain_sql, format = build_explain_statement(full_sql, adapter)
|
|
37
|
+
|
|
38
|
+
rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
|
|
39
|
+
|
|
40
|
+
result = if format == "json"
|
|
41
|
+
# PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
|
|
42
|
+
raw = rows.first&.values&.first.to_s
|
|
43
|
+
JSON.parse(raw) rescue raw
|
|
44
|
+
else
|
|
45
|
+
rows.map { |r| r.values.join("\t") }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ result: result, format: format, adapter: adapter }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def self.reconstruct_sql(sql, binds, conn, adapter)
|
|
54
|
+
return sql if binds.empty?
|
|
55
|
+
|
|
56
|
+
if adapter.include?("postgresql")
|
|
57
|
+
result = sql.dup
|
|
58
|
+
binds.each_with_index do |value, i|
|
|
59
|
+
result = result.gsub("$#{i + 1}", conn.quote(value))
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
else
|
|
63
|
+
# MySQL / SQLite: replace ? sequentially
|
|
64
|
+
result = sql.dup
|
|
65
|
+
binds.each do |value|
|
|
66
|
+
result = result.sub("?", conn.quote(value))
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.build_explain_statement(sql, adapter)
|
|
73
|
+
if adapter.include?("postgresql")
|
|
74
|
+
["EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{sql}", "json"]
|
|
75
|
+
elsif adapter.include?("mysql")
|
|
76
|
+
["EXPLAIN FORMAT=JSON #{sql}", "json"]
|
|
77
|
+
else
|
|
78
|
+
["EXPLAIN QUERY PLAN #{sql}", "text"]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module BodyFormatter
|
|
6
|
+
# Formats a body string for MCP output.
|
|
7
|
+
#
|
|
8
|
+
# params keys used:
|
|
9
|
+
# "save_bodies" (boolean) — save to /tmp/rails-profiler/{token}/{name} and return path
|
|
10
|
+
# "max_body_size" (number) — truncate inline body at N chars
|
|
11
|
+
# "json_path" (string) — JSONPath to extract from body when save_bodies is true
|
|
12
|
+
# "xml_path" (string) — XPath to extract from body when save_bodies is true
|
|
13
|
+
#
|
|
14
|
+
# Returns a formatted string ready to embed in markdown, or nil if body is blank.
|
|
15
|
+
def self.format_body(token, name, body, encoding, params)
|
|
16
|
+
return " *(binary, base64 encoded)*" if encoding == "base64"
|
|
17
|
+
return nil if body.nil? || body.empty?
|
|
18
|
+
|
|
19
|
+
if params["save_bodies"]
|
|
20
|
+
path = FileCache.save(token, name, body)
|
|
21
|
+
if path
|
|
22
|
+
result = " *(saved → `#{path}`)*"
|
|
23
|
+
if params["json_path"]
|
|
24
|
+
extracted = PathExtractor.extract_json(body, params["json_path"])
|
|
25
|
+
result += "\n `#{params['json_path']}` → `#{extracted}`"
|
|
26
|
+
elsif params["xml_path"]
|
|
27
|
+
extracted = PathExtractor.extract_xml(body, params["xml_path"])
|
|
28
|
+
result += "\n `#{params['xml_path']}` → `#{extracted}`"
|
|
29
|
+
end
|
|
30
|
+
result
|
|
31
|
+
else
|
|
32
|
+
truncate_body(body, params["max_body_size"])
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
truncate_body(body, params["max_body_size"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.truncate_body(body, max_size)
|
|
40
|
+
max = max_size&.to_i
|
|
41
|
+
content = if max && body.length > max
|
|
42
|
+
"#{body[0, max]}\n... [truncated, #{body.length} chars total]"
|
|
43
|
+
else
|
|
44
|
+
body
|
|
45
|
+
end
|
|
46
|
+
"```\n#{content}\n```"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
class FileCache
|
|
8
|
+
BASE_DIR = "/tmp/rails-profiler"
|
|
9
|
+
|
|
10
|
+
def self.save(token, name, content)
|
|
11
|
+
cleanup if rand < 0.05
|
|
12
|
+
|
|
13
|
+
dir = File.join(BASE_DIR, token)
|
|
14
|
+
FileUtils.mkdir_p(dir)
|
|
15
|
+
path = File.join(dir, name)
|
|
16
|
+
File.write(path, content)
|
|
17
|
+
path
|
|
18
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.cleanup(max_age: 3600)
|
|
23
|
+
return unless Dir.exist?(BASE_DIR)
|
|
24
|
+
|
|
25
|
+
Dir.glob(File.join(BASE_DIR, "*")).each do |dir|
|
|
26
|
+
FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|