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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a0b42cc0bcdfe940609dbb07f9ee29a23cd00495c738711950749b22e58d664
4
- data.tar.gz: b61678855bf48c082f4776773ce1754328c4e6b6d1672ea2542a836f79d9602a
3
+ metadata.gz: a118e9ba5a79bc87f82ee566e0e2af65a7199fff6694561ad080f990fb7bf8cd
4
+ data.tar.gz: 44e2391b361cbcda4ea4a133fefb7db653cd2f0a48e72859216c368ce1867ded
5
5
  SHA512:
6
- metadata.gz: bde96bed30d2bc7fda19f62a10a2a1b9d55e1e73b83bc91ae0bba63ae7fb58a0d66ca692cb3db7ca66d17c29cfc69d190123095d6cc8f428e33e4d3030733b70
7
- data.tar.gz: 310c1ddfc31ab16fddd783db87e24bbe10a5c48058f678215f389f66056ede8af928438f7a350fd4f9ab8828f49f6daf4cdffd4f41634c76b3f9d1f0a0840f0f
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
@@ -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
- query_counts = db_data["queries"].group_by { |q| normalize_sql(q["sql"]) }
18
- .transform_values(&:count)
17
+ query_groups = db_data["queries"].group_by { |q| normalize_sql(q["sql"]) }
19
18
 
20
- query_counts.each do |normalized_sql, count|
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: 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
- { sql: sql, total_occurrences: total, profiles: occurrences }
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
  {
@@ -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(&:count)
63
- .select { |_, count| count > 1 }
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} duplicate query patterns:\n"
87
+ lines << "Found #{query_counts.size} query pattern(s) repeated 3+ times:\n"
88
88
 
89
- query_counts.sort_by { |_, count| -count }.first(5).each do |sql, count|
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 << "```\n"
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 potential N+1 query problems detected\n"
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 << "- **Duplicate Patterns:** #{query_counts.size}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
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.6.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