rails-profiler 0.6.0 → 0.8.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: 6c1f16fad7d5792dda0491401519802bd402be3b6c952b898cc0930e675a0d49
4
+ data.tar.gz: 6ea6e3ca98b297f89b32da94a56cf73ff38788e8d31a9570dd58c072bfb58930
5
5
  SHA512:
6
- metadata.gz: bde96bed30d2bc7fda19f62a10a2a1b9d55e1e73b83bc91ae0bba63ae7fb58a0d66ca692cb3db7ca66d17c29cfc69d190123095d6cc8f428e33e4d3030733b70
7
- data.tar.gz: 310c1ddfc31ab16fddd783db87e24bbe10a5c48058f678215f389f66056ede8af928438f7a350fd4f9ab8828f49f6daf4cdffd4f41634c76b3f9d1f0a0840f0f
6
+ metadata.gz: 3e487bc2fc57bd9a1dee6eabdc45db629dd752e0df2ce95e74fc31701583ab5d8de72fa57b7b19dc81d6c412c6bec2b4a814768af84ab80da5af6c61b3fb063a
7
+ data.tar.gz: 0e3572af0b7f7bca2d1bc132da45173ad9dd2f29010d5685a987d247a89c114d5c76846781a5383aa7bee2fdef038059aa66ac8c9e8bf7ed1245c8a6eaf50354
@@ -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);
@@ -1685,6 +1896,61 @@ a.profiler-toolbar-item.profiler-text--warning::after {
1685
1896
  cursor: pointer;
1686
1897
  }
1687
1898
 
1899
+ .profiler-preset-btn {
1900
+ height: 28px;
1901
+ padding: 0 10px;
1902
+ background: transparent;
1903
+ border: 1px solid var(--profiler-border);
1904
+ border-radius: var(--profiler-radius-full);
1905
+ color: var(--profiler-text-muted);
1906
+ font-family: var(--profiler-font-sans);
1907
+ font-size: var(--profiler-text-xs);
1908
+ font-weight: 500;
1909
+ cursor: pointer;
1910
+ transition: all var(--profiler-transition-base);
1911
+ white-space: nowrap;
1912
+ }
1913
+ .profiler-preset-btn:hover {
1914
+ border-color: var(--profiler-accent);
1915
+ color: var(--profiler-accent);
1916
+ background: var(--profiler-accent-bg);
1917
+ }
1918
+ .profiler-preset-btn--active {
1919
+ border-color: var(--profiler-accent);
1920
+ background: var(--profiler-accent);
1921
+ color: var(--profiler-text-on-accent, #fff);
1922
+ }
1923
+ .profiler-preset-btn--active:hover {
1924
+ opacity: 0.85;
1925
+ color: var(--profiler-text-on-accent, #fff);
1926
+ }
1927
+
1928
+ .sortable {
1929
+ cursor: pointer;
1930
+ user-select: none;
1931
+ white-space: nowrap;
1932
+ transition: color var(--profiler-transition-base);
1933
+ }
1934
+ .sortable:hover {
1935
+ color: var(--profiler-text) !important;
1936
+ }
1937
+ .sortable--active {
1938
+ color: var(--profiler-text) !important;
1939
+ }
1940
+
1941
+ .sort-icon {
1942
+ font-size: 10px;
1943
+ margin-left: 4px;
1944
+ vertical-align: middle;
1945
+ }
1946
+ .sort-icon--idle {
1947
+ opacity: 0.35;
1948
+ }
1949
+ .sort-icon--active {
1950
+ color: var(--profiler-accent);
1951
+ opacity: 1;
1952
+ }
1953
+
1688
1954
  .profiler-timeline {
1689
1955
  margin: 24px 0;
1690
1956
  padding: 24px;
@@ -2110,6 +2376,33 @@ a.profiler-toolbar-item.profiler-text--warning::after {
2110
2376
  color: var(--profiler-text-subtle);
2111
2377
  font-family: var(--profiler-font-sans);
2112
2378
  }
2379
+ .profiler-flamegraph__search {
2380
+ display: flex;
2381
+ align-items: center;
2382
+ gap: 8px;
2383
+ }
2384
+ .profiler-flamegraph__search-input {
2385
+ height: 28px;
2386
+ padding: 0 8px;
2387
+ background: var(--profiler-bg-lighter);
2388
+ border: 1px solid var(--profiler-border);
2389
+ border-radius: var(--profiler-radius-sm);
2390
+ color: var(--profiler-text);
2391
+ font-family: var(--profiler-font-mono);
2392
+ font-size: var(--profiler-text-xs);
2393
+ width: 200px;
2394
+ transition: border-color var(--profiler-transition-base);
2395
+ }
2396
+ .profiler-flamegraph__search-input:focus {
2397
+ outline: none;
2398
+ border-color: var(--profiler-accent);
2399
+ }
2400
+ .profiler-flamegraph__match-count {
2401
+ font-size: var(--profiler-text-xs);
2402
+ font-family: var(--profiler-font-mono);
2403
+ color: var(--profiler-text-muted);
2404
+ white-space: nowrap;
2405
+ }
2113
2406
  .profiler-flamegraph__canvas-container {
2114
2407
  position: relative;
2115
2408
  border: 1px solid var(--profiler-border);
@@ -718,11 +718,26 @@
718
718
  if (!bytes) return "-";
719
719
  return (bytes / 1024 / 1024).toFixed(2) + " MB";
720
720
  }
721
+ var PRESETS = [
722
+ { key: "slow", label: "Slow" },
723
+ { key: "many_queries", label: "Many queries" },
724
+ { key: "errors", label: "Errors" },
725
+ { key: "has_exception", label: "Has exception" }
726
+ ];
721
727
  function ProfileList() {
728
+ const params = new URLSearchParams(window.location.search);
722
729
  const initialSection = () => {
723
- const s3 = new URLSearchParams(window.location.search).get("section");
730
+ const s3 = params.get("section");
724
731
  return s3 === "http" || s3 === "jobs" || s3 === "outbound" ? s3 : "http";
725
732
  };
733
+ const initialSort = () => {
734
+ const col = params.get("sort");
735
+ const dir = params.get("dir");
736
+ return {
737
+ col: col === "duration" || col === "memory" || col === "status" || col === "queries" ? col : null,
738
+ dir: dir === "desc" ? "desc" : "asc"
739
+ };
740
+ };
726
741
  const [section, setSection] = d2(initialSection);
727
742
  const [profiles, setProfiles] = d2([]);
728
743
  const [jobs, setJobs] = d2([]);
@@ -740,6 +755,11 @@
740
755
  const [httpMethod, setHttpMethod] = d2("");
741
756
  const [httpStatus, setHttpStatus] = d2("");
742
757
  const [httpDuration, setHttpDuration] = d2("");
758
+ const [httpPreset, setHttpPreset] = d2(() => {
759
+ const p3 = params.get("preset");
760
+ return PRESETS.some((pr) => pr.key === p3) ? p3 : "";
761
+ });
762
+ const [httpSort, setHttpSort] = d2(initialSort);
743
763
  const [jobSearch, setJobSearch] = d2("");
744
764
  const [jobStatus, setJobStatus] = d2("");
745
765
  const [jobDuration, setJobDuration] = d2("");
@@ -759,6 +779,30 @@
759
779
  if (section === "jobs") loadJobs();
760
780
  if (section === "outbound") loadOutbound();
761
781
  }, []);
782
+ y2(() => {
783
+ const url = new URL(window.location.href);
784
+ if (httpSort.col) {
785
+ url.searchParams.set("sort", httpSort.col);
786
+ url.searchParams.set("dir", httpSort.dir);
787
+ } else {
788
+ url.searchParams.delete("sort");
789
+ url.searchParams.delete("dir");
790
+ }
791
+ if (httpPreset) {
792
+ url.searchParams.set("preset", httpPreset);
793
+ } else {
794
+ url.searchParams.delete("preset");
795
+ }
796
+ history.replaceState(null, "", url.toString());
797
+ }, [httpSort, httpPreset]);
798
+ const toggleHttpSort = (col) => {
799
+ setHttpSort(
800
+ (prev) => prev.col === col ? { col, dir: prev.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }
801
+ );
802
+ };
803
+ const togglePreset = (key) => {
804
+ setHttpPreset((prev) => prev === key ? "" : key);
805
+ };
762
806
  const loadJobs = () => {
763
807
  if (jobsLoaded) return;
764
808
  setLoadingJobs(true);
@@ -796,6 +840,8 @@
796
840
  setHttpMethod("");
797
841
  setHttpStatus("");
798
842
  setHttpDuration("");
843
+ setHttpPreset("");
844
+ setHttpSort({ col: null, dir: "asc" });
799
845
  setJobSearch("");
800
846
  setJobStatus("");
801
847
  setJobDuration("");
@@ -870,8 +916,36 @@
870
916
  if (httpDuration) {
871
917
  if (httpDuration === "lt100" ? p3.duration >= 100 : p3.duration < parseInt(httpDuration)) return false;
872
918
  }
919
+ if (httpPreset === "slow" && p3.duration < 500) return false;
920
+ if (httpPreset === "many_queries" && (p3.collectors_data?.database?.total_queries ?? 0) <= 20) return false;
921
+ if (httpPreset === "errors" && p3.status < 500) return false;
922
+ if (httpPreset === "has_exception" && !p3.collectors_data?.exception) return false;
873
923
  return true;
874
924
  });
925
+ const sortedProfiles = httpSort.col ? [...filteredProfiles].sort((a3, b) => {
926
+ let av, bv;
927
+ switch (httpSort.col) {
928
+ case "duration":
929
+ av = a3.duration;
930
+ bv = b.duration;
931
+ break;
932
+ case "memory":
933
+ av = a3.memory ?? 0;
934
+ bv = b.memory ?? 0;
935
+ break;
936
+ case "status":
937
+ av = a3.status;
938
+ bv = b.status;
939
+ break;
940
+ case "queries":
941
+ av = a3.collectors_data?.database?.total_queries ?? 0;
942
+ bv = b.collectors_data?.database?.total_queries ?? 0;
943
+ break;
944
+ default:
945
+ return 0;
946
+ }
947
+ return httpSort.dir === "asc" ? av - bv : bv - av;
948
+ }) : filteredProfiles;
875
949
  const filteredJobs = jobs.filter((p3) => {
876
950
  if (jobSearch && !p3.path.toLowerCase().includes(jobSearch.toLowerCase())) return false;
877
951
  if (jobStatus === "failed" && p3.status !== 500) return false;
@@ -894,9 +968,13 @@
894
968
  }
895
969
  return true;
896
970
  });
897
- const httpFiltersActive = !!(httpSearch || httpMethod || httpStatus || httpDuration);
971
+ const httpFiltersActive = !!(httpSearch || httpMethod || httpStatus || httpDuration || httpPreset);
898
972
  const jobFiltersActive = !!(jobSearch || jobStatus || jobDuration);
899
973
  const outboundFiltersActive = !!(outboundSearch || outboundMethod || outboundStatus);
974
+ const sortIcon = (col) => {
975
+ if (httpSort.col !== col) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
976
+ return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: httpSort.dir === "asc" ? "\u25B2" : "\u25BC" });
977
+ };
900
978
  return /* @__PURE__ */ u3("div", { class: "container", children: [
901
979
  /* @__PURE__ */ u3("div", { class: "header", children: [
902
980
  /* @__PURE__ */ u3("h1", { children: [
@@ -925,6 +1003,15 @@
925
1003
  /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No profiles found" }),
926
1004
  /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Make some requests to your application to see profiling data" })
927
1005
  ] }) : /* @__PURE__ */ u3(k, { children: [
1006
+ /* @__PURE__ */ u3("div", { class: "profiler-action-bar profiler-mb-2", children: /* @__PURE__ */ u3("div", { class: "profiler-filter-group", children: PRESETS.map((preset) => /* @__PURE__ */ u3(
1007
+ "button",
1008
+ {
1009
+ class: `profiler-preset-btn${httpPreset === preset.key ? " profiler-preset-btn--active" : ""}`,
1010
+ onClick: () => togglePreset(preset.key),
1011
+ children: preset.label
1012
+ },
1013
+ preset.key
1014
+ )) }) }),
928
1015
  /* @__PURE__ */ u3("div", { class: "profiler-action-bar profiler-mb-3", children: [
929
1016
  /* @__PURE__ */ u3("div", { class: "profiler-filter-group", children: [
930
1017
  /* @__PURE__ */ u3(
@@ -974,13 +1061,26 @@
974
1061
  /* @__PURE__ */ u3("th", { children: "Time" }),
975
1062
  /* @__PURE__ */ u3("th", { children: "Method" }),
976
1063
  /* @__PURE__ */ u3("th", { children: "Path" }),
977
- /* @__PURE__ */ u3("th", { children: "Duration" }),
978
- /* @__PURE__ */ u3("th", { children: "Memory" }),
979
- /* @__PURE__ */ u3("th", { children: "Status" }),
1064
+ /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "duration" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("duration"), children: [
1065
+ "Duration ",
1066
+ sortIcon("duration")
1067
+ ] }),
1068
+ /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "queries" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("queries"), children: [
1069
+ "Queries ",
1070
+ sortIcon("queries")
1071
+ ] }),
1072
+ /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "memory" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("memory"), children: [
1073
+ "Memory ",
1074
+ sortIcon("memory")
1075
+ ] }),
1076
+ /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "status" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("status"), children: [
1077
+ "Status ",
1078
+ sortIcon("status")
1079
+ ] }),
980
1080
  /* @__PURE__ */ u3("th", { children: "Token" }),
981
1081
  /* @__PURE__ */ u3("th", {})
982
1082
  ] }) }),
983
- /* @__PURE__ */ u3("tbody", { children: filteredProfiles.map((p3) => /* @__PURE__ */ u3("tr", { children: [
1083
+ /* @__PURE__ */ u3("tbody", { children: sortedProfiles.map((p3) => /* @__PURE__ */ u3("tr", { children: [
984
1084
  /* @__PURE__ */ u3("td", { children: formatTime(p3.started_at) }),
985
1085
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: methodClass(p3.method), children: p3.method }) }),
986
1086
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("a", { href: `${BASE}/profiles/${p3.token}`, children: p3.path }) }),
@@ -988,6 +1088,7 @@
988
1088
  p3.duration.toFixed(2),
989
1089
  " ms"
990
1090
  ] }) }),
1091
+ /* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "\u2014" }),
991
1092
  /* @__PURE__ */ u3("td", { children: formatMemory(p3.memory) }),
992
1093
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
993
1094
  /* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
@@ -1263,10 +1364,154 @@
1263
1364
  }
1264
1365
 
1265
1366
  // app/assets/typescript/profiler/components/dashboard/tabs/DatabaseTab.tsx
1266
- function DatabaseTab({ dbData }) {
1367
+ function normalizeSql(sql) {
1368
+ return sql.replace(/\$\d+/g, "?").replace(/\b\d+\b/g, "?").replace(/'[^']*'/g, "?").replace(/"[^"]*"/g, "?").trim();
1369
+ }
1370
+ function computeN1Groups(queries) {
1371
+ const groups = /* @__PURE__ */ new Map();
1372
+ queries.forEach((query, index) => {
1373
+ if (query.cached || query.transaction) return;
1374
+ const pattern = normalizeSql(query.sql);
1375
+ if (!groups.has(pattern)) {
1376
+ groups.set(pattern, { indices: [], backtrace: query.backtrace ?? [] });
1377
+ }
1378
+ groups.get(pattern).indices.push(index);
1379
+ });
1380
+ const result = [];
1381
+ groups.forEach(({ indices, backtrace }, pattern) => {
1382
+ if (indices.length >= 3) {
1383
+ result.push({ pattern, indices, backtrace });
1384
+ }
1385
+ });
1386
+ return result.sort((a3, b) => b.indices.length - a3.indices.length);
1387
+ }
1388
+ function renderPlanNode(node, depth = 0) {
1389
+ const type = node["Node Type"] ?? "";
1390
+ const actualRows = node["Actual Rows"] ?? 0;
1391
+ const planRows = node["Plan Rows"] ?? 1;
1392
+ const isExpensive = type.includes("Seq Scan") || type.includes("Hash Join") || type.includes("Nested Loop") || type.includes("Filter") || actualRows > 0 && planRows > 0 && actualRows > planRows * 10;
1393
+ const children = node["Plans"] ?? [];
1394
+ return /* @__PURE__ */ u3("div", { class: `profiler-explain-node${isExpensive ? " profiler-explain-node--expensive" : ""}`, style: `margin-left:${depth * 14}px`, children: [
1395
+ /* @__PURE__ */ u3("span", { class: "profiler-explain-node__type", children: type }),
1396
+ node["Relation Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1397
+ " on ",
1398
+ node["Relation Name"]
1399
+ ] }),
1400
+ node["Index Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1401
+ " using ",
1402
+ node["Index Name"]
1403
+ ] }),
1404
+ /* @__PURE__ */ u3("div", { class: "profiler-explain-node__stats profiler-text--xs profiler-text--muted", children: [
1405
+ node["Actual Total Time"] != null && /* @__PURE__ */ u3("span", { children: [
1406
+ "time: ",
1407
+ node["Actual Total Time"].toFixed(3),
1408
+ "ms"
1409
+ ] }),
1410
+ node["Actual Rows"] != null && /* @__PURE__ */ u3("span", { children: [
1411
+ "rows: ",
1412
+ actualRows,
1413
+ " (est: ",
1414
+ planRows,
1415
+ ")"
1416
+ ] }),
1417
+ node["Total Cost"] != null && /* @__PURE__ */ u3("span", { children: [
1418
+ "cost: ",
1419
+ node["Total Cost"].toFixed(2)
1420
+ ] }),
1421
+ node["Filter"] && /* @__PURE__ */ u3("span", { class: "profiler-text--warning", children: [
1422
+ "filter: ",
1423
+ node["Filter"]
1424
+ ] })
1425
+ ] }),
1426
+ children.map((child, i3) => renderPlanNode(child, depth + 1))
1427
+ ] });
1428
+ }
1429
+ function ExplainModal({ state, onClose }) {
1430
+ if (!state.open) return null;
1431
+ const renderResult = () => {
1432
+ if (state.loading) return /* @__PURE__ */ u3("div", { class: "profiler-text--muted", children: "Running EXPLAIN ANALYZE\u2026" });
1433
+ if (state.error) return /* @__PURE__ */ u3("div", { class: "profiler-text--error", children: state.error });
1434
+ if (!state.result) return null;
1435
+ if (state.format === "json") {
1436
+ try {
1437
+ const parsed = typeof state.result === "string" ? JSON.parse(state.result) : state.result;
1438
+ const plans = Array.isArray(parsed) ? parsed : [parsed];
1439
+ return /* @__PURE__ */ u3("div", { class: "profiler-explain-result", children: plans.map((entry, i3) => /* @__PURE__ */ u3("div", { children: [
1440
+ renderPlanNode(entry["Plan"] ?? entry),
1441
+ entry["Planning Time"] != null && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
1442
+ "Planning: ",
1443
+ entry["Planning Time"].toFixed(3),
1444
+ "ms",
1445
+ entry["Execution Time"] != null && /* @__PURE__ */ u3(k, { children: [
1446
+ " \xB7 Execution: ",
1447
+ entry["Execution Time"].toFixed(3),
1448
+ "ms"
1449
+ ] })
1450
+ ] })
1451
+ ] }, i3)) });
1452
+ } catch {
1453
+ return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
1454
+ }
1455
+ }
1456
+ return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
1457
+ };
1458
+ return /* @__PURE__ */ u3("div", { class: "profiler-modal__overlay", onClick: onClose, children: /* @__PURE__ */ u3("div", { class: "profiler-modal", onClick: (e3) => e3.stopPropagation(), children: [
1459
+ /* @__PURE__ */ u3("div", { class: "profiler-modal__header", children: [
1460
+ /* @__PURE__ */ u3("span", { class: "profiler-modal__title", children: "EXPLAIN ANALYZE" }),
1461
+ /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: state.adapter }),
1462
+ /* @__PURE__ */ u3("button", { class: "profiler-modal__close", onClick: onClose, children: "\xD7" })
1463
+ ] }),
1464
+ /* @__PURE__ */ u3("div", { class: "profiler-modal__body", children: renderResult() })
1465
+ ] }) });
1466
+ }
1467
+ function DatabaseTab({ dbData, token }) {
1468
+ const [openBacktraces, setOpenBacktraces] = d2(/* @__PURE__ */ new Set());
1469
+ const [explainState, setExplainState] = d2({
1470
+ open: false,
1471
+ loading: false,
1472
+ result: null,
1473
+ format: "text",
1474
+ adapter: "",
1475
+ error: null
1476
+ });
1267
1477
  if (!dbData?.queries) {
1268
1478
  return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No database queries recorded" }) });
1269
1479
  }
1480
+ const n1Groups = computeN1Groups(dbData.queries);
1481
+ const n1IndexSet = new Set(n1Groups.flatMap((g2) => g2.indices));
1482
+ const toggleBacktrace = (pattern) => {
1483
+ setOpenBacktraces((prev) => {
1484
+ const next = new Set(prev);
1485
+ next.has(pattern) ? next.delete(pattern) : next.add(pattern);
1486
+ return next;
1487
+ });
1488
+ };
1489
+ const runExplain = async (queryIndex) => {
1490
+ setExplainState({ open: true, loading: true, result: null, format: "text", adapter: "", error: null });
1491
+ try {
1492
+ const res = await fetch("/_profiler/api/explain", {
1493
+ method: "POST",
1494
+ headers: { "Content-Type": "application/json" },
1495
+ body: JSON.stringify({ token, query_index: queryIndex })
1496
+ });
1497
+ if (!res.ok) {
1498
+ const body = await res.json().catch(() => ({}));
1499
+ setExplainState((s3) => ({ ...s3, loading: false, error: body.error ?? `HTTP ${res.status}` }));
1500
+ return;
1501
+ }
1502
+ const data = await res.json();
1503
+ setExplainState((s3) => ({
1504
+ ...s3,
1505
+ loading: false,
1506
+ result: data.result,
1507
+ format: data.format ?? "text",
1508
+ adapter: data.adapter ?? ""
1509
+ }));
1510
+ } catch (err) {
1511
+ setExplainState((s3) => ({ ...s3, loading: false, error: err.message ?? "Request failed" }));
1512
+ }
1513
+ };
1514
+ const closeExplain = () => setExplainState((s3) => ({ ...s3, open: false }));
1270
1515
  return /* @__PURE__ */ u3(k, { children: [
1271
1516
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
1272
1517
  "Database Queries (",
@@ -1290,19 +1535,60 @@
1290
1535
  /* @__PURE__ */ u3("strong", { children: dbData.cached_queries })
1291
1536
  ] })
1292
1537
  ] }),
1293
- dbData.queries.map((query, index) => /* @__PURE__ */ u3("div", { class: `profiler-query-card${query.slow ? " profiler-query-card--slow" : ""}`, children: [
1538
+ n1Groups.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
1539
+ /* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
1540
+ /* @__PURE__ */ u3("div", { children: [
1541
+ /* @__PURE__ */ u3("strong", { children: "Potential N+1 detected" }),
1542
+ " \u2014 ",
1543
+ n1Groups.length,
1544
+ " pattern",
1545
+ n1Groups.length > 1 ? "s" : "",
1546
+ " repeated ",
1547
+ n1Groups.reduce((sum, g2) => sum + g2.indices.length, 0),
1548
+ " times total",
1549
+ /* @__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." })
1550
+ ] })
1551
+ ] }),
1552
+ n1Groups.map((group) => /* @__PURE__ */ u3("div", { class: "profiler-n1-group profiler-mb-4", children: [
1553
+ /* @__PURE__ */ u3("div", { class: "profiler-n1-group__header", onClick: () => toggleBacktrace(group.pattern), children: [
1554
+ /* @__PURE__ */ u3("span", { class: "profiler-n1-group__count", children: [
1555
+ "N+1 \xB7 ",
1556
+ group.indices.length,
1557
+ "\xD7"
1558
+ ] }),
1559
+ /* @__PURE__ */ u3("code", { class: "profiler-n1-group__pattern", children: group.pattern }),
1560
+ /* @__PURE__ */ u3("span", { class: "profiler-n1-group__toggle", children: [
1561
+ openBacktraces.has(group.pattern) ? "\u25B2" : "\u25BC",
1562
+ " backtrace"
1563
+ ] })
1564
+ ] }),
1565
+ 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)) })
1566
+ ] }, group.pattern)),
1567
+ dbData.queries.map((query, index) => /* @__PURE__ */ u3("div", { class: [
1568
+ "profiler-query-card",
1569
+ query.slow ? "profiler-query-card--slow" : "",
1570
+ n1IndexSet.has(index) ? "profiler-query-card--n1" : ""
1571
+ ].filter(Boolean).join(" "), children: [
1294
1572
  /* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
1295
- /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1296
- "#",
1297
- index + 1
1573
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
1574
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1575
+ "#",
1576
+ index + 1
1577
+ ] }),
1578
+ n1IndexSet.has(index) && /* @__PURE__ */ u3("span", { class: "profiler-badge profiler-badge--warning", children: "N+1" }),
1579
+ query.name && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: query.name })
1298
1580
  ] }),
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"
1581
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
1582
+ /* @__PURE__ */ u3("span", { class: `profiler-query-card__duration ${query.slow ? "profiler-query-card__duration--slow" : "profiler-query-card__duration--fast"}`, children: [
1583
+ query.duration.toFixed(2),
1584
+ " ms"
1585
+ ] }),
1586
+ !query.cached && !query.transaction && /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", onClick: () => runExplain(index), children: "Explain" })
1302
1587
  ] })
1303
1588
  ] }),
1304
1589
  /* @__PURE__ */ u3("code", { class: "profiler-query-card__code", children: query.sql })
1305
- ] }, index))
1590
+ ] }, index)),
1591
+ /* @__PURE__ */ u3(ExplainModal, { state: explainState, onClose: closeExplain })
1306
1592
  ] });
1307
1593
  }
1308
1594
 
@@ -1382,6 +1668,7 @@
1382
1668
  this.dpr = 1;
1383
1669
  this.hoveredFrame = null;
1384
1670
  this.zoomStack = [];
1671
+ this.searchQuery = "";
1385
1672
  this.isPanning = false;
1386
1673
  this.panStartX = 0;
1387
1674
  this.panStartViewport = { start: 0, end: 0 };
@@ -1604,6 +1891,10 @@
1604
1891
  this.render();
1605
1892
  }
1606
1893
  }
1894
+ setSearchQuery(query) {
1895
+ this.searchQuery = query;
1896
+ this.render();
1897
+ }
1607
1898
  render() {
1608
1899
  const ctx = this.ctx;
1609
1900
  const w3 = this.canvas.width / this.dpr;
@@ -1613,6 +1904,15 @@
1613
1904
  const style = getComputedStyle(this.canvas);
1614
1905
  const textColor = style.getPropertyValue("--profiler-text").trim() || "#eef2f7";
1615
1906
  const textMuted = style.getPropertyValue("--profiler-text-muted").trim() || "#5e7080";
1907
+ const searchLower = this.searchQuery.toLowerCase();
1908
+ const hasSearch = searchLower.length > 0;
1909
+ if (hasSearch && this.callbacks.onSearchResults) {
1910
+ let matchCount = 0;
1911
+ for (const f4 of this.frames) {
1912
+ if (f4.node.name.toLowerCase().includes(searchLower)) matchCount++;
1913
+ }
1914
+ this.callbacks.onSearchResults(matchCount, this.frames.length);
1915
+ }
1616
1916
  for (const frame of this.frames) {
1617
1917
  const x2 = (frame.absStart - this.viewport.start) / vpRange * w3;
1618
1918
  const fw = (frame.absEnd - frame.absStart) / vpRange * w3;
@@ -1620,8 +1920,9 @@
1620
1920
  if (x2 + fw < 0 || x2 > w3 || fw < 0.5) continue;
1621
1921
  const color = CATEGORY_COLORS[frame.node.category] || "#a78bfa";
1622
1922
  const isHovered = frame === this.hoveredFrame;
1923
+ const isMatch = !hasSearch || frame.node.name.toLowerCase().includes(searchLower);
1623
1924
  ctx.fillStyle = isHovered ? this.lightenColor(color, 0.2) : color;
1624
- ctx.globalAlpha = isHovered ? 1 : 0.85;
1925
+ ctx.globalAlpha = hasSearch && !isMatch ? 0.2 : isHovered ? 1 : 0.85;
1625
1926
  this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1626
1927
  ctx.fill();
1627
1928
  ctx.globalAlpha = 1;
@@ -1630,8 +1931,15 @@
1630
1931
  ctx.lineWidth = 1.5;
1631
1932
  this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1632
1933
  ctx.stroke();
1934
+ } else if (hasSearch && isMatch) {
1935
+ ctx.strokeStyle = "#ffffff";
1936
+ ctx.lineWidth = 1;
1937
+ ctx.globalAlpha = 0.5;
1938
+ this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1939
+ ctx.stroke();
1940
+ ctx.globalAlpha = 1;
1633
1941
  }
1634
- if (fw > MIN_TEXT_WIDTH) {
1942
+ if (fw > MIN_TEXT_WIDTH && isMatch) {
1635
1943
  ctx.fillStyle = this.getTextColor(color);
1636
1944
  ctx.font = '11px "JetBrains Mono", monospace';
1637
1945
  ctx.textBaseline = "middle";
@@ -1856,7 +2164,11 @@
1856
2164
  const rendererRef = A2(null);
1857
2165
  const tooltipRef = A2(null);
1858
2166
  const breadcrumbsRef = A2(null);
2167
+ const searchInputRef = A2(null);
1859
2168
  const [isZoomed, setIsZoomed] = d2(false);
2169
+ const [searchQuery, setSearchQuery] = d2("");
2170
+ const [matchCount, setMatchCount] = d2(0);
2171
+ const [totalCount, setTotalCount] = d2(0);
1860
2172
  const data = flamegraphData;
1861
2173
  y2(() => {
1862
2174
  if (!data?.root_events?.length || !canvasRef.current || !containerRef.current) return;
@@ -1888,6 +2200,10 @@
1888
2200
  onZoomChange: (ancestors) => {
1889
2201
  breadcrumbs.update(ancestors);
1890
2202
  setIsZoomed(ancestors.length > 0);
2203
+ },
2204
+ onSearchResults: (match, total) => {
2205
+ setMatchCount(match);
2206
+ setTotalCount(total);
1891
2207
  }
1892
2208
  });
1893
2209
  rendererRef.current = renderer;
@@ -1906,6 +2222,23 @@
1906
2222
  breadcrumbsRef.current = null;
1907
2223
  };
1908
2224
  }, [data]);
2225
+ y2(() => {
2226
+ rendererRef.current?.setSearchQuery(searchQuery);
2227
+ if (!searchQuery) {
2228
+ setMatchCount(0);
2229
+ setTotalCount(0);
2230
+ }
2231
+ }, [searchQuery]);
2232
+ y2(() => {
2233
+ const onKeyDown = (e3) => {
2234
+ if ((e3.ctrlKey || e3.metaKey) && e3.key === "f") {
2235
+ e3.preventDefault();
2236
+ searchInputRef.current?.focus();
2237
+ }
2238
+ };
2239
+ document.addEventListener("keydown", onKeyDown, { capture: true });
2240
+ return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
2241
+ }, []);
1909
2242
  if (!data?.root_events?.length) {
1910
2243
  if (!perfData?.events?.length) {
1911
2244
  return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No performance events recorded" }) });
@@ -1976,6 +2309,24 @@
1976
2309
  ] }, cat)) }),
1977
2310
  /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__controls", children: [
1978
2311
  isZoomed && /* @__PURE__ */ u3("button", { class: "profiler-flamegraph__reset", onClick: handleReset, children: "Reset Zoom" }),
2312
+ /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__search", children: [
2313
+ /* @__PURE__ */ u3(
2314
+ "input",
2315
+ {
2316
+ ref: searchInputRef,
2317
+ type: "text",
2318
+ class: "profiler-flamegraph__search-input",
2319
+ placeholder: "Search events\u2026 (Ctrl+F)",
2320
+ value: searchQuery,
2321
+ onInput: (e3) => setSearchQuery(e3.target.value)
2322
+ }
2323
+ ),
2324
+ searchQuery && /* @__PURE__ */ u3("span", { class: "profiler-flamegraph__match-count", children: [
2325
+ matchCount,
2326
+ " / ",
2327
+ totalCount
2328
+ ] })
2329
+ ] }),
1979
2330
  /* @__PURE__ */ u3("span", { class: "profiler-flamegraph__hint", children: "Click to zoom, scroll to zoom in/out, drag to pan" })
1980
2331
  ] }),
1981
2332
  /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__canvas-container", ref: containerRef, children: /* @__PURE__ */ u3(
@@ -2545,7 +2896,7 @@
2545
2896
  activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
2546
2897
  activeTab === "request" && /* @__PURE__ */ u3(RequestTab, { profile }),
2547
2898
  activeTab === "dump" && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
2548
- activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"] }),
2899
+ activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
2549
2900
  activeTab === "ajax" && /* @__PURE__ */ u3(AjaxTab, { ajaxData: cd["ajax"] }),
2550
2901
  activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
2551
2902
  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.8.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.8.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