Class: Rperf::Viewer
- Inherits:
-
Object
- Object
- Rperf::Viewer
- Defined in:
- lib/rperf/viewer.rb
Overview
Rack middleware that serves flamegraph visualizations of rperf snapshots.
*Security note*: This middleware exposes profiling data without authentication. It is intended for development and staging environments. In production, place it behind an authenticated reverse proxy or restrict access by IP / VPN.
Usage:
require "rperf/viewer"
use Rperf::Viewer # mount at /rperf (default)
use Rperf::Viewer, path: "/profiler" # custom mount path
use Rperf::Viewer, max_snapshots: 12 # keep fewer snapshots
Take snapshots periodically:
viewer = Rperf::Viewer.instance
viewer.take_snapshot! # snapshot with clear: true
viewer.add_snapshot(data) # or add pre-taken snapshot data
Time-travel mode (multiple snapshots from a directory):
viewer.add_snapshot_dir("./profiles") # *.json(.gz) files; lazy-loaded
The UI fetches data from /snapshots (list with meta/summary only) and /snapshots/:id (full body). When more than one snapshot is present, a sidebar lists them with commit info and diff/pin/sparkline support.
Class Attribute Summary collapse
-
.instance ⇒ Object
readonly
Returns the most recently created Viewer instance.
Instance Attribute Summary collapse
-
#max_snapshots ⇒ Object
readonly
Returns the value of attribute max_snapshots.
-
#path ⇒ Object
readonly
Returns the value of attribute path.
Class Method Summary collapse
-
.render_static_html(data) ⇒ Object
Generate a self-contained static HTML file with inline snapshot data.
-
.samples_to_json(samples, label_sets) ⇒ Object
Convert aggregated samples to JSON-friendly format.
Instance Method Summary collapse
-
#add_snapshot(data) ⇒ Object
Add a pre-taken snapshot hash to the history.
-
#add_snapshot_dir(dir) ⇒ Object
Add all *.json(.gz) profiles in dir as lazy-loaded snapshots (time-travel mode).
-
#call(env) ⇒ Object
Rack interface.
-
#initialize(app, path: "/rperf", max_snapshots: 24) ⇒ Viewer
constructor
A new instance of Viewer.
-
#take_snapshot! ⇒ Object
Take a snapshot from the running profiler and store it.
Constructor Details
#initialize(app, path: "/rperf", max_snapshots: 24) ⇒ Viewer
Returns a new instance of Viewer.
39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/rperf/viewer.rb', line 39 def initialize(app, path: "/rperf", max_snapshots: 24) raise ArgumentError, "max_snapshots must be a positive integer, got #{max_snapshots.inspect}" unless max_snapshots.is_a?(Integer) && max_snapshots > 0 @app = app @path = path.chomp("/") @max_snapshots = max_snapshots # In-memory entries: {id:, taken_at:, data:} # Directory entries: {id:, taken_at:, path:, meta:, summary:} — body lazy-loaded @snapshots = [] @mutex = Mutex.new @next_id = 0 self.class.instance_variable_set(:@instance, self) end |
Class Attribute Details
.instance ⇒ Object (readonly)
Returns the most recently created Viewer instance.
34 35 36 |
# File 'lib/rperf/viewer.rb', line 34 def instance @instance end |
Instance Attribute Details
#max_snapshots ⇒ Object (readonly)
Returns the value of attribute max_snapshots.
37 38 39 |
# File 'lib/rperf/viewer.rb', line 37 def max_snapshots @max_snapshots end |
#path ⇒ Object (readonly)
Returns the value of attribute path.
37 38 39 |
# File 'lib/rperf/viewer.rb', line 37 def path @path end |
Class Method Details
.render_static_html(data) ⇒ Object
Generate a self-contained static HTML file with inline snapshot data. The HTML loads d3/d3-flamegraph from CDN but requires no server. This is the one intentional exception to fetch-based data loading: a static file has no server to fetch from.
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/rperf/viewer.rb', line 164 def self.render_static_html(data) samples = data[:aggregated_samples] || [] label_sets = data[:label_sets] || [] json_samples, json_label_sets = samples_to_json(samples, label_sets) json_snapshot = JSON.generate({ id: 1, taken_at: Time.now.iso8601, mode: data[:mode], frequency: data[:frequency], duration_ns: data[:duration_ns], sampling_count: data[:sampling_count], meta: data[:meta], summary: data[:summary], samples: json_samples, label_sets: json_label_sets, }) logo = LOGO_SVG.sub("<svg ", '<svg style="height:36px;width:auto" ') html = VIEWER_HTML.sub("<!-- LOGO -->") { logo } # Hide the snapshot selector including its "Snapshot:" label text # (single snapshot, no server) html = html.sub('<label id="lbl-snapshot">', '<label id="lbl-snapshot" style="display:none">') # Replace dynamic loading with inline data. # Escape for safe embedding in <script>: # - "</" prevents closing </script> tag injection # - U+2028/U+2029 are line terminators in JS but valid in JSON json_safe = json_snapshot .gsub("</", "<\\/") .gsub(" ", "\\u2028") .gsub(" ", "\\u2029") # Block form: the String-replacement form of sub interprets \\ and \& # in the replacement, corrupting JSON that contains backslashes html = html.sub("loadSnapshotList().catch(showLoadError);") { "currentData = #{json_safe}; updateTagDropdowns(); applyAndRender();" } html end |
.samples_to_json(samples, label_sets) ⇒ Object
Convert aggregated samples to JSON-friendly format. Stack is stored top-to-bottom (leaf first) in C; reverse to root-first for flamegraph. Label set keys are converted from symbols to strings for JSON.
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/rperf/viewer.rb', line 144 def self.samples_to_json(samples, label_sets) json_samples = samples.map do |frames, weight, _thread_seq, label_set_id| # thread_seq is intentionally omitted: the viewer UI never reads it, # and it would bloat the largest responses the viewer serves { stack: frames.reverse.map { |_, label| label }, weight: weight, label_set_id: label_set_id || 0, } end json_label_sets = label_sets.map do |ls| ls.is_a?(Hash) ? ls.transform_keys(&:to_s) : ls end [json_samples, json_label_sets] end |
Instance Method Details
#add_snapshot(data) ⇒ Object
Add a pre-taken snapshot hash to the history. Attaches meta/summary (phase-1 profile format) unless already present, so in-memory snapshots and directory profiles share the same list UI.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/rperf/viewer.rb', line 63 def add_snapshot(data) data[:meta] ||= Rperf::Meta.(data) data[:summary] ||= Rperf::Meta.build_summary(data) @mutex.synchronize do @next_id += 1 entry = { id: @next_id, taken_at: Time.now, data: data } @snapshots << entry # Evict only in-memory snapshots: directory entries (time-travel mode) # are exempt from max_snapshots and hold no body memory anyway while @snapshots.count { |s| s[:data] } > @max_snapshots idx = @snapshots.index { |s| s[:data] } @snapshots.delete_at(idx) end entry end end |
#add_snapshot_dir(dir) ⇒ Object
Add all *.json(.gz) profiles in dir as lazy-loaded snapshots (time-travel mode). Only meta/summary are read up front (Rperf.read_meta); bodies are loaded on selection. Files without meta (older rperf) are listed as unknown snapshots. Entries are sorted by meta.git.committed_at → meta.created_at → file mtime. max_snapshots does not apply. Returns the number of files added.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/rperf/viewer.rb', line 86 def add_snapshot_dir(dir) files = Dir.glob(File.join(dir, "*.json.gz")) + Dir.glob(File.join(dir, "*.json")) entries = files.filter_map do |file| head = Rperf.(file) = head && head[:meta] summary = head && head[:summary] # File may vanish between glob and stat — skip instead of aborting the # whole listing mtime = begin File.mtime(file) rescue SystemCallError next end { path: file, meta: , summary: summary, taken_at: mtime, sort_time: snapshot_sort_time(, mtime) } end entries.sort_by! { |e| e[:sort_time] } @mutex.synchronize do entries.each do |e| @next_id += 1 @snapshots << { id: @next_id, taken_at: e[:taken_at], path: e[:path], meta: e[:meta], summary: e[:summary] } end end entries.size end |
#call(env) ⇒ Object
Rack interface
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/rperf/viewer.rb', line 114 def call(env) req_path = env["PATH_INFO"] || "/" # Not our path — pass through to app if req_path == @path # Exact match: redirect to trailing slash return [301, { "location" => "#{@path}/" }, [""]] end unless req_path.start_with?("#{@path}/") return @app ? @app.call(env) : [404, { "content-type" => "text/plain" }, ["Not Found"]] end # Strip prefix to get sub-path sub_path = req_path[@path.size..] case sub_path when "/" serve_html when "/snapshots" serve_snapshot_list when %r{\A/snapshots/(\d+)\z} serve_snapshot($1.to_i) else [404, { "content-type" => "text/plain" }, ["Not Found"]] end end |