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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
4
- data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
3
+ metadata.gz: a118e9ba5a79bc87f82ee566e0e2af65a7199fff6694561ad080f990fb7bf8cd
4
+ data.tar.gz: 44e2391b361cbcda4ea4a133fefb7db653cd2f0a48e72859216c368ce1867ded
5
5
  SHA512:
6
- metadata.gz: 1be16066652bfa11550b5d277d3370eec2457d08dfbb0ceab46ebafe12a484cc342c61892133b030ab4b6d7d1506bcbe53ff8118eaa9ee4ee759c36012a0058e
7
- data.tar.gz: a72957818f9d2a7dbdd914f57167ffd0bcfb6eef7df3dfb721a7a651f3acd89536d11cec4dbfa7c47f72f3331862b3279eb0312a7c66a0fbb47bb36e35c1640c
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 DatabaseTab({ dbData }) {
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
- dbData.queries.map((query, index) => /* @__PURE__ */ u3("div", { class: `profiler-query-card${query.slow ? " profiler-query-card--slow" : ""}`, children: [
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("span", { class: "profiler-text--muted", children: [
1296
- "#",
1297
- index + 1
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("span", { class: `profiler-query-card__duration ${query.slow ? "profiler-query-card__duration--slow" : "profiler-query-card__duration--fast"}`, children: [
1300
- query.duration.toFixed(2),
1301
- " ms"
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
@@ -32,5 +32,6 @@ Profiler::Engine.routes.draw do
32
32
  resources :outbound_http, only: [:index]
33
33
  get "toolbar/:token", to: "toolbar#show"
34
34
  post "ajax/link", to: "ajax#link"
35
+ post "explain", to: "explain#create"
35
36
  end
36
37
  end
@@ -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