rails-profiler 0.6.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/resources/n1_patterns.rb +7 -6
- data/lib/profiler/mcp/server.rb +13 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +14 -9
- data/lib/profiler/mcp/tools/explain_query.rb +50 -0
- data/lib/profiler/version.rb +1 -1
- metadata +4 -1
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
|
|
@@ -14,15 +14,15 @@ module Profiler
|
|
|
14
14
|
db_data = profile.collector_data("database")
|
|
15
15
|
next unless db_data && db_data["queries"]
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
.transform_values(&:count)
|
|
17
|
+
query_groups = db_data["queries"].group_by { |q| normalize_sql(q["sql"]) }
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
query_groups.each do |normalized_sql, queries|
|
|
21
20
|
pattern_map[normalized_sql] << {
|
|
22
21
|
token: profile.token,
|
|
23
22
|
path: profile.path,
|
|
24
|
-
count:
|
|
25
|
-
timestamp: profile.started_at&.iso8601
|
|
23
|
+
count: queries.size,
|
|
24
|
+
timestamp: profile.started_at&.iso8601,
|
|
25
|
+
backtrace: (queries.first["backtrace"] || []).first(3)
|
|
26
26
|
}
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -35,7 +35,8 @@ module Profiler
|
|
|
35
35
|
# Sort by total occurrence count descending
|
|
36
36
|
sorted = n1_patterns.map do |sql, occurrences|
|
|
37
37
|
total = occurrences.sum { |o| o[:count] }
|
|
38
|
-
|
|
38
|
+
sample_backtrace = occurrences.find { |o| o[:backtrace].any? }&.dig(:backtrace) || []
|
|
39
|
+
{ sql: sql, total_occurrences: total, backtrace: sample_backtrace, profiles: occurrences }
|
|
39
40
|
end.sort_by { |p| -p[:total_occurrences] }.first(20)
|
|
40
41
|
|
|
41
42
|
{
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Profiler
|
|
|
65
65
|
require_relative "tools/query_profiles"
|
|
66
66
|
require_relative "tools/get_profile_detail"
|
|
67
67
|
require_relative "tools/analyze_queries"
|
|
68
|
+
require_relative "tools/explain_query"
|
|
68
69
|
require_relative "tools/get_profile_ajax"
|
|
69
70
|
require_relative "tools/get_profile_dumps"
|
|
70
71
|
require_relative "tools/get_profile_http"
|
|
@@ -116,6 +117,18 @@ module Profiler
|
|
|
116
117
|
},
|
|
117
118
|
handler: Tools::AnalyzeQueries
|
|
118
119
|
),
|
|
120
|
+
define_tool(
|
|
121
|
+
name: "explain_query",
|
|
122
|
+
description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only available in development/test environments.",
|
|
123
|
+
input_schema: {
|
|
124
|
+
properties: {
|
|
125
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
126
|
+
query_index: { type: "integer", description: "Zero-based index of the query within the profile's database queries list (required)" }
|
|
127
|
+
},
|
|
128
|
+
required: ["token", "query_index"]
|
|
129
|
+
},
|
|
130
|
+
handler: Tools::ExplainQuery
|
|
131
|
+
),
|
|
119
132
|
define_tool(
|
|
120
133
|
name: "get_profile_ajax",
|
|
121
134
|
description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
|
|
@@ -59,8 +59,8 @@ module Profiler
|
|
|
59
59
|
slow_threshold = Profiler.configuration.slow_query_threshold
|
|
60
60
|
slow_queries = queries.select { |q| q["duration"] > slow_threshold }
|
|
61
61
|
query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
|
|
62
|
-
.transform_values
|
|
63
|
-
.select { |_,
|
|
62
|
+
.transform_values { |qs| { count: qs.size, backtrace: qs.first["backtrace"] || [] } }
|
|
63
|
+
.select { |_, v| v[:count] >= 3 }
|
|
64
64
|
|
|
65
65
|
unless summary_only
|
|
66
66
|
# Detect slow queries
|
|
@@ -81,22 +81,27 @@ module Profiler
|
|
|
81
81
|
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Detect duplicate queries (potential N+1)
|
|
84
|
+
# Detect duplicate queries (potential N+1, threshold: ≥ 3 occurrences)
|
|
85
85
|
if query_counts.any?
|
|
86
86
|
lines << "## ⚠️ Duplicate Queries (Potential N+1)"
|
|
87
|
-
lines << "Found #{query_counts.size}
|
|
87
|
+
lines << "Found #{query_counts.size} query pattern(s) repeated 3+ times:\n"
|
|
88
88
|
|
|
89
|
-
query_counts.sort_by { |_,
|
|
90
|
-
lines << "### Executed #{count} times:"
|
|
89
|
+
query_counts.sort_by { |_, v| -v[:count] }.first(5).each do |sql, v|
|
|
90
|
+
lines << "### Executed #{v[:count]} times:"
|
|
91
91
|
lines << "```sql"
|
|
92
92
|
lines << sql
|
|
93
|
-
lines << "
|
|
93
|
+
lines << "```"
|
|
94
|
+
if v[:backtrace].any?
|
|
95
|
+
lines << "#### Called from:"
|
|
96
|
+
v[:backtrace].first(3).each { |frame| lines << " #{frame}" }
|
|
97
|
+
end
|
|
98
|
+
lines << ""
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n" if query_counts.size > 5
|
|
97
102
|
else
|
|
98
103
|
lines << "## ✅ No Duplicate Queries"
|
|
99
|
-
lines << "No
|
|
104
|
+
lines << "No query pattern repeated 3+ times\n"
|
|
100
105
|
end
|
|
101
106
|
end
|
|
102
107
|
|
|
@@ -106,7 +111,7 @@ module Profiler
|
|
|
106
111
|
lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
|
|
107
112
|
lines << "- **Average Duration:** #{(queries.sum { |q| q['duration'] } / queries.size).round(2)}ms"
|
|
108
113
|
lines << "- **Slow Queries:** #{slow_queries.size}"
|
|
109
|
-
lines << "- **
|
|
114
|
+
lines << "- **N+1 Patterns (≥3×):** #{query_counts.size}"
|
|
110
115
|
lines << "- **Cached Queries:** #{queries.count { |q| q['cached'] }}"
|
|
111
116
|
|
|
112
117
|
lines.join("\n")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "profiler/explain_runner"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
class ExplainQuery
|
|
9
|
+
def self.call(params)
|
|
10
|
+
token = params["token"]
|
|
11
|
+
query_index = params["query_index"]
|
|
12
|
+
|
|
13
|
+
unless token
|
|
14
|
+
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if query_index.nil?
|
|
18
|
+
return [{ type: "text", text: "Error: query_index parameter is required" }]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless Profiler.configuration.enabled
|
|
22
|
+
return [{ type: "text", text: "Error: EXPLAIN is only available when the profiler is enabled" }]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
data = Profiler::ExplainRunner.run(token, query_index.to_i)
|
|
26
|
+
|
|
27
|
+
lines = []
|
|
28
|
+
lines << "# EXPLAIN ANALYZE — Query ##{query_index}"
|
|
29
|
+
lines << "Adapter: `#{data[:adapter]}`\n"
|
|
30
|
+
|
|
31
|
+
if data[:format] == "json"
|
|
32
|
+
lines << "```json"
|
|
33
|
+
lines << JSON.pretty_generate(data[:result])
|
|
34
|
+
lines << "```"
|
|
35
|
+
else
|
|
36
|
+
lines << "```"
|
|
37
|
+
lines << data[:result].to_s
|
|
38
|
+
lines << "```"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[{ type: "text", text: lines.join("\n") }]
|
|
42
|
+
rescue ArgumentError => e
|
|
43
|
+
[{ type: "text", text: "Error: #{e.message}" }]
|
|
44
|
+
rescue => e
|
|
45
|
+
[{ type: "text", text: "EXPLAIN failed: #{e.message}" }]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -107,6 +107,7 @@ files:
|
|
|
107
107
|
- app/assets/builds/profiler.css
|
|
108
108
|
- app/assets/builds/profiler.js
|
|
109
109
|
- app/controllers/profiler/api/ajax_controller.rb
|
|
110
|
+
- app/controllers/profiler/api/explain_controller.rb
|
|
110
111
|
- app/controllers/profiler/api/jobs_controller.rb
|
|
111
112
|
- app/controllers/profiler/api/outbound_http_controller.rb
|
|
112
113
|
- app/controllers/profiler/api/profiles_controller.rb
|
|
@@ -138,6 +139,7 @@ files:
|
|
|
138
139
|
- lib/profiler/collectors/view_collector.rb
|
|
139
140
|
- lib/profiler/configuration.rb
|
|
140
141
|
- lib/profiler/engine.rb
|
|
142
|
+
- lib/profiler/explain_runner.rb
|
|
141
143
|
- lib/profiler/instrumentation/active_job_instrumentation.rb
|
|
142
144
|
- lib/profiler/instrumentation/net_http_instrumentation.rb
|
|
143
145
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
@@ -152,6 +154,7 @@ files:
|
|
|
152
154
|
- lib/profiler/mcp/server.rb
|
|
153
155
|
- lib/profiler/mcp/tools/analyze_queries.rb
|
|
154
156
|
- lib/profiler/mcp/tools/clear_profiles.rb
|
|
157
|
+
- lib/profiler/mcp/tools/explain_query.rb
|
|
155
158
|
- lib/profiler/mcp/tools/get_profile_ajax.rb
|
|
156
159
|
- lib/profiler/mcp/tools/get_profile_detail.rb
|
|
157
160
|
- lib/profiler/mcp/tools/get_profile_dumps.rb
|