rails-profiler 0.15.0 → 0.16.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: 4f9f680d793888c215c4b0203f3f4ceb538d415de05916400730fd86561e1c84
4
- data.tar.gz: d0d944230d9e29d4fea24b54c6ea7134d1994e6624c1e7d0994cd7900a716406
3
+ metadata.gz: 786465dc498178e1ab9256de7b02b397df7db82b4fc817fc3adbe191d8170fcd
4
+ data.tar.gz: acdeb2a1cdb00a59429bdf14a75f0551c4e94e65787b0640096dd5e044520999
5
5
  SHA512:
6
- metadata.gz: b46d2fd2ecc46a051a5616115a0c2ef8b4da155b009ac5734e5e44fe355e14b2b30c4cdeaf52721d403436eebb5076ca6b3c6c3343407d326cdae17394607df7
7
- data.tar.gz: fc1e2d5d01929200d5fe4f705db509fce7daed7ed13f8a474b31196e397bf797b912a85add2c0f2ae662f3d90b94f65e8dcb383ea142f1706420dfe68b4a961f
6
+ metadata.gz: 5e6e51705278a372284b5936aab067c5c1fc09d257561b2787e9d8a5847f608c1cdc2149b74259abc9f411b48e8369f5fd368fd934b13eb8c2a47efbafd27651
7
+ data.tar.gz: f2cf3377ba982ee6135a0040d4f9d1c588c7255724d72d5fde8a38ed53cba188a40a669fa28161b906e3cc401b46405a2c24aac5d5438233e854b2e3c2ec3b74
@@ -2702,6 +2702,53 @@ a.profiler-toolbar-item.profiler-text--warning::after {
2702
2702
  .profiler-fn-profiling__toggle--active:hover {
2703
2703
  background: rgba(245, 158, 11, 0.15);
2704
2704
  }
2705
+ .profiler-fn-profiling__mode-selector {
2706
+ display: inline-flex;
2707
+ border: 1px solid var(--profiler-border-strong);
2708
+ border-radius: var(--profiler-radius-sm);
2709
+ overflow: hidden;
2710
+ }
2711
+ .profiler-fn-profiling__mode-btn {
2712
+ height: 28px;
2713
+ padding: 0 12px;
2714
+ background: var(--profiler-bg-lighter);
2715
+ border: none;
2716
+ border-right: 1px solid var(--profiler-border-strong);
2717
+ color: var(--profiler-text-muted);
2718
+ font-size: var(--profiler-text-xs);
2719
+ font-weight: 600;
2720
+ font-family: var(--profiler-font-sans);
2721
+ cursor: pointer;
2722
+ transition: all var(--profiler-transition-base);
2723
+ white-space: nowrap;
2724
+ }
2725
+ .profiler-fn-profiling__mode-btn:last-child {
2726
+ border-right: none;
2727
+ }
2728
+ .profiler-fn-profiling__mode-btn:hover:not(:disabled):not(.profiler-fn-profiling__mode-btn--active) {
2729
+ background: var(--profiler-bg-elevated);
2730
+ color: var(--profiler-text);
2731
+ }
2732
+ .profiler-fn-profiling__mode-btn--active {
2733
+ background: var(--profiler-accent-bg);
2734
+ color: var(--profiler-accent);
2735
+ cursor: default;
2736
+ }
2737
+ .profiler-fn-profiling__mode-btn:disabled {
2738
+ opacity: 0.5;
2739
+ cursor: not-allowed;
2740
+ }
2741
+ .profiler-fn-profiling__cpu-pct {
2742
+ color: var(--profiler-text-subtle);
2743
+ font-size: 0.85em;
2744
+ }
2745
+ .profiler-fn-profiling__gc--medium {
2746
+ color: #f59e0b;
2747
+ }
2748
+ .profiler-fn-profiling__gc--high {
2749
+ color: #ef4444;
2750
+ font-weight: 600;
2751
+ }
2705
2752
  .profiler-fn-profiling__hint {
2706
2753
  margin: 12px 0 0;
2707
2754
  padding: 10px 14px;
@@ -2513,8 +2513,12 @@
2513
2513
  const [fnSortDir, setFnSortDir] = d2("desc");
2514
2514
  const [fnEnabled, setFnEnabled] = d2(functionProfileData?.enabled ?? false);
2515
2515
  const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
2516
+ const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
2517
+ const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
2516
2518
  const [fnToggling, setFnToggling] = d2(false);
2517
2519
  const [fnMaxFramesUpdating, setFnMaxFramesUpdating] = d2(false);
2520
+ const [fnModeUpdating, setFnModeUpdating] = d2(false);
2521
+ const [fnClockUpdating, setFnClockUpdating] = d2(false);
2518
2522
  const patchFunctionProfiling = async (patch) => {
2519
2523
  const res = await fetch("/_profiler/api/function_profiling", {
2520
2524
  method: "PATCH",
@@ -2541,6 +2545,25 @@
2541
2545
  setFnMaxFramesUpdating(false);
2542
2546
  }
2543
2547
  };
2548
+ const updateMode = async (value) => {
2549
+ setFnMode(value);
2550
+ setFnModeUpdating(true);
2551
+ try {
2552
+ const json = await patchFunctionProfiling({ mode: value });
2553
+ setFnMode(json.mode ?? value);
2554
+ } finally {
2555
+ setFnModeUpdating(false);
2556
+ }
2557
+ };
2558
+ const updateClock = async (value) => {
2559
+ setFnClockUpdating(true);
2560
+ try {
2561
+ const json = await patchFunctionProfiling({ clock: value });
2562
+ setFnClock(json.clock ?? value);
2563
+ } finally {
2564
+ setFnClockUpdating(false);
2565
+ }
2566
+ };
2544
2567
  const data = flamegraphData;
2545
2568
  y2(() => {
2546
2569
  if (!data?.root_events?.length || !canvasRef.current || !containerRef.current) return;
@@ -2623,10 +2646,16 @@
2623
2646
  toggling: fnToggling,
2624
2647
  maxFrames: fnMaxFrames,
2625
2648
  maxFramesUpdating: fnMaxFramesUpdating,
2649
+ mode: fnMode,
2650
+ modeUpdating: fnModeUpdating,
2651
+ clock: fnClock,
2652
+ clockUpdating: fnClockUpdating,
2626
2653
  sortKey: fnSortKey,
2627
2654
  sortDir: fnSortDir,
2628
2655
  onToggle: toggleFunctionProfiling,
2629
2656
  onMaxFramesChange: updateMaxFrames,
2657
+ onModeChange: updateMode,
2658
+ onClockChange: updateClock,
2630
2659
  onSortChange: (key, dir) => {
2631
2660
  setFnSortKey(key);
2632
2661
  setFnSortDir(dir);
@@ -2666,10 +2695,16 @@
2666
2695
  toggling: fnToggling,
2667
2696
  maxFrames: fnMaxFrames,
2668
2697
  maxFramesUpdating: fnMaxFramesUpdating,
2698
+ mode: fnMode,
2699
+ modeUpdating: fnModeUpdating,
2700
+ clock: fnClock,
2701
+ clockUpdating: fnClockUpdating,
2669
2702
  sortKey: fnSortKey,
2670
2703
  sortDir: fnSortDir,
2671
2704
  onToggle: toggleFunctionProfiling,
2672
2705
  onMaxFramesChange: updateMaxFrames,
2706
+ onModeChange: updateMode,
2707
+ onClockChange: updateClock,
2673
2708
  onSortChange: (key, dir) => {
2674
2709
  setFnSortKey(key);
2675
2710
  setFnSortDir(dir);
@@ -2755,10 +2790,16 @@
2755
2790
  toggling: fnToggling,
2756
2791
  maxFrames: fnMaxFrames,
2757
2792
  maxFramesUpdating: fnMaxFramesUpdating,
2793
+ mode: fnMode,
2794
+ modeUpdating: fnModeUpdating,
2795
+ clock: fnClock,
2796
+ clockUpdating: fnClockUpdating,
2758
2797
  sortKey: fnSortKey,
2759
2798
  sortDir: fnSortDir,
2760
2799
  onToggle: toggleFunctionProfiling,
2761
2800
  onMaxFramesChange: updateMaxFrames,
2801
+ onModeChange: updateMode,
2802
+ onClockChange: updateClock,
2762
2803
  onSortChange: (key, dir) => {
2763
2804
  setFnSortKey(key);
2764
2805
  setFnSortDir(dir);
@@ -2780,15 +2821,23 @@
2780
2821
  }
2781
2822
  return null;
2782
2823
  }
2783
- function FunctionProfilingSection({ data, enabled, toggling, maxFrames, maxFramesUpdating, sortKey, sortDir, onToggle, onMaxFramesChange, onSortChange }) {
2824
+ function FunctionProfilingSection({ data, enabled, toggling, maxFrames, maxFramesUpdating, mode, modeUpdating, clock, clockUpdating, sortKey, sortDir, onToggle, onMaxFramesChange, onModeChange, onClockChange, onSortChange }) {
2784
2825
  const [filterNames, setFilterNames] = d2(null);
2785
2826
  const [filterLabel, setFilterLabel] = d2(null);
2786
2827
  const [hoveredFnName, setHoveredFnName] = d2(null);
2787
2828
  const fnRendererRef = A2(null);
2788
2829
  const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
2830
+ const dataMode = data?.mode ?? "full";
2831
+ const dataClock = data?.clock ?? "wall";
2832
+ const isSampling = dataMode === "lite";
2833
+ const isObjectClock = dataClock === "object";
2834
+ const showAllocated = !isSampling;
2835
+ const showMemory = dataMode === "full";
2836
+ const showClock = mode === "lite";
2837
+ const effectiveSortKey = sortKey === "memory_bytes" && !showMemory || sortKey === "allocated_objects" && !showAllocated ? "total_duration" : sortKey;
2789
2838
  const rootCalls = hasData ? data.root_calls ?? [] : [];
2790
2839
  const sortedFunctions = hasData ? [...data.functions].sort(
2791
- (a3, b) => sortDir === "asc" ? a3[sortKey] - b[sortKey] : b[sortKey] - a3[sortKey]
2840
+ (a3, b) => sortDir === "asc" ? a3[effectiveSortKey] - b[effectiveSortKey] : b[effectiveSortKey] - a3[effectiveSortKey]
2792
2841
  ) : [];
2793
2842
  const displayedFunctions = filterNames ? sortedFunctions.filter((fn) => filterNames.has(fn.name)) : sortedFunctions;
2794
2843
  const handleFrameSelect = (node) => {
@@ -2808,7 +2857,7 @@
2808
2857
  }
2809
2858
  };
2810
2859
  const sortIcon = (key) => {
2811
- if (sortKey !== key) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
2860
+ if (effectiveSortKey !== key) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
2812
2861
  return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: sortDir === "asc" ? "\u25B2" : "\u25BC" });
2813
2862
  };
2814
2863
  const handleMaxFramesBlur = (e3) => {
@@ -2826,6 +2875,28 @@
2826
2875
  /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__header", children: [
2827
2876
  /* @__PURE__ */ u3("span", { class: `profiler-fn-profiling__title${enabled ? " profiler-fn-profiling__title--active" : ""}`, children: "Function Profiling" }),
2828
2877
  /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__controls", children: [
2878
+ /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__mode-selector", children: ["full", "lite"].map((m3) => /* @__PURE__ */ u3(
2879
+ "button",
2880
+ {
2881
+ class: `profiler-fn-profiling__mode-btn${mode === m3 ? " profiler-fn-profiling__mode-btn--active" : ""}`,
2882
+ onClick: () => mode !== m3 && onModeChange(m3),
2883
+ disabled: modeUpdating,
2884
+ title: m3 === "full" ? "Exhaustive TracePoint \u2014 timing + allocations + memory bytes" : "StackProf sampling \u2014 very low overhead",
2885
+ children: m3.charAt(0).toUpperCase() + m3.slice(1)
2886
+ },
2887
+ m3
2888
+ )) }),
2889
+ showClock && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__mode-selector", children: ["wall", "cpu", "object"].map((c3) => /* @__PURE__ */ u3(
2890
+ "button",
2891
+ {
2892
+ class: `profiler-fn-profiling__mode-btn${clock === c3 ? " profiler-fn-profiling__mode-btn--active" : ""}`,
2893
+ onClick: () => clock !== c3 && onClockChange(c3),
2894
+ disabled: clockUpdating,
2895
+ title: c3 === "wall" ? "Wall-clock time (includes I/O waits)" : c3 === "cpu" ? "CPU time only (excludes I/O waits)" : "Object allocations per function",
2896
+ children: c3 === "wall" ? "Wall" : c3 === "cpu" ? "CPU" : "Alloc"
2897
+ },
2898
+ c3
2899
+ )) }),
2829
2900
  /* @__PURE__ */ u3("label", { class: "profiler-fn-profiling__max-frames-label", children: [
2830
2901
  "Max frames",
2831
2902
  /* @__PURE__ */ u3(
@@ -2853,7 +2924,15 @@
2853
2924
  )
2854
2925
  ] })
2855
2926
  ] }),
2856
- !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." }),
2927
+ !enabled && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
2928
+ "Enable function profiling to see where your app spends time.",
2929
+ " ",
2930
+ /* @__PURE__ */ u3("strong", { children: "Lite" }),
2931
+ ": statistical sampling via stackprof \u2014 very low overhead (<1%), enabled by default.",
2932
+ " ",
2933
+ /* @__PURE__ */ u3("strong", { children: "Full" }),
2934
+ ": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
2935
+ ] }),
2857
2936
  enabled && !hasData && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
2858
2937
  hasData && /* @__PURE__ */ u3(k, { children: [
2859
2938
  data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
@@ -2867,18 +2946,54 @@
2867
2946
  /* @__PURE__ */ u3("span", { class: "stat-value", children: data.functions.length })
2868
2947
  ] }),
2869
2948
  /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2870
- /* @__PURE__ */ u3("span", { class: "stat-label", children: "Total Calls" }),
2949
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: isSampling ? "Total Samples" : "Total Calls" }),
2871
2950
  /* @__PURE__ */ u3("span", { class: "stat-value", children: data.total_calls })
2872
2951
  ] }),
2873
- /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2874
- /* @__PURE__ */ u3("span", { class: "stat-label", children: "Total Duration" }),
2952
+ !isObjectClock && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2953
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Wall" }),
2875
2954
  /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2876
- data.total_duration?.toFixed(2),
2955
+ (data.elapsed_wall_ms ?? data.total_duration ?? 0).toFixed(2),
2877
2956
  " ",
2878
2957
  /* @__PURE__ */ u3("small", { children: "ms" })
2879
2958
  ] })
2880
2959
  ] }),
2881
- /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2960
+ isSampling && !isObjectClock && data.elapsed_cpu_ms != null && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2961
+ /* @__PURE__ */ u3(
2962
+ "span",
2963
+ {
2964
+ class: "stat-label",
2965
+ title: "CPU time excludes I/O waits (DB, network, sleep). Low CPU% = I/O-bound.",
2966
+ children: "CPU"
2967
+ }
2968
+ ),
2969
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2970
+ data.elapsed_cpu_ms.toFixed(2),
2971
+ " ",
2972
+ /* @__PURE__ */ u3("small", { children: "ms" }),
2973
+ data.elapsed_wall_ms != null && data.elapsed_wall_ms > 0 && /* @__PURE__ */ u3("small", { class: "profiler-fn-profiling__cpu-pct", children: [
2974
+ " ",
2975
+ "(",
2976
+ Math.round(data.elapsed_cpu_ms / data.elapsed_wall_ms * 100),
2977
+ "%)"
2978
+ ] })
2979
+ ] })
2980
+ ] }),
2981
+ isObjectClock && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2982
+ /* @__PURE__ */ u3("span", { class: "stat-label", children: "Allocations" }),
2983
+ /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2984
+ data.total_duration?.toLocaleString(),
2985
+ " ",
2986
+ /* @__PURE__ */ u3("small", { children: "obj" })
2987
+ ] })
2988
+ ] }),
2989
+ isSampling && (data.gc_overhead_pct ?? 0) > 0 && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2990
+ /* @__PURE__ */ u3("span", { class: "stat-label", title: `${data.gc_samples} samples during GC`, children: "GC" }),
2991
+ /* @__PURE__ */ u3("span", { class: `stat-value${(data.gc_overhead_pct ?? 0) >= 20 ? " profiler-fn-profiling__gc--high" : (data.gc_overhead_pct ?? 0) >= 5 ? " profiler-fn-profiling__gc--medium" : ""}`, children: [
2992
+ data.gc_overhead_pct,
2993
+ "%"
2994
+ ] })
2995
+ ] }),
2996
+ showAllocated && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2882
2997
  /* @__PURE__ */ u3("span", { class: "stat-label", children: "Allocated" }),
2883
2998
  /* @__PURE__ */ u3("span", { class: "stat-value", children: [
2884
2999
  data.total_allocated_objects?.toLocaleString(),
@@ -2886,7 +3001,7 @@
2886
3001
  /* @__PURE__ */ u3("small", { children: "obj" })
2887
3002
  ] })
2888
3003
  ] }),
2889
- /* @__PURE__ */ u3("div", { class: "stat-item", children: [
3004
+ showMemory && /* @__PURE__ */ u3("div", { class: "stat-item", children: [
2890
3005
  /* @__PURE__ */ u3("span", { class: "stat-label", children: "Memory" }),
2891
3006
  /* @__PURE__ */ u3("span", { class: "stat-value", children: formatBytes3(data.total_memory_bytes ?? 0) })
2892
3007
  ] })
@@ -2918,23 +3033,26 @@
2918
3033
  /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
2919
3034
  /* @__PURE__ */ u3("th", { children: "Function" }),
2920
3035
  /* @__PURE__ */ u3("th", { children: "File" }),
2921
- /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "calls" ? " sortable--active" : ""}`, onClick: () => handleColClick("calls"), children: [
2922
- "Calls ",
3036
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${effectiveSortKey === "calls" ? " sortable--active" : ""}`, onClick: () => handleColClick("calls"), children: [
3037
+ isSampling ? "Samples" : "Calls",
3038
+ " ",
2923
3039
  sortIcon("calls")
2924
3040
  ] }),
2925
- /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "total_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("total_duration"), children: [
2926
- "Total Time ",
3041
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${effectiveSortKey === "total_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("total_duration"), children: [
3042
+ isObjectClock ? "Total Alloc" : "Total Time",
3043
+ " ",
2927
3044
  sortIcon("total_duration")
2928
3045
  ] }),
2929
- /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "self_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("self_duration"), children: [
2930
- "Self Time ",
3046
+ /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${effectiveSortKey === "self_duration" ? " sortable--active" : ""}`, onClick: () => handleColClick("self_duration"), children: [
3047
+ isObjectClock ? "Self Alloc" : "Self Time",
3048
+ " ",
2931
3049
  sortIcon("self_duration")
2932
3050
  ] }),
2933
- /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "allocated_objects" ? " sortable--active" : ""}`, onClick: () => handleColClick("allocated_objects"), children: [
3051
+ showAllocated && /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${effectiveSortKey === "allocated_objects" ? " sortable--active" : ""}`, onClick: () => handleColClick("allocated_objects"), children: [
2934
3052
  "Objects ",
2935
3053
  sortIcon("allocated_objects")
2936
3054
  ] }),
2937
- /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${sortKey === "memory_bytes" ? " sortable--active" : ""}`, onClick: () => handleColClick("memory_bytes"), children: [
3055
+ showMemory && /* @__PURE__ */ u3("th", { class: `profiler-text--right sortable${effectiveSortKey === "memory_bytes" ? " sortable--active" : ""}`, onClick: () => handleColClick("memory_bytes"), children: [
2938
3056
  "Memory ",
2939
3057
  sortIcon("memory_bytes")
2940
3058
  ] })
@@ -2970,20 +3088,28 @@
2970
3088
  fn.line
2971
3089
  ] }),
2972
3090
  /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: fn.calls }),
2973
- /* @__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: [
3091
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: isObjectClock ? /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
3092
+ fn.total_duration.toLocaleString(),
3093
+ " ",
3094
+ /* @__PURE__ */ u3("small", { children: "obj" })
3095
+ ] }) : /* @__PURE__ */ u3("span", { class: fn.total_duration >= 100 ? "badge-error" : fn.total_duration >= 10 ? "badge-warning" : "badge-success", children: [
2974
3096
  fn.total_duration.toFixed(2),
2975
3097
  " ms"
2976
3098
  ] }) }),
2977
- /* @__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: [
3099
+ /* @__PURE__ */ u3("td", { class: "profiler-text--right", children: isObjectClock ? /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
3100
+ fn.self_duration.toLocaleString(),
3101
+ " ",
3102
+ /* @__PURE__ */ u3("small", { children: "obj" })
3103
+ ] }) : /* @__PURE__ */ u3("span", { class: fn.self_duration >= 50 ? "badge-error" : fn.self_duration >= 5 ? "badge-warning" : "badge-success", children: [
2978
3104
  fn.self_duration.toFixed(2),
2979
3105
  " ms"
2980
3106
  ] }) }),
2981
- /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: [
3107
+ showAllocated && /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: [
2982
3108
  fn.allocated_objects.toLocaleString(),
2983
3109
  " ",
2984
3110
  /* @__PURE__ */ u3("small", { children: "obj" })
2985
3111
  ] }),
2986
- /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: formatBytes3(fn.memory_bytes) })
3112
+ showMemory && /* @__PURE__ */ u3("td", { class: "profiler-text--right profiler-text--muted", children: formatBytes3(fn.memory_bytes) })
2987
3113
  ]
2988
3114
  },
2989
3115
  i3
@@ -7,8 +7,10 @@ module Profiler
7
7
 
8
8
  def show
9
9
  render json: {
10
- enabled: Profiler.function_profiling_enabled,
11
- max_frames: Profiler.function_profiling_max_frames
10
+ enabled: Profiler.function_profiling_enabled,
11
+ max_frames: Profiler.function_profiling_max_frames,
12
+ mode: Profiler.function_profiling_mode,
13
+ clock: Profiler.function_profiling_clock
12
14
  }
13
15
  end
14
16
 
@@ -22,9 +24,19 @@ module Profiler
22
24
  Profiler.function_profiling_max_frames = max.positive? ? max : Profiler.function_profiling_max_frames
23
25
  end
24
26
 
27
+ if params.key?(:mode) && %w[full lite].include?(params[:mode])
28
+ Profiler.function_profiling_mode = params[:mode]
29
+ end
30
+
31
+ if params.key?(:clock) && %w[wall cpu object].include?(params[:clock])
32
+ Profiler.function_profiling_clock = params[:clock]
33
+ end
34
+
25
35
  render json: {
26
- enabled: Profiler.function_profiling_enabled,
27
- max_frames: Profiler.function_profiling_max_frames
36
+ enabled: Profiler.function_profiling_enabled,
37
+ max_frames: Profiler.function_profiling_max_frames,
38
+ mode: Profiler.function_profiling_mode,
39
+ clock: Profiler.function_profiling_clock
28
40
  }
29
41
  end
30
42
  end
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_collector"
4
- require "objspace"
4
+
5
+ begin
6
+ require "stackprof"
7
+ rescue LoadError
8
+ # stackprof not available — lite mode will fall back to TracePoint without memory tracking
9
+ end
5
10
 
6
11
  module Profiler
7
12
  module Collectors
8
13
  class FunctionProfilerCollector < BaseCollector
9
14
  CallFrame = Struct.new(:name, :file, :line, :started_at, :alloc_before, :memsize_before, :recursive, :children)
10
15
 
16
+ GC_FRAME_NAME = "(garbage collection)"
17
+
11
18
  def name
12
19
  "function_profile"
13
20
  end
@@ -37,15 +44,227 @@ module Profiler
37
44
 
38
45
  def subscribe
39
46
  unless Profiler.function_profiling_enabled
40
- store_data({ enabled: false, max_frames: Profiler.function_profiling_max_frames })
47
+ store_data({
48
+ enabled: false,
49
+ max_frames: Profiler.function_profiling_max_frames,
50
+ mode: Profiler.function_profiling_mode,
51
+ clock: Profiler.function_profiling_clock
52
+ })
53
+ return
54
+ end
55
+
56
+ mode = Profiler.function_profiling_mode
57
+ clock = Profiler.function_profiling_clock
58
+ Thread.current[:fn_profiler_mode] = mode
59
+ Thread.current[:fn_profiler_clock] = clock
60
+
61
+ if mode == "lite" && defined?(StackProf)
62
+ # Always record wall + cpu at request level for the comparison stat
63
+ Thread.current[:fn_profiler_wall_start] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ Thread.current[:fn_profiler_cpu_start] = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
65
+
66
+ sp_mode = case clock
67
+ when "cpu" then :cpu
68
+ when "object" then :object
69
+ else :wall
70
+ end
71
+ StackProf.start(mode: sp_mode, interval: 1000, raw: true)
72
+ else
73
+ subscribe_tracepoint(mode)
74
+ end
75
+ end
76
+
77
+ def collect
78
+ mode = Thread.current[:fn_profiler_mode] || Profiler.function_profiling_mode
79
+ clock = Thread.current[:fn_profiler_clock] || Profiler.function_profiling_clock
80
+ Thread.current[:fn_profiler_mode] = nil
81
+ Thread.current[:fn_profiler_clock] = nil
82
+
83
+ if mode == "lite" && defined?(StackProf)
84
+ StackProf.stop
85
+ result = StackProf.results
86
+ wall_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - (Thread.current[:fn_profiler_wall_start] || 0)) * 1000
87
+ cpu_ms = (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - (Thread.current[:fn_profiler_cpu_start] || 0)) * 1000
88
+ Thread.current[:fn_profiler_wall_start] = nil
89
+ Thread.current[:fn_profiler_cpu_start] = nil
90
+ collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
91
+ else
92
+ collect_tracepoint(mode)
93
+ end
94
+ end
95
+
96
+ def toolbar_summary
97
+ return { text: "off", color: "gray" } unless Profiler.function_profiling_enabled
98
+ case Profiler.function_profiling_mode
99
+ when "lite" then { text: "sampling on", color: "blue" }
100
+ else { text: "fn profiling on", color: "purple" }
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # ── StackProf (lite mode) ──────────────────────────────────────────────────
107
+
108
+ def collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
109
+ unless result
110
+ store_data({ enabled: true, mode: mode, clock: clock, max_frames: 0,
111
+ frame_cap_reached: false, total_calls: 0, total_duration: 0,
112
+ functions: [], root_calls: [] })
41
113
  return
42
114
  end
43
115
 
44
- app_root = app_root_path
116
+ frames_meta = result[:frames] || {}
117
+ raw = result[:raw] || []
118
+ total_samples = [result[:samples] || 0, 1].max
119
+ elapsed_ms = case clock
120
+ when "object" then total_samples.to_f
121
+ when "cpu" then cpu_ms
122
+ else wall_ms
123
+ end
124
+ app_root = app_root_path
125
+
126
+ # Partition frames: app frames vs GC frame
127
+ # Exclude Ruby meta-frames (<main>, <class:Foo>, #<Class:0x...> generated templates) — noise
128
+ gc_frame_ids = frames_meta.each_with_object(Set.new) { |(id, f), s| s << id if f[:name] == GC_FRAME_NAME }
129
+ app_frame_ids = frames_meta.each_with_object(Set.new) do |(id, f), s|
130
+ s << id if f[:file]&.start_with?(app_root) && !f[:name].to_s.match?(/\A[<#]/)
131
+ end
132
+
133
+ gc_samples, gc_overhead_pct = count_gc_samples(raw, gc_frame_ids, total_samples)
134
+
135
+ roots = build_sampling_tree(raw, frames_meta, app_frame_ids, total_samples, elapsed_ms)
136
+
137
+ functions = frames_meta.filter_map do |id, frame|
138
+ next unless app_frame_ids.include?(id)
139
+ next if frame[:name].to_s.match?(/\A[<#]/)
140
+ total_dur = (frame[:total_samples].to_f / total_samples) * elapsed_ms
141
+ self_dur = (frame[:samples].to_f / total_samples) * elapsed_ms
142
+ {
143
+ name: frame[:name] || id.to_s,
144
+ file: relative_path(frame[:file] || ""),
145
+ line: frame[:line] || 0,
146
+ calls: frame[:total_samples] || 0,
147
+ recursive_calls: 0,
148
+ total_duration: total_dur.round(3),
149
+ self_duration: self_dur.round(3),
150
+ allocated_objects: 0,
151
+ memory_bytes: 0,
152
+ self_memory_bytes: 0
153
+ }
154
+ end.sort_by { |f| -f[:total_duration] }
155
+
156
+ # For wall/cpu clocks, total_duration = stackprof elapsed (accurate).
157
+ # For object clock, total_duration = total allocations count.
158
+ total_duration = clock == "object" ? total_samples.to_f : elapsed_ms
159
+
160
+ store_data({
161
+ enabled: true,
162
+ mode: mode,
163
+ clock: clock,
164
+ elapsed_wall_ms: wall_ms.round(2),
165
+ elapsed_cpu_ms: cpu_ms.round(2),
166
+ gc_samples: gc_samples,
167
+ gc_overhead_pct: gc_overhead_pct,
168
+ max_frames: total_samples,
169
+ frame_cap_reached: false,
170
+ total_calls: functions.sum { |f| f[:calls] },
171
+ total_duration: total_duration.round(2),
172
+ total_allocated_objects: 0,
173
+ total_memory_bytes: 0,
174
+ functions: functions,
175
+ root_calls: roots.map { |n| serialize_node(n) }
176
+ })
177
+ end
178
+
179
+ # Count samples that contain at least one GC frame.
180
+ def count_gc_samples(raw, gc_frame_ids, total_samples)
181
+ return [0, 0.0] if gc_frame_ids.empty?
182
+
183
+ gc_count = 0
184
+ i = 0
185
+ while i < raw.length
186
+ depth = raw[i]
187
+ break if i + depth + 1 >= raw.length
188
+ stack = raw[i + 1, depth]
189
+ count = raw[i + depth + 1]
190
+ i += depth + 2
191
+ gc_count += count if stack.any? { |id| gc_frame_ids.include?(id) }
192
+ end
193
+
194
+ pct = (gc_count.to_f / total_samples * 100).round(1)
195
+ [gc_count, pct]
196
+ end
197
+
198
+ # Parse stackprof raw samples and build a call tree filtered to app/ frames.
199
+ #
200
+ # Raw format: [depth, frame[0]=outermost(caller), ..., frame[depth-1]=innermost(callee), count, ...]
201
+ # frames are already outermost-first, so no reversal needed.
202
+ def build_sampling_tree(raw, frames_meta, app_frame_ids, total_samples, elapsed_ms)
203
+ tree = {}
204
+
205
+ i = 0
206
+ while i < raw.length
207
+ depth = raw[i]
208
+ break if i + depth + 1 >= raw.length
209
+ stack = raw[i + 1, depth]
210
+ count = raw[i + depth + 1]
211
+ i += depth + 2
212
+
213
+ app_stack = stack.select { |id| app_frame_ids.include?(id) }
214
+ next if app_stack.empty?
215
+
216
+ current = tree
217
+ app_stack.each do |frame_id|
218
+ current[frame_id] ||= { samples: 0, children: {} }
219
+ current[frame_id][:samples] += count
220
+ current = current[frame_id][:children]
221
+ end
222
+ end
223
+
224
+ nodes_from_sampling_tree(tree, frames_meta, total_samples, elapsed_ms, 0.0)
225
+ end
226
+
227
+ def nodes_from_sampling_tree(tree_hash, frames_meta, total_samples, elapsed_ms, parent_offset)
228
+ nodes = []
229
+ cursor = parent_offset
230
+
231
+ tree_hash.each do |frame_id, node|
232
+ meta = frames_meta[frame_id] || {}
233
+ duration = (node[:samples].to_f / total_samples) * elapsed_ms
234
+ children = nodes_from_sampling_tree(node[:children], frames_meta, total_samples, elapsed_ms, cursor)
235
+
236
+ nodes << {
237
+ name: meta[:name] || frame_id.to_s,
238
+ started_at: cursor / 1000.0,
239
+ finished_at: (cursor + duration) / 1000.0,
240
+ duration: duration.round(3),
241
+ category: "method",
242
+ payload: {
243
+ file: relative_path(meta[:file] || ""),
244
+ line: meta[:line] || 0,
245
+ allocated_objects: 0,
246
+ memory_bytes: 0,
247
+ recursive: false,
248
+ samples: node[:samples]
249
+ },
250
+ children: children
251
+ }
252
+ cursor += duration
253
+ end
254
+
255
+ nodes
256
+ end
257
+
258
+ # ── TracePoint (full mode) ────────────────────────────────────────────────
259
+
260
+ def subscribe_tracepoint(mode)
261
+ app_root = app_root_path
45
262
  max_frames = Profiler.function_profiling_max_frames
263
+
46
264
  Thread.current[:fn_profiler_stack] = []
47
265
  Thread.current[:fn_profiler_roots] = []
48
266
  Thread.current[:fn_profiler_count] = 0
267
+ Thread.current[:fn_profiler_depth] = Hash.new(0)
49
268
 
50
269
  @trace = TracePoint.new(:call, :return) do |tp|
51
270
  next unless tp.path&.start_with?(app_root)
@@ -58,8 +277,9 @@ module Profiler
58
277
  count = (Thread.current[:fn_profiler_count] += 1)
59
278
  next if count > max_frames
60
279
 
61
- fn_name = "#{tp.defined_class}##{tp.method_id}"
62
- is_recursive = stack.any? { |f| f.name == fn_name }
280
+ fn_name = "#{tp.defined_class}##{tp.method_id}"
281
+ depth_map = Thread.current[:fn_profiler_depth]
282
+ is_recursive = (depth_map[fn_name] += 1) > 1
63
283
 
64
284
  stack.push(CallFrame.new(
65
285
  fn_name,
@@ -67,7 +287,7 @@ module Profiler
67
287
  tp.lineno,
68
288
  Process.clock_gettime(Process::CLOCK_MONOTONIC),
69
289
  GC.stat[:total_allocated_objects],
70
- ObjectSpace.memsize_of_all,
290
+ GC.stat[:oldmalloc_increase_bytes],
71
291
  is_recursive,
72
292
  []
73
293
  ))
@@ -75,9 +295,10 @@ module Profiler
75
295
  frame = stack.pop
76
296
  next unless frame
77
297
 
78
- finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
298
+ Thread.current[:fn_profiler_depth][frame.name] -= 1
299
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
300
  allocated = GC.stat[:total_allocated_objects] - frame.alloc_before
80
- memory_bytes = [ObjectSpace.memsize_of_all - frame.memsize_before, 0].max
301
+ memory_bytes = [GC.stat[:oldmalloc_increase_bytes] - frame.memsize_before, 0].max
81
302
  node = build_node(frame, finished_at, allocated, memory_bytes)
82
303
 
83
304
  if stack.empty?
@@ -91,22 +312,21 @@ module Profiler
91
312
  @trace.enable
92
313
  end
93
314
 
94
- def collect
315
+ def collect_tracepoint(mode)
95
316
  @trace&.disable
96
317
  @trace = nil
97
318
 
98
- stack = Thread.current[:fn_profiler_stack]
99
- roots = Thread.current[:fn_profiler_roots] || []
100
- count = Thread.current[:fn_profiler_count] || 0
319
+ stack = Thread.current[:fn_profiler_stack]
320
+ roots = Thread.current[:fn_profiler_roots] || []
321
+ count = Thread.current[:fn_profiler_count] || 0
101
322
  max_frames = Profiler.function_profiling_max_frames
102
323
 
103
- # Flush any frames still on the stack (e.g. if an exception was raised)
104
324
  if stack&.any?
105
325
  finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
326
  until stack.empty?
107
327
  frame = stack.pop
108
328
  allocated = GC.stat[:total_allocated_objects] - frame.alloc_before
109
- memory_bytes = [ObjectSpace.memsize_of_all - frame.memsize_before, 0].max
329
+ memory_bytes = [GC.stat[:oldmalloc_increase_bytes] - frame.memsize_before, 0].max
110
330
  node = build_node(frame, finished_at, allocated, memory_bytes)
111
331
  if stack.empty?
112
332
  roots << node
@@ -116,31 +336,38 @@ module Profiler
116
336
  end
117
337
  end
118
338
 
119
- Thread.current[:fn_profiler_stack] = nil
120
- Thread.current[:fn_profiler_roots] = nil
121
- Thread.current[:fn_profiler_count] = nil
339
+ Thread.current[:fn_profiler_stack] = nil
340
+ Thread.current[:fn_profiler_roots] = nil
341
+ Thread.current[:fn_profiler_count] = nil
342
+ Thread.current[:fn_profiler_depth] = nil
122
343
 
123
344
  stats = {}
124
345
  aggregate(roots, stats)
125
346
  sorted_functions = stats.values.sort_by { |s| -s[:total_duration] }
126
347
 
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] }
348
+ total_duration = roots.sum { |n| n[:duration] }
349
+ total_allocated = stats.values.sum { |s| s[:allocated_objects] }
350
+ total_memory = stats.values.sum { |s| s[:memory_bytes] }
130
351
 
131
352
  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),
353
+ enabled: true,
354
+ mode: mode,
355
+ clock: "wall",
356
+ elapsed_wall_ms: total_duration.round(2),
357
+ elapsed_cpu_ms: nil,
358
+ gc_samples: 0,
359
+ gc_overhead_pct: 0.0,
360
+ max_frames: max_frames,
361
+ frame_cap_reached: count >= max_frames,
362
+ total_calls: stats.values.sum { |s| s[:calls] },
363
+ total_duration: total_duration.round(2),
137
364
  total_allocated_objects: total_allocated,
138
- total_memory_bytes: total_memory,
365
+ total_memory_bytes: total_memory,
139
366
  functions: sorted_functions.map do |s|
140
367
  s.merge(
141
- total_duration: s[:total_duration].round(3),
142
- self_duration: s[:self_duration].round(3),
143
- memory_bytes: s[:memory_bytes],
368
+ total_duration: s[:total_duration].round(3),
369
+ self_duration: s[:self_duration].round(3),
370
+ memory_bytes: s[:memory_bytes],
144
371
  self_memory_bytes: s[:self_memory_bytes]
145
372
  )
146
373
  end,
@@ -148,12 +375,7 @@ module Profiler
148
375
  })
149
376
  end
150
377
 
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
378
+ # ── Shared helpers ─────────────────────────────────────────────────────────
157
379
 
158
380
  def app_root_path
159
381
  if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.15.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -10,6 +10,8 @@ module Profiler
10
10
  attr_writer :configuration
11
11
  attr_accessor :function_profiling_enabled
12
12
  attr_accessor :function_profiling_max_frames
13
+ attr_accessor :function_profiling_mode
14
+ attr_accessor :function_profiling_clock
13
15
 
14
16
  def configuration
15
17
  @configuration ||= Configuration.new
@@ -75,8 +77,10 @@ module Profiler
75
77
  end
76
78
  end
77
79
 
78
- self.function_profiling_enabled = false
80
+ self.function_profiling_enabled = true
79
81
  self.function_profiling_max_frames = 2000
82
+ self.function_profiling_mode = "lite"
83
+ self.function_profiling_clock = "wall"
80
84
  end
81
85
 
82
86
  # Require core components
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-11 00:00:00.000000000 Z
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails