rails-profiler 0.11.1 → 0.12.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: 5895b7dd1e9141516af0f2a710c2eac52a458d6dcc6e7e8c0847577bb7dd1429
4
- data.tar.gz: fd4c1d48bee719dff9a14b658389a2e0cad306a274cae92db4cf2ed0c4ddb11d
3
+ metadata.gz: b25edc08059907d5374748ac0624d804685b83189faabc4bae8b19cc65b0bed9
4
+ data.tar.gz: a5e8268d4e59540a91d7fc2b27c44bbb492e221e2ac2f4446c2ad095a2473245
5
5
  SHA512:
6
- metadata.gz: aea2caa0bde982cca5fc88a4fe7045b4600e0b293442477e105300514480a113ba8a93a10f568ee74ec2f21534c8c4e283dd70558392024a12f1219e38b31a72
7
- data.tar.gz: 2a88a395c11f73907e383fd7b836f4f6c77974aed00c254fe3b586b77d81b7ab15c166641518642f91c39c7356c76d6111ab45bbd89eade95c05ed558c3e9077
6
+ metadata.gz: af7137fb8650d7a40d2291ad66dc4fe25466c77ad2ebb83489f15191114a1a290b6a4eb47e5760744e991212ec0ed05a146d1d9450f4c7d7cbcbd394629ae3b1
7
+ data.tar.gz: 197f4016c350ed157b690d52ff44c83ef4ef2c5ae69f7906ad55ec8b44416fb606e41b66e3d0d84124ec378cb2961ac0b6c8bcde08e55c1e45c488648889cea8
@@ -2524,6 +2524,324 @@ a.profiler-toolbar-item.profiler-text--warning::after {
2524
2524
  overflow: hidden;
2525
2525
  }
2526
2526
 
2527
+ .profiler-fn-profiling {
2528
+ margin-top: 32px;
2529
+ border-top: 1px solid var(--profiler-border);
2530
+ padding-top: 24px;
2531
+ animation: fadeIn 300ms ease;
2532
+ }
2533
+ .profiler-fn-profiling__header {
2534
+ display: flex;
2535
+ align-items: center;
2536
+ justify-content: space-between;
2537
+ gap: 16px;
2538
+ margin-bottom: 16px;
2539
+ flex-wrap: wrap;
2540
+ }
2541
+ .profiler-fn-profiling__title {
2542
+ font-size: var(--profiler-text-xs);
2543
+ font-weight: 700;
2544
+ letter-spacing: 0.12em;
2545
+ text-transform: uppercase;
2546
+ color: var(--profiler-text-muted);
2547
+ font-family: var(--profiler-font-sans);
2548
+ display: flex;
2549
+ align-items: center;
2550
+ gap: 8px;
2551
+ }
2552
+ .profiler-fn-profiling__title::before {
2553
+ content: "";
2554
+ display: inline-block;
2555
+ width: 8px;
2556
+ height: 8px;
2557
+ border-radius: 50%;
2558
+ background: #94a3b8;
2559
+ flex-shrink: 0;
2560
+ }
2561
+ .profiler-fn-profiling__title--active::before {
2562
+ background: var(--profiler-accent);
2563
+ box-shadow: 0 0 6px var(--profiler-accent-glow);
2564
+ }
2565
+ .profiler-fn-profiling__controls {
2566
+ display: flex;
2567
+ align-items: center;
2568
+ gap: 12px;
2569
+ flex-wrap: wrap;
2570
+ }
2571
+ .profiler-fn-profiling__max-frames-label {
2572
+ display: flex;
2573
+ align-items: center;
2574
+ gap: 6px;
2575
+ font-size: var(--profiler-text-xs);
2576
+ color: var(--profiler-text-muted);
2577
+ font-family: var(--profiler-font-sans);
2578
+ cursor: default;
2579
+ }
2580
+ .profiler-fn-profiling__max-frames-input {
2581
+ width: 70px;
2582
+ height: 26px;
2583
+ padding: 0 8px;
2584
+ background: var(--profiler-bg-lighter);
2585
+ border: 1px solid var(--profiler-border);
2586
+ border-radius: var(--profiler-radius-sm);
2587
+ color: var(--profiler-text);
2588
+ font-family: var(--profiler-font-mono);
2589
+ font-size: var(--profiler-text-xs);
2590
+ text-align: center;
2591
+ transition: border-color var(--profiler-transition-base);
2592
+ appearance: textfield;
2593
+ }
2594
+ .profiler-fn-profiling__max-frames-input::-webkit-inner-spin-button, .profiler-fn-profiling__max-frames-input::-webkit-outer-spin-button {
2595
+ -webkit-appearance: none;
2596
+ }
2597
+ .profiler-fn-profiling__max-frames-input:focus {
2598
+ outline: none;
2599
+ border-color: var(--profiler-accent);
2600
+ box-shadow: 0 0 0 2px var(--profiler-accent-glow);
2601
+ }
2602
+ .profiler-fn-profiling__max-frames-input:disabled {
2603
+ opacity: 0.4;
2604
+ cursor: not-allowed;
2605
+ }
2606
+ .profiler-fn-profiling__updating {
2607
+ font-size: var(--profiler-text-xs);
2608
+ color: var(--profiler-text-muted);
2609
+ font-family: var(--profiler-font-mono);
2610
+ animation: pulse 1s ease-in-out infinite;
2611
+ }
2612
+ .profiler-fn-profiling__toggle {
2613
+ display: inline-flex;
2614
+ align-items: center;
2615
+ gap: 6px;
2616
+ height: 28px;
2617
+ padding: 0 14px;
2618
+ border-radius: var(--profiler-radius-full);
2619
+ border: 1px solid var(--profiler-border-strong);
2620
+ background: var(--profiler-bg-lighter);
2621
+ color: var(--profiler-text-muted);
2622
+ font-size: var(--profiler-text-xs);
2623
+ font-weight: 600;
2624
+ font-family: var(--profiler-font-sans);
2625
+ cursor: pointer;
2626
+ transition: all var(--profiler-transition-base);
2627
+ white-space: nowrap;
2628
+ }
2629
+ .profiler-fn-profiling__toggle::before {
2630
+ content: "";
2631
+ width: 6px;
2632
+ height: 6px;
2633
+ border-radius: 50%;
2634
+ background: var(--profiler-text-subtle);
2635
+ flex-shrink: 0;
2636
+ transition: background var(--profiler-transition-base), box-shadow var(--profiler-transition-base);
2637
+ }
2638
+ .profiler-fn-profiling__toggle:hover {
2639
+ border-color: var(--profiler-border-strong);
2640
+ color: var(--profiler-text);
2641
+ background: var(--profiler-bg-elevated);
2642
+ }
2643
+ .profiler-fn-profiling__toggle:disabled {
2644
+ opacity: 0.5;
2645
+ cursor: not-allowed;
2646
+ }
2647
+ .profiler-fn-profiling__toggle--active {
2648
+ border-color: var(--profiler-border-accent);
2649
+ background: var(--profiler-accent-bg);
2650
+ color: var(--profiler-accent);
2651
+ }
2652
+ .profiler-fn-profiling__toggle--active::before {
2653
+ background: var(--profiler-accent);
2654
+ box-shadow: 0 0 6px var(--profiler-accent-glow);
2655
+ }
2656
+ .profiler-fn-profiling__toggle--active:hover {
2657
+ background: rgba(245, 158, 11, 0.15);
2658
+ }
2659
+ .profiler-fn-profiling__hint {
2660
+ margin: 12px 0 0;
2661
+ padding: 10px 14px;
2662
+ background: var(--profiler-bg-lighter);
2663
+ border: 1px solid var(--profiler-border);
2664
+ border-radius: var(--profiler-radius-md);
2665
+ font-size: var(--profiler-text-xs);
2666
+ color: var(--profiler-text-muted);
2667
+ font-family: var(--profiler-font-sans);
2668
+ line-height: 1.6;
2669
+ }
2670
+ .profiler-fn-profiling__flamegraph {
2671
+ margin: 16px 0;
2672
+ }
2673
+ .profiler-fn-profiling__flamegraph .profiler-flamegraph__controls {
2674
+ margin-bottom: 6px;
2675
+ }
2676
+ .profiler-fn-profiling__sort {
2677
+ display: flex;
2678
+ align-items: center;
2679
+ gap: 4px;
2680
+ margin: 16px 0 8px;
2681
+ padding: 3px;
2682
+ background: var(--profiler-bg-lighter);
2683
+ border: 1px solid var(--profiler-border);
2684
+ border-radius: var(--profiler-radius-md);
2685
+ width: fit-content;
2686
+ }
2687
+ .profiler-fn-profiling__sort-btn {
2688
+ padding: 4px 12px;
2689
+ border: none;
2690
+ border-radius: var(--profiler-radius-sm);
2691
+ background: transparent;
2692
+ color: var(--profiler-text-muted);
2693
+ font-size: var(--profiler-text-xs);
2694
+ font-weight: 500;
2695
+ font-family: var(--profiler-font-sans);
2696
+ cursor: pointer;
2697
+ transition: all var(--profiler-transition-fast);
2698
+ white-space: nowrap;
2699
+ }
2700
+ .profiler-fn-profiling__sort-btn:hover {
2701
+ color: var(--profiler-text);
2702
+ background: var(--profiler-bg-elevated);
2703
+ }
2704
+ .profiler-fn-profiling__sort-btn--active {
2705
+ background: var(--profiler-accent);
2706
+ color: var(--profiler-text-on-accent);
2707
+ font-weight: 600;
2708
+ box-shadow: var(--profiler-shadow-sm);
2709
+ }
2710
+ .profiler-fn-profiling__sort-btn--active:hover {
2711
+ background: var(--profiler-accent-hover);
2712
+ color: var(--profiler-text-on-accent);
2713
+ }
2714
+ .profiler-fn-profiling__table {
2715
+ width: 100%;
2716
+ border-collapse: collapse;
2717
+ font-size: var(--profiler-text-xs);
2718
+ font-family: var(--profiler-font-sans);
2719
+ margin-top: 8px;
2720
+ }
2721
+ .profiler-fn-profiling__table thead tr {
2722
+ border-bottom: 1px solid var(--profiler-border-strong);
2723
+ }
2724
+ .profiler-fn-profiling__table th {
2725
+ padding: 8px 10px;
2726
+ text-align: left;
2727
+ font-size: var(--profiler-text-xs);
2728
+ font-weight: 600;
2729
+ letter-spacing: 0.08em;
2730
+ text-transform: uppercase;
2731
+ color: var(--profiler-text-subtle);
2732
+ }
2733
+ .profiler-fn-profiling__table th.profiler-text--right {
2734
+ text-align: right;
2735
+ }
2736
+ .profiler-fn-profiling__table tbody tr {
2737
+ border-bottom: 1px solid var(--profiler-border);
2738
+ transition: background var(--profiler-transition-fast);
2739
+ }
2740
+ .profiler-fn-profiling__table tbody tr:hover {
2741
+ background: var(--profiler-bg-lighter);
2742
+ }
2743
+ .profiler-fn-profiling__table tbody tr:last-child {
2744
+ border-bottom: none;
2745
+ }
2746
+ .profiler-fn-profiling__table tbody tr.profiler-fn-profiling__row--highlighted {
2747
+ background: rgba(251, 191, 36, 0.08);
2748
+ outline: 1px solid rgba(251, 191, 36, 0.35);
2749
+ outline-offset: -1px;
2750
+ }
2751
+ .profiler-fn-profiling__table td {
2752
+ padding: 7px 10px;
2753
+ vertical-align: middle;
2754
+ line-height: 1.4;
2755
+ }
2756
+ .profiler-fn-profiling__table td.profiler-text--right {
2757
+ text-align: right;
2758
+ }
2759
+ .profiler-fn-profiling__table td.profiler-text--muted {
2760
+ color: var(--profiler-text-muted);
2761
+ }
2762
+ .profiler-fn-profiling__name {
2763
+ font-family: var(--profiler-font-mono);
2764
+ font-size: var(--profiler-text-xs);
2765
+ font-weight: 500;
2766
+ color: var(--profiler-text);
2767
+ max-width: 280px;
2768
+ overflow: hidden;
2769
+ text-overflow: ellipsis;
2770
+ white-space: nowrap;
2771
+ }
2772
+ .profiler-fn-profiling__recursive {
2773
+ display: inline-flex;
2774
+ align-items: center;
2775
+ justify-content: center;
2776
+ width: 14px;
2777
+ height: 14px;
2778
+ border-radius: 3px;
2779
+ background: var(--profiler-warning-bg);
2780
+ color: var(--profiler-warning);
2781
+ font-size: 9px;
2782
+ font-weight: 700;
2783
+ margin-right: 5px;
2784
+ vertical-align: middle;
2785
+ flex-shrink: 0;
2786
+ cursor: help;
2787
+ }
2788
+ .profiler-fn-profiling__filter-active {
2789
+ display: flex;
2790
+ align-items: center;
2791
+ justify-content: space-between;
2792
+ gap: 8px;
2793
+ margin: 12px 0 0;
2794
+ padding: 8px 14px;
2795
+ background: var(--profiler-accent-bg);
2796
+ border: 1px solid var(--profiler-border-accent);
2797
+ border-radius: var(--profiler-radius-md);
2798
+ font-size: var(--profiler-text-xs);
2799
+ color: var(--profiler-accent);
2800
+ font-family: var(--profiler-font-sans);
2801
+ }
2802
+ .profiler-fn-profiling__filter-active strong {
2803
+ font-family: var(--profiler-font-mono);
2804
+ font-weight: 600;
2805
+ }
2806
+ .profiler-fn-profiling__filter-clear {
2807
+ padding: 2px 8px;
2808
+ border: 1px solid var(--profiler-border-accent);
2809
+ border-radius: var(--profiler-radius-sm);
2810
+ background: transparent;
2811
+ color: var(--profiler-accent);
2812
+ font-size: var(--profiler-text-xs);
2813
+ font-family: var(--profiler-font-sans);
2814
+ cursor: pointer;
2815
+ transition: all var(--profiler-transition-fast);
2816
+ white-space: nowrap;
2817
+ }
2818
+ .profiler-fn-profiling__filter-clear:hover {
2819
+ background: var(--profiler-accent);
2820
+ color: var(--profiler-text-on-accent);
2821
+ }
2822
+ .profiler-fn-profiling__cap-warning {
2823
+ display: flex;
2824
+ align-items: center;
2825
+ gap: 8px;
2826
+ margin: 12px 0 0;
2827
+ padding: 10px 14px;
2828
+ background: var(--profiler-warning-bg);
2829
+ border: 1px solid rgba(251, 146, 60, 0.3);
2830
+ border-radius: var(--profiler-radius-md);
2831
+ font-size: var(--profiler-text-xs);
2832
+ color: var(--profiler-warning);
2833
+ font-family: var(--profiler-font-sans);
2834
+ font-weight: 500;
2835
+ }
2836
+
2837
+ @keyframes pulse {
2838
+ 0%, 100% {
2839
+ opacity: 1;
2840
+ }
2841
+ 50% {
2842
+ opacity: 0.4;
2843
+ }
2844
+ }
2527
2845
  pre[data-language=sql],
2528
2846
  .sql-code {
2529
2847
  background: var(--profiler-bg);
@@ -1682,7 +1682,8 @@
1682
1682
  sql: "#fb923c",
1683
1683
  cache: "#a78bfa",
1684
1684
  http: "#f87171",
1685
- custom: "#e879f9"
1685
+ custom: "#e879f9",
1686
+ method: "#94a3b8"
1686
1687
  };
1687
1688
  var FRAME_HEIGHT = 24;
1688
1689
  var FRAME_GAP = 1;
@@ -1704,6 +1705,7 @@
1704
1705
  this.hoveredFrame = null;
1705
1706
  this.zoomStack = [];
1706
1707
  this.searchQuery = "";
1708
+ this.highlightName = "";
1707
1709
  this.isPanning = false;
1708
1710
  this.panStartX = 0;
1709
1711
  this.panStartViewport = { start: 0, end: 0 };
@@ -1930,6 +1932,10 @@
1930
1932
  this.searchQuery = query;
1931
1933
  this.render();
1932
1934
  }
1935
+ setHighlightName(name) {
1936
+ this.highlightName = name;
1937
+ this.render();
1938
+ }
1933
1939
  render() {
1934
1940
  const ctx = this.ctx;
1935
1941
  const w3 = this.canvas.width / this.dpr;
@@ -1955,9 +1961,10 @@
1955
1961
  if (x2 + fw < 0 || x2 > w3 || fw < 0.5) continue;
1956
1962
  const color = CATEGORY_COLORS[frame.node.category] || "#a78bfa";
1957
1963
  const isHovered = frame === this.hoveredFrame;
1964
+ const isHighlighted = !!this.highlightName && frame.node.name === this.highlightName;
1958
1965
  const isMatch = !hasSearch || frame.node.name.toLowerCase().includes(searchLower);
1959
- ctx.fillStyle = isHovered ? this.lightenColor(color, 0.2) : color;
1960
- ctx.globalAlpha = hasSearch && !isMatch ? 0.2 : isHovered ? 1 : 0.85;
1966
+ ctx.fillStyle = isHovered || isHighlighted ? this.lightenColor(color, 0.2) : color;
1967
+ ctx.globalAlpha = hasSearch && !isMatch ? 0.2 : isHovered || isHighlighted ? 1 : 0.85;
1961
1968
  this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1962
1969
  ctx.fill();
1963
1970
  ctx.globalAlpha = 1;
@@ -1966,6 +1973,11 @@
1966
1973
  ctx.lineWidth = 1.5;
1967
1974
  this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1968
1975
  ctx.stroke();
1976
+ } else if (isHighlighted) {
1977
+ ctx.strokeStyle = "#fbbf24";
1978
+ ctx.lineWidth = 2;
1979
+ this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
1980
+ ctx.stroke();
1969
1981
  } else if (hasSearch && isMatch) {
1970
1982
  ctx.strokeStyle = "#ffffff";
1971
1983
  ctx.lineWidth = 1;
@@ -2048,7 +2060,8 @@
2048
2060
  sql: "SQL",
2049
2061
  cache: "Cache",
2050
2062
  http: "HTTP",
2051
- custom: "Custom"
2063
+ custom: "Custom",
2064
+ method: "Method"
2052
2065
  };
2053
2066
  var CATEGORY_COLORS2 = {
2054
2067
  controller: "#60a5fa",
@@ -2057,8 +2070,14 @@
2057
2070
  sql: "#fb923c",
2058
2071
  cache: "#a78bfa",
2059
2072
  http: "#f87171",
2060
- custom: "#e879f9"
2073
+ custom: "#e879f9",
2074
+ method: "#94a3b8"
2061
2075
  };
2076
+ function formatBytes2(bytes) {
2077
+ if (bytes < 1024) return `${bytes} B`;
2078
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2079
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
2080
+ }
2062
2081
  var FlameGraphTooltip = class {
2063
2082
  constructor(container, totalDuration) {
2064
2083
  this.totalDuration = totalDuration;
@@ -2096,7 +2115,40 @@
2096
2115
  span.textContent = `${pctTotal}%`;
2097
2116
  return span;
2098
2117
  });
2099
- if (node.payload) {
2118
+ if (category === "method" && node.payload) {
2119
+ if (node.payload.memory_bytes != null) {
2120
+ this.addRow("Memory", () => {
2121
+ const span = document.createElement("span");
2122
+ span.className = "value";
2123
+ span.textContent = formatBytes2(node.payload.memory_bytes);
2124
+ return span;
2125
+ });
2126
+ }
2127
+ if (node.payload.allocated_objects != null) {
2128
+ this.addRow("Objects", () => {
2129
+ const span = document.createElement("span");
2130
+ span.className = "value";
2131
+ span.textContent = `${node.payload.allocated_objects.toLocaleString()} obj`;
2132
+ return span;
2133
+ });
2134
+ }
2135
+ if (node.payload.recursive) {
2136
+ this.addRow("Recursive", () => {
2137
+ const span = document.createElement("span");
2138
+ span.className = "value";
2139
+ span.style.color = "var(--profiler-warning)";
2140
+ span.textContent = "\u21BA yes";
2141
+ return span;
2142
+ });
2143
+ }
2144
+ if (node.payload.file) {
2145
+ const payloadDiv = document.createElement("div");
2146
+ payloadDiv.className = "tooltip-payload";
2147
+ payloadDiv.textContent = `${node.payload.file}:${node.payload.line}`;
2148
+ this.el.appendChild(payloadDiv);
2149
+ }
2150
+ }
2151
+ if (node.payload && category !== "method") {
2100
2152
  let payloadText = null;
2101
2153
  if (category === "sql" && node.payload.sql) {
2102
2154
  payloadText = node.payload.sql.length > 200 ? node.payload.sql.slice(0, 200) + "..." : node.payload.sql;
@@ -2189,7 +2241,8 @@
2189
2241
  sql: "#fb923c",
2190
2242
  cache: "#a78bfa",
2191
2243
  http: "#f87171",
2192
- custom: "#e879f9"
2244
+ custom: "#e879f9",
2245
+ method: "#94a3b8"
2193
2246
  };
2194
2247
  var CATEGORY_LABELS2 = {
2195
2248
  controller: "Controller",
@@ -2198,9 +2251,16 @@
2198
2251
  sql: "SQL",
2199
2252
  cache: "Cache",
2200
2253
  http: "HTTP",
2201
- custom: "Custom"
2254
+ custom: "Custom",
2255
+ method: "Method"
2202
2256
  };
2203
- function FlameGraphTab({ flamegraphData, perfData }) {
2257
+ function formatBytes3(bytes) {
2258
+ if (bytes < 0) return "\u2014";
2259
+ if (bytes < 1024) return `${bytes} B`;
2260
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2261
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
2262
+ }
2263
+ function FlameGraphTab({ flamegraphData, perfData, functionProfileData }) {
2204
2264
  const canvasRef = A2(null);
2205
2265
  const containerRef = A2(null);
2206
2266
  const rendererRef = A2(null);
@@ -2211,6 +2271,38 @@
2211
2271
  const [searchQuery, setSearchQuery] = d2("");
2212
2272
  const [matchCount, setMatchCount] = d2(0);
2213
2273
  const [totalCount, setTotalCount] = d2(0);
2274
+ const [fnSortKey, setFnSortKey] = d2("total_duration");
2275
+ const [fnSortDir, setFnSortDir] = d2("desc");
2276
+ const [fnEnabled, setFnEnabled] = d2(functionProfileData?.enabled ?? false);
2277
+ const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
2278
+ const [fnToggling, setFnToggling] = d2(false);
2279
+ const [fnMaxFramesUpdating, setFnMaxFramesUpdating] = d2(false);
2280
+ const patchFunctionProfiling = async (patch) => {
2281
+ const res = await fetch("/_profiler/api/function_profiling", {
2282
+ method: "PATCH",
2283
+ headers: { "Content-Type": "application/json" },
2284
+ body: JSON.stringify(patch)
2285
+ });
2286
+ return res.json();
2287
+ };
2288
+ const toggleFunctionProfiling = async () => {
2289
+ setFnToggling(true);
2290
+ try {
2291
+ const json = await patchFunctionProfiling({ enabled: !fnEnabled });
2292
+ setFnEnabled(json.enabled);
2293
+ } finally {
2294
+ setFnToggling(false);
2295
+ }
2296
+ };
2297
+ const updateMaxFrames = async (value) => {
2298
+ setFnMaxFramesUpdating(true);
2299
+ try {
2300
+ const json = await patchFunctionProfiling({ max_frames: value });
2301
+ setFnMaxFrames(json.max_frames);
2302
+ } finally {
2303
+ setFnMaxFramesUpdating(false);
2304
+ }
2305
+ };
2214
2306
  const data = flamegraphData;
2215
2307
  y2(() => {
2216
2308
  if (!data?.root_events?.length || !canvasRef.current || !containerRef.current) return;
@@ -2283,9 +2375,29 @@
2283
2375
  }, []);
2284
2376
  if (!data?.root_events?.length) {
2285
2377
  if (!perfData?.events?.length) {
2286
- return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No performance events recorded" }) });
2378
+ return /* @__PURE__ */ u3("div", { class: "profiler-flamegraph", children: [
2379
+ /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No performance events recorded" }) }),
2380
+ /* @__PURE__ */ u3(
2381
+ FunctionProfilingSection,
2382
+ {
2383
+ data: functionProfileData,
2384
+ enabled: fnEnabled,
2385
+ toggling: fnToggling,
2386
+ maxFrames: fnMaxFrames,
2387
+ maxFramesUpdating: fnMaxFramesUpdating,
2388
+ sortKey: fnSortKey,
2389
+ sortDir: fnSortDir,
2390
+ onToggle: toggleFunctionProfiling,
2391
+ onMaxFramesChange: updateMaxFrames,
2392
+ onSortChange: (key, dir) => {
2393
+ setFnSortKey(key);
2394
+ setFnSortDir(dir);
2395
+ }
2396
+ }
2397
+ )
2398
+ ] });
2287
2399
  }
2288
- return /* @__PURE__ */ u3(k, { children: [
2400
+ return /* @__PURE__ */ u3("div", { class: "profiler-flamegraph", children: [
2289
2401
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
2290
2402
  "Performance Timeline (",
2291
2403
  perfData.total_events,
@@ -2307,7 +2419,25 @@
2307
2419
  ] })
2308
2420
  ] }),
2309
2421
  event.payload && Object.keys(event.payload).length > 0 && /* @__PURE__ */ u3("pre", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: JSON.stringify(event.payload, null, 2) })
2310
- ] }, index))
2422
+ ] }, index)),
2423
+ /* @__PURE__ */ u3(
2424
+ FunctionProfilingSection,
2425
+ {
2426
+ data: functionProfileData,
2427
+ enabled: fnEnabled,
2428
+ toggling: fnToggling,
2429
+ maxFrames: fnMaxFrames,
2430
+ maxFramesUpdating: fnMaxFramesUpdating,
2431
+ sortKey: fnSortKey,
2432
+ sortDir: fnSortDir,
2433
+ onToggle: toggleFunctionProfiling,
2434
+ onMaxFramesChange: updateMaxFrames,
2435
+ onSortChange: (key, dir) => {
2436
+ setFnSortKey(key);
2437
+ setFnSortDir(dir);
2438
+ }
2439
+ }
2440
+ )
2311
2441
  ] });
2312
2442
  }
2313
2443
  const categoryCounts = {};
@@ -2378,7 +2508,329 @@
2378
2508
  class: "profiler-flamegraph__canvas",
2379
2509
  style: { width: "100%" }
2380
2510
  }
2381
- ) })
2511
+ ) }),
2512
+ /* @__PURE__ */ u3(
2513
+ FunctionProfilingSection,
2514
+ {
2515
+ data: functionProfileData,
2516
+ enabled: fnEnabled,
2517
+ toggling: fnToggling,
2518
+ maxFrames: fnMaxFrames,
2519
+ maxFramesUpdating: fnMaxFramesUpdating,
2520
+ sortKey: fnSortKey,
2521
+ sortDir: fnSortDir,
2522
+ onToggle: toggleFunctionProfiling,
2523
+ onMaxFramesChange: updateMaxFrames,
2524
+ onSortChange: (key, dir) => {
2525
+ setFnSortKey(key);
2526
+ setFnSortDir(dir);
2527
+ }
2528
+ }
2529
+ )
2530
+ ] });
2531
+ }
2532
+ function collectNames(node, out = /* @__PURE__ */ new Set()) {
2533
+ out.add(node.name);
2534
+ node.children?.forEach((c3) => collectNames(c3, out));
2535
+ return out;
2536
+ }
2537
+ function findFirstNodeByName(nodes, name) {
2538
+ for (const node of nodes) {
2539
+ if (node.name === name) return node;
2540
+ const found = findFirstNodeByName(node.children ?? [], name);
2541
+ if (found) return found;
2542
+ }
2543
+ return null;
2544
+ }
2545
+ function FunctionProfilingSection({ data, enabled, toggling, maxFrames, maxFramesUpdating, sortKey, sortDir, onToggle, onMaxFramesChange, onSortChange }) {
2546
+ const [filterNames, setFilterNames] = d2(null);
2547
+ const [filterLabel, setFilterLabel] = d2(null);
2548
+ const [hoveredFnName, setHoveredFnName] = d2(null);
2549
+ const fnRendererRef = A2(null);
2550
+ const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
2551
+ const rootCalls = hasData ? data.root_calls ?? [] : [];
2552
+ const sortedFunctions = hasData ? [...data.functions].sort(
2553
+ (a3, b) => sortDir === "asc" ? a3[sortKey] - b[sortKey] : b[sortKey] - a3[sortKey]
2554
+ ) : [];
2555
+ const displayedFunctions = filterNames ? sortedFunctions.filter((fn) => filterNames.has(fn.name)) : sortedFunctions;
2556
+ const handleFrameSelect = (node) => {
2557
+ if (!node) {
2558
+ setFilterNames(null);
2559
+ setFilterLabel(null);
2560
+ } else {
2561
+ setFilterNames(collectNames(node));
2562
+ setFilterLabel(node.name);
2563
+ }
2564
+ };
2565
+ const handleColClick = (key) => {
2566
+ if (key === sortKey) {
2567
+ onSortChange(key, sortDir === "asc" ? "desc" : "asc");
2568
+ } else {
2569
+ onSortChange(key, "desc");
2570
+ }
2571
+ };
2572
+ const sortIcon = (key) => {
2573
+ if (sortKey !== key) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
2574
+ return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: sortDir === "asc" ? "\u25B2" : "\u25BC" });
2575
+ };
2576
+ const handleMaxFramesBlur = (e3) => {
2577
+ const value = parseInt(e3.target.value, 10);
2578
+ if (!isNaN(value) && value > 0 && value !== maxFrames) {
2579
+ onMaxFramesChange(value);
2580
+ }
2581
+ };
2582
+ const handleMaxFramesKeyDown = (e3) => {
2583
+ if (e3.key === "Enter") {
2584
+ e3.target.blur();
2585
+ }
2586
+ };
2587
+ return /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling", children: [
2588
+ /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__header", children: [
2589
+ /* @__PURE__ */ u3("span", { class: `profiler-fn-profiling__title${enabled ? " profiler-fn-profiling__title--active" : ""}`, children: "Function Profiling" }),
2590
+ /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__controls", children: [
2591
+ /* @__PURE__ */ u3("label", { class: "profiler-fn-profiling__max-frames-label", children: [
2592
+ "Max frames",
2593
+ /* @__PURE__ */ u3(
2594
+ "input",
2595
+ {
2596
+ type: "number",
2597
+ class: "profiler-fn-profiling__max-frames-input",
2598
+ defaultValue: maxFrames,
2599
+ min: 1,
2600
+ disabled: maxFramesUpdating,
2601
+ onBlur: handleMaxFramesBlur,
2602
+ onKeyDown: handleMaxFramesKeyDown
2603
+ }
2604
+ ),
2605
+ maxFramesUpdating && /* @__PURE__ */ u3("span", { class: "profiler-fn-profiling__updating", children: "\u2026" })
2606
+ ] }),
2607
+ /* @__PURE__ */ u3(
2608
+ "button",
2609
+ {
2610
+ class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
2611
+ onClick: onToggle,
2612
+ disabled: toggling,
2613
+ children: toggling ? "\u2026" : enabled ? "Enabled \u2014 click to disable" : "Disabled \u2014 click to enable"
2614
+ }
2615
+ )
2616
+ ] })
2617
+ ] }),
2618
+ !enabled && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Enable function profiling to automatically track execution time and memory allocation for every method call in your app/ directory (using Ruby TracePoint). Warning: significant overhead \u2014 for development use only." }),
2619
+ enabled && !hasData && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
2620
+ hasData && /* @__PURE__ */ u3(k, { children: [
2621
+ data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
2622
+ "\u26A0 Frame cap reached (",
2623
+ data.max_frames,
2624
+ ' frames) \u2014 call tree is truncated. Increase "Max frames" to capture more.'
2625
+ ] }),
2626
+ /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__stats", style: { marginTop: "0.75rem" }, children: [
2627
+ /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2628
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Functions" }),
2629
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: data.functions.length })
2630
+ ] }),
2631
+ /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2632
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Total Calls" }),
2633
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: data.total_calls })
2634
+ ] }),
2635
+ /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2636
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Total Duration" }),
2637
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2638
+ data.total_duration?.toFixed(2),
2639
+ " ",
2640
+ /* @__PURE__ */ u3("small", { children: "ms" })
2641
+ ] })
2642
+ ] }),
2643
+ /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2644
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Allocated" }),
2645
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2646
+ data.total_allocated_objects?.toLocaleString(),
2647
+ " ",
2648
+ /* @__PURE__ */ u3("small", { children: "obj" })
2649
+ ] })
2650
+ ] }),
2651
+ /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2652
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Memory" }),
2653
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: formatBytes3(data.total_memory_bytes ?? 0) })
2654
+ ] })
2655
+ ] }),
2656
+ rootCalls.length > 0 && /* @__PURE__ */ u3(
2657
+ FunctionFlameGraph,
2658
+ {
2659
+ rootCalls,
2660
+ onFrameSelect: handleFrameSelect,
2661
+ rendererRef: fnRendererRef,
2662
+ onHoverName: (name) => setHoveredFnName(name)
2663
+ }
2664
+ ),
2665
+ filterNames && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__filter-active", children: [
2666
+ /* @__PURE__ */ u3("span", { children: [
2667
+ "Showing ",
2668
+ /* @__PURE__ */ u3("strong", { children: filterLabel }),
2669
+ " + ",
2670
+ filterNames.size - 1,
2671
+ " child function",
2672
+ filterNames.size !== 2 ? "s" : ""
2673
+ ] }),
2674
+ /* @__PURE__ */ u3("button", { class: "profiler-fn-profiling__filter-clear", onClick: () => {
2675
+ setFilterNames(null);
2676
+ setFilterLabel(null);
2677
+ }, children: "\u2715 Clear" })
2678
+ ] }),
2679
+ /* @__PURE__ */ u3("table", { class: "profiler-table profiler-fn-profiling__table", children: [
2680
+ /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
2681
+ /* @__PURE__ */ u3("th", { children: "Function" }),
2682
+ /* @__PURE__ */ u3("th", { children: "File" }),
2683
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "calls" ? " sortable--active" : ""}`, onClick: () => handleColClick("calls"), children: [
2684
+ "Calls ",
2685
+ sortIcon("calls")
2686
+ ] }),
2687
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "total_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("total_duration"), children: [
2688
+ "Total Time ",
2689
+ sortIcon("total_duration")
2690
+ ] }),
2691
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "self_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("self_duration"), children: [
2692
+ "Self Time ",
2693
+ sortIcon("self_duration")
2694
+ ] }),
2695
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "allocated_objects" ? " sortable--active" : ""}`, onClick: () => handleColClick("allocated_objects"), children: [
2696
+ "Objects ",
2697
+ sortIcon("allocated_objects")
2698
+ ] }),
2699
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "memory_bytes" ? " sortable--active" : ""}`, onClick: () => handleColClick("memory_bytes"), children: [
2700
+ "Memory ",
2701
+ sortIcon("memory_bytes")
2702
+ ] })
2703
+ ] }) }),
2704
+ /* @__PURE__ */ u3("tbody", { children: displayedFunctions.map((fn, i3) => /* @__PURE__ */ u3(
2705
+ "tr",
2706
+ {
2707
+ class: hoveredFnName === fn.name ? "profiler-fn-profiling__row--highlighted" : "",
2708
+ style: { cursor: rootCalls.length > 0 ? "pointer" : "default" },
2709
+ onMouseEnter: () => {
2710
+ setHoveredFnName(fn.name);
2711
+ fnRendererRef.current?.setHighlightName(fn.name);
2712
+ },
2713
+ onMouseLeave: () => {
2714
+ setHoveredFnName(null);
2715
+ fnRendererRef.current?.setHighlightName("");
2716
+ },
2717
+ onClick: () => {
2718
+ const node = findFirstNodeByName(rootCalls, fn.name);
2719
+ if (node) {
2720
+ fnRendererRef.current?.zoomTo(node);
2721
+ handleFrameSelect(node);
2722
+ }
2723
+ },
2724
+ children: [
2725
+ /* @__PURE__ */ u3("td", { class: "profiler-fn-profiling__name", children: [
2726
+ fn.recursive_calls > 0 && /* @__PURE__ */ u3("span", { class: "profiler-fn-profiling__recursive", title: `${fn.recursive_calls} recursive call(s)`, children: "\u21BA" }),
2727
+ fn.name
2728
+ ] }),
2729
+ /* @__PURE__ */ u3("td", { class: "profiler-text--muted profiler-text--xs", style: { fontFamily: "var(--profiler-font-mono)" }, children: [
2730
+ fn.file,
2731
+ ":",
2732
+ fn.line
2733
+ ] }),
2734
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: fn.calls }),
2735
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: /* @__PURE__ */ u3("span", { class: fn.total_duration >= 100 ? "badge-error" : fn.total_duration >= 10 ? "badge-warning" : "badge-success", children: [
2736
+ fn.total_duration.toFixed(2),
2737
+ " ms"
2738
+ ] }) }),
2739
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: /* @__PURE__ */ u3("span", { class: fn.self_duration >= 50 ? "badge-error" : fn.self_duration >= 5 ? "badge-warning" : "badge-success", children: [
2740
+ fn.self_duration.toFixed(2),
2741
+ " ms"
2742
+ ] }) }),
2743
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: [
2744
+ fn.allocated_objects.toLocaleString(),
2745
+ " ",
2746
+ /* @__PURE__ */ u3("small", { children: "obj" })
2747
+ ] }),
2748
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: formatBytes3(fn.memory_bytes) })
2749
+ ]
2750
+ },
2751
+ i3
2752
+ )) })
2753
+ ] })
2754
+ ] })
2755
+ ] });
2756
+ }
2757
+ function FunctionFlameGraph({ rootCalls, onFrameSelect, rendererRef: externalRendererRef, onHoverName }) {
2758
+ const canvasRef = A2(null);
2759
+ const containerRef = A2(null);
2760
+ const rendererRef = A2(null);
2761
+ const onFrameSelectRef = A2(onFrameSelect);
2762
+ const onHoverNameRef = A2(onHoverName);
2763
+ const [isZoomed, setIsZoomed] = d2(false);
2764
+ y2(() => {
2765
+ onFrameSelectRef.current = onFrameSelect;
2766
+ }, [onFrameSelect]);
2767
+ y2(() => {
2768
+ onHoverNameRef.current = onHoverName;
2769
+ }, [onHoverName]);
2770
+ const totalDuration = rootCalls.reduce((sum, n2) => sum + n2.duration, 0);
2771
+ y2(() => {
2772
+ if (!rootCalls.length || !canvasRef.current || !containerRef.current) return;
2773
+ const container = containerRef.current;
2774
+ const canvas = canvasRef.current;
2775
+ const tooltip = new FlameGraphTooltip(container, totalDuration);
2776
+ const breadcrumbs = new FlameGraphBreadcrumbs(container, (node) => {
2777
+ if (node) {
2778
+ rendererRef.current?.zoomTo(node);
2779
+ onFrameSelectRef.current?.(node);
2780
+ } else {
2781
+ rendererRef.current?.resetZoom();
2782
+ onFrameSelectRef.current?.(null);
2783
+ }
2784
+ });
2785
+ container.insertBefore(breadcrumbs["el"], canvas);
2786
+ const renderer = new FlameGraphRenderer(canvas, rootCalls, {
2787
+ onHover: (frame, x2, y3) => {
2788
+ if (frame) {
2789
+ tooltip.show(frame, x2, y3);
2790
+ onHoverNameRef.current?.(frame.node.name);
2791
+ } else {
2792
+ tooltip.hide();
2793
+ onHoverNameRef.current?.(null);
2794
+ }
2795
+ },
2796
+ onClick: (frame) => {
2797
+ setIsZoomed(true);
2798
+ onFrameSelectRef.current?.(frame.node);
2799
+ renderer.zoomTo(frame.node);
2800
+ },
2801
+ onZoomChange: (ancestors) => {
2802
+ breadcrumbs.update(ancestors);
2803
+ setIsZoomed(ancestors.length > 0);
2804
+ if (ancestors.length === 0) onFrameSelectRef.current?.(null);
2805
+ }
2806
+ });
2807
+ rendererRef.current = renderer;
2808
+ if (externalRendererRef) externalRendererRef.current = renderer;
2809
+ const handleResize = () => renderer.resizeCanvas();
2810
+ window.addEventListener("resize", handleResize);
2811
+ const handleTheme = () => renderer.render();
2812
+ document.addEventListener("profiler:theme-change", handleTheme);
2813
+ return () => {
2814
+ renderer.destroy();
2815
+ tooltip.destroy();
2816
+ breadcrumbs.destroy();
2817
+ window.removeEventListener("resize", handleResize);
2818
+ document.removeEventListener("profiler:theme-change", handleTheme);
2819
+ rendererRef.current = null;
2820
+ if (externalRendererRef) externalRendererRef.current = null;
2821
+ };
2822
+ }, [rootCalls]);
2823
+ const handleReset = () => {
2824
+ rendererRef.current?.resetZoom();
2825
+ setIsZoomed(false);
2826
+ onFrameSelectRef.current?.(null);
2827
+ };
2828
+ return /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__flamegraph", children: [
2829
+ /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__controls", children: [
2830
+ isZoomed && /* @__PURE__ */ u3("button", { class: "profiler-flamegraph__reset", onClick: handleReset, children: "Reset Zoom" }),
2831
+ /* @__PURE__ */ u3("span", { class: "profiler-flamegraph__hint", children: "Click to zoom & filter table \xB7 scroll to zoom in/out \xB7 drag to pan" })
2832
+ ] }),
2833
+ /* @__PURE__ */ u3("div", { class: "profiler-flamegraph__canvas-container", ref: containerRef, children: /* @__PURE__ */ u3("canvas", { ref: canvasRef, class: "profiler-flamegraph__canvas", style: { width: "100%" } }) })
2382
2834
  ] });
2383
2835
  }
2384
2836
 
@@ -2941,7 +3393,7 @@
2941
3393
  activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
2942
3394
  activeTab === "ajax" && /* @__PURE__ */ u3(AjaxTab, { ajaxData: cd["ajax"] }),
2943
3395
  activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
2944
- activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"] }),
3396
+ activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"], functionProfileData: cd["function_profile"] }),
2945
3397
  activeTab === "views" && /* @__PURE__ */ u3(ViewsTab, { viewData: cd["view"] }),
2946
3398
  activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
2947
3399
  activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class FunctionProfilingController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def show
9
+ render json: {
10
+ enabled: Profiler.function_profiling_enabled,
11
+ max_frames: Profiler.function_profiling_max_frames
12
+ }
13
+ end
14
+
15
+ def update
16
+ if params.key?(:enabled)
17
+ Profiler.function_profiling_enabled = params[:enabled] == true || params[:enabled] == "true"
18
+ end
19
+
20
+ if params.key?(:max_frames)
21
+ max = params[:max_frames].to_i
22
+ Profiler.function_profiling_max_frames = max.positive? ? max : Profiler.function_profiling_max_frames
23
+ end
24
+
25
+ render json: {
26
+ enabled: Profiler.function_profiling_enabled,
27
+ max_frames: Profiler.function_profiling_max_frames
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
data/config/routes.rb CHANGED
@@ -33,5 +33,6 @@ Profiler::Engine.routes.draw do
33
33
  get "toolbar/:token", to: "toolbar#show"
34
34
  post "ajax/link", to: "ajax#link"
35
35
  post "explain", to: "explain#create"
36
+ resource :function_profiling, only: [:show, :update], controller: "function_profiling"
36
37
  end
37
38
  end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+ require "objspace"
5
+
6
+ module Profiler
7
+ module Collectors
8
+ class FunctionProfilerCollector < BaseCollector
9
+ CallFrame = Struct.new(:name, :file, :line, :started_at, :alloc_before, :memsize_before, :recursive, :children)
10
+
11
+ def name
12
+ "function_profile"
13
+ end
14
+
15
+ def icon
16
+ "⚡"
17
+ end
18
+
19
+ def priority
20
+ 31
21
+ end
22
+
23
+ def tab_config
24
+ {
25
+ key: "function_profile",
26
+ label: "Function Profile",
27
+ icon: icon,
28
+ priority: priority,
29
+ enabled: false,
30
+ default_active: false
31
+ }
32
+ end
33
+
34
+ def has_data?
35
+ false
36
+ end
37
+
38
+ def subscribe
39
+ unless Profiler.function_profiling_enabled
40
+ store_data({ enabled: false, max_frames: Profiler.function_profiling_max_frames })
41
+ return
42
+ end
43
+
44
+ app_root = app_root_path
45
+ max_frames = Profiler.function_profiling_max_frames
46
+ Thread.current[:fn_profiler_stack] = []
47
+ Thread.current[:fn_profiler_roots] = []
48
+ Thread.current[:fn_profiler_count] = 0
49
+
50
+ @trace = TracePoint.new(:call, :return) do |tp|
51
+ next unless tp.path&.start_with?(app_root)
52
+
53
+ stack = Thread.current[:fn_profiler_stack]
54
+ next unless stack
55
+
56
+ case tp.event
57
+ when :call
58
+ count = (Thread.current[:fn_profiler_count] += 1)
59
+ next if count > max_frames
60
+
61
+ fn_name = "#{tp.defined_class}##{tp.method_id}"
62
+ is_recursive = stack.any? { |f| f.name == fn_name }
63
+
64
+ stack.push(CallFrame.new(
65
+ fn_name,
66
+ relative_path(tp.path),
67
+ tp.lineno,
68
+ Process.clock_gettime(Process::CLOCK_MONOTONIC),
69
+ GC.stat[:total_allocated_objects],
70
+ ObjectSpace.memsize_of_all,
71
+ is_recursive,
72
+ []
73
+ ))
74
+ when :return
75
+ frame = stack.pop
76
+ next unless frame
77
+
78
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ allocated = GC.stat[:total_allocated_objects] - frame.alloc_before
80
+ memory_bytes = [ObjectSpace.memsize_of_all - frame.memsize_before, 0].max
81
+ node = build_node(frame, finished_at, allocated, memory_bytes)
82
+
83
+ if stack.empty?
84
+ Thread.current[:fn_profiler_roots] << node
85
+ else
86
+ stack.last.children << node
87
+ end
88
+ end
89
+ end
90
+
91
+ @trace.enable
92
+ end
93
+
94
+ def collect
95
+ @trace&.disable
96
+ @trace = nil
97
+
98
+ stack = Thread.current[:fn_profiler_stack]
99
+ roots = Thread.current[:fn_profiler_roots] || []
100
+ count = Thread.current[:fn_profiler_count] || 0
101
+ max_frames = Profiler.function_profiling_max_frames
102
+
103
+ # Flush any frames still on the stack (e.g. if an exception was raised)
104
+ if stack&.any?
105
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ until stack.empty?
107
+ frame = stack.pop
108
+ allocated = GC.stat[:total_allocated_objects] - frame.alloc_before
109
+ memory_bytes = [ObjectSpace.memsize_of_all - frame.memsize_before, 0].max
110
+ node = build_node(frame, finished_at, allocated, memory_bytes)
111
+ if stack.empty?
112
+ roots << node
113
+ else
114
+ stack.last.children << node
115
+ end
116
+ end
117
+ end
118
+
119
+ Thread.current[:fn_profiler_stack] = nil
120
+ Thread.current[:fn_profiler_roots] = nil
121
+ Thread.current[:fn_profiler_count] = nil
122
+
123
+ stats = {}
124
+ aggregate(roots, stats)
125
+ sorted_functions = stats.values.sort_by { |s| -s[:total_duration] }
126
+
127
+ total_duration = roots.sum { |n| n[:duration] }
128
+ total_allocated = stats.values.sum { |s| s[:allocated_objects] }
129
+ total_memory = stats.values.sum { |s| s[:memory_bytes] }
130
+
131
+ store_data({
132
+ enabled: true,
133
+ max_frames: max_frames,
134
+ frame_cap_reached: count >= max_frames,
135
+ total_calls: stats.values.sum { |s| s[:calls] },
136
+ total_duration: total_duration.round(2),
137
+ total_allocated_objects: total_allocated,
138
+ total_memory_bytes: total_memory,
139
+ functions: sorted_functions.map do |s|
140
+ s.merge(
141
+ total_duration: s[:total_duration].round(3),
142
+ self_duration: s[:self_duration].round(3),
143
+ memory_bytes: s[:memory_bytes],
144
+ self_memory_bytes: s[:self_memory_bytes]
145
+ )
146
+ end,
147
+ root_calls: roots.map { |n| serialize_node(n) }
148
+ })
149
+ end
150
+
151
+ def toolbar_summary
152
+ return { text: "off", color: "gray" } unless Profiler.function_profiling_enabled
153
+ { text: "fn profiling on", color: "purple" }
154
+ end
155
+
156
+ private
157
+
158
+ def app_root_path
159
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
160
+ Rails.root.join("app").to_s
161
+ else
162
+ File.join(Dir.pwd, "app")
163
+ end
164
+ end
165
+
166
+ def relative_path(path)
167
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
168
+ path.delete_prefix(Rails.root.to_s + "/")
169
+ else
170
+ path.delete_prefix(Dir.pwd + "/")
171
+ end
172
+ end
173
+
174
+ def build_node(frame, finished_at, allocated, memory_bytes)
175
+ {
176
+ name: frame.name,
177
+ started_at: frame.started_at,
178
+ finished_at: finished_at,
179
+ duration: ((finished_at - frame.started_at) * 1000).round(3),
180
+ category: "method",
181
+ payload: {
182
+ file: frame.file,
183
+ line: frame.line,
184
+ allocated_objects: allocated,
185
+ memory_bytes: memory_bytes,
186
+ recursive: frame.recursive
187
+ },
188
+ children: frame.children
189
+ }
190
+ end
191
+
192
+ def serialize_node(node)
193
+ node.merge(children: node[:children].map { |c| serialize_node(c) })
194
+ end
195
+
196
+ def aggregate(nodes, stats)
197
+ nodes.each do |node|
198
+ key = "#{node[:name]}|#{node[:payload][:file]}:#{node[:payload][:line]}"
199
+ stats[key] ||= {
200
+ name: node[:name],
201
+ file: node[:payload][:file],
202
+ line: node[:payload][:line],
203
+ calls: 0,
204
+ recursive_calls: 0,
205
+ total_duration: 0.0,
206
+ self_duration: 0.0,
207
+ allocated_objects: 0,
208
+ memory_bytes: 0,
209
+ self_memory_bytes: 0
210
+ }
211
+ s = stats[key]
212
+ s[:calls] += 1
213
+ s[:recursive_calls] += 1 if node[:payload][:recursive]
214
+ s[:total_duration] += node[:duration]
215
+ s[:allocated_objects] += node[:payload][:allocated_objects]
216
+ s[:memory_bytes] += node[:payload][:memory_bytes]
217
+
218
+ children_duration = node[:children].sum { |c| c[:duration] }
219
+ children_memory = node[:children].sum { |c| c[:payload][:memory_bytes] }
220
+ s[:self_duration] += (node[:duration] - children_duration)
221
+ s[:self_memory_bytes] += (node[:payload][:memory_bytes] - children_memory)
222
+
223
+ aggregate(node[:children], stats)
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -41,6 +41,7 @@ module Profiler
41
41
  Profiler::Collectors::CacheCollector,
42
42
  Profiler::Collectors::HttpCollector,
43
43
  Profiler::Collectors::FlameGraphCollector,
44
+ Profiler::Collectors::FunctionProfilerCollector,
44
45
  Profiler::Collectors::LogCollector,
45
46
  Profiler::Collectors::RoutesCollector,
46
47
  Profiler::Collectors::I18nCollector
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.11.1"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -8,6 +8,8 @@ module Profiler
8
8
 
9
9
  class << self
10
10
  attr_writer :configuration
11
+ attr_accessor :function_profiling_enabled
12
+ attr_accessor :function_profiling_max_frames
11
13
 
12
14
  def configuration
13
15
  @configuration ||= Configuration.new
@@ -72,6 +74,9 @@ module Profiler
72
74
  value
73
75
  end
74
76
  end
77
+
78
+ self.function_profiling_enabled = false
79
+ self.function_profiling_max_frames = 2000
75
80
  end
76
81
 
77
82
  # Require core components
@@ -84,6 +89,7 @@ require_relative "profiler/collectors/cache_collector"
84
89
  require_relative "profiler/collectors/dump_collector"
85
90
  require_relative "profiler/collectors/http_collector"
86
91
  require_relative "profiler/collectors/flamegraph_collector"
92
+ require_relative "profiler/collectors/function_profiler_collector"
87
93
  require_relative "profiler/collectors/log_collector"
88
94
  require_relative "profiler/collectors/exception_collector"
89
95
  require_relative "profiler/collectors/routes_collector"
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.11.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -108,6 +108,7 @@ files:
108
108
  - app/assets/builds/profiler.js
109
109
  - app/controllers/profiler/api/ajax_controller.rb
110
110
  - app/controllers/profiler/api/explain_controller.rb
111
+ - app/controllers/profiler/api/function_profiling_controller.rb
111
112
  - app/controllers/profiler/api/jobs_controller.rb
112
113
  - app/controllers/profiler/api/outbound_http_controller.rb
113
114
  - app/controllers/profiler/api/profiles_controller.rb
@@ -129,6 +130,7 @@ files:
129
130
  - lib/profiler/collectors/dump_collector.rb
130
131
  - lib/profiler/collectors/exception_collector.rb
131
132
  - lib/profiler/collectors/flamegraph_collector.rb
133
+ - lib/profiler/collectors/function_profiler_collector.rb
132
134
  - lib/profiler/collectors/http_collector.rb
133
135
  - lib/profiler/collectors/i18n_collector.rb
134
136
  - lib/profiler/collectors/job_collector.rb