rperf 0.8.0 → 0.10.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.
@@ -1,4 +1,5 @@
1
1
  require "rperf"
2
+ require "active_support/concern"
2
3
 
3
4
  module Rperf::ActiveJobMiddleware
4
5
  extend ActiveSupport::Concern
data/lib/rperf/meta.rb ADDED
@@ -0,0 +1,343 @@
1
+ # Profile metadata support: git/host info collection, summary statistics,
2
+ # snapshot file naming, and a meta/summary prefix reader that lists profiles
3
+ # without loading the sample body.
4
+ #
5
+ # JSON profiles written by rperf >= 0.10 place "meta" and "summary" as the
6
+ # first two top-level keys, so Meta.read can decompress only the head of the
7
+ # file and stop as soon as both are extracted.
8
+
9
+ require "json"
10
+ require "time"
11
+ require "zlib"
12
+
13
+ module Rperf
14
+ module Meta
15
+ FORMAT_VERSION = 1
16
+ TOP_METHODS_LIMIT = 50
17
+
18
+ module_function
19
+
20
+ # Collect git information for the profiled working directory.
21
+ # GitHub Actions environment variables take priority over git commands
22
+ # (CI checkouts may be detached or shallow). Returns a Hash with
23
+ # sha/branch/subject/committed_at/dirty, or nil when not in a git
24
+ # repository or git is unavailable.
25
+ def collect_git(dir = Dir.pwd)
26
+ gh_sha = ENV["GITHUB_SHA"]
27
+ # Validate the sha shape: the value is passed to git as a positional
28
+ # argument, and a crafted value starting with "-" would be parsed as
29
+ # a git option
30
+ if gh_sha && gh_sha.match?(/\A\h{7,64}\z/)
31
+ git = { sha: gh_sha, dirty: false }
32
+ branch = ENV["GITHUB_HEAD_REF"]
33
+ branch = ENV["GITHUB_REF_NAME"] if branch.nil? || branch.empty?
34
+ git[:branch] = branch if branch && !branch.empty?
35
+ # Enrich from the local checkout when possible (may fail on shallow clones)
36
+ subject = git_capture(dir, "log", "-1", "--format=%s", gh_sha)
37
+ committed_at = git_capture(dir, "log", "-1", "--format=%cI", gh_sha)
38
+ git[:subject] = subject if subject && !subject.empty?
39
+ git[:committed_at] = committed_at if committed_at && !committed_at.empty?
40
+ return git
41
+ end
42
+
43
+ sha = git_capture(dir, "rev-parse", "HEAD")
44
+ return nil if sha.nil? || sha.empty?
45
+
46
+ git = { sha: sha }
47
+ branch = git_capture(dir, "rev-parse", "--abbrev-ref", "HEAD")
48
+ git[:branch] = branch if branch && !branch.empty? && branch != "HEAD"
49
+ subject = git_capture(dir, "log", "-1", "--format=%s")
50
+ git[:subject] = subject if subject && !subject.empty?
51
+ committed_at = git_capture(dir, "log", "-1", "--format=%cI")
52
+ git[:committed_at] = committed_at if committed_at && !committed_at.empty?
53
+ status = git_capture(dir, "status", "--porcelain")
54
+ git[:dirty] = !status.empty? if status
55
+ git
56
+ end
57
+
58
+ # Run a git command, returning stripped stdout or nil on failure
59
+ # (no git binary, not a repository, etc.).
60
+ def git_capture(dir, *args)
61
+ out = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read)
62
+ $?.success? ? out.strip : nil
63
+ rescue SystemCallError
64
+ nil
65
+ end
66
+
67
+ # File name used by `rperf record --snapshot-dir`.
68
+ # In a git repository: rperf-<sha7>-<timestamp>.json.gz
69
+ # Outside: rperf-nogit-<timestamp>-<pid>.json.gz
70
+ def snapshot_filename(git, time: Time.now.utc, pid: Process.pid)
71
+ ts = time.utc.strftime("%Y%m%dT%H%M%SZ")
72
+ if git && git[:sha]
73
+ "rperf-#{git[:sha][0, 7]}-#{ts}.json.gz"
74
+ else
75
+ "rperf-nogit-#{ts}-#{pid}.json.gz"
76
+ end
77
+ end
78
+
79
+ # Build the meta hash for a profile about to be written.
80
+ # Git info comes from RPERF_META_GIT (set by the CLI, which collects it
81
+ # before exec so a chdir in the profiled app cannot point at the wrong
82
+ # repository); when unset (direct API usage) it is collected here.
83
+ # RPERF_META_GIT="null" means "already checked, not a repository".
84
+ def build_meta(data)
85
+ meta = {
86
+ format_version: FORMAT_VERSION,
87
+ created_at: Time.now.utc.iso8601,
88
+ ruby_version: RUBY_VERSION,
89
+ rperf_version: Rperf::VERSION,
90
+ mode: (data[:mode] || :cpu).to_s,
91
+ }
92
+ hostname = safe_hostname
93
+ meta[:hostname] = hostname if hostname
94
+ git = git_from_env_or_collect
95
+ meta[:git] = git if git
96
+ labels = labels_from_env
97
+ meta[:labels] = labels if labels && !labels.empty?
98
+ meta
99
+ end
100
+
101
+ def git_from_env_or_collect
102
+ if ENV.key?("RPERF_META_GIT")
103
+ v = ENV["RPERF_META_GIT"].to_s
104
+ return nil if v.empty? || v == "null"
105
+ begin
106
+ JSON.parse(v, symbolize_names: true)
107
+ rescue JSON::ParserError
108
+ nil
109
+ end
110
+ else
111
+ # Memoized (array wraps a legitimate nil): periodic viewer snapshots
112
+ # must not spawn git subprocesses on every take_snapshot!
113
+ @collect_git_memo ||= [collect_git]
114
+ @collect_git_memo[0]
115
+ end
116
+ end
117
+
118
+ def labels_from_env
119
+ v = ENV["RPERF_META_LABELS"]
120
+ return nil unless v
121
+ begin
122
+ labels = JSON.parse(v)
123
+ labels.is_a?(Hash) ? labels : nil
124
+ rescue JSON::ParserError
125
+ nil
126
+ end
127
+ end
128
+
129
+ def safe_hostname
130
+ require "socket"
131
+ Socket.gethostname
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ # Build the summary hash from profile data (as returned by Rperf.stop).
137
+ # Fields whose source data is missing are omitted.
138
+ def build_summary(data)
139
+ s = {}
140
+ s[:total_ms] = (data[:duration_ns] / 1e6).round(1) if data[:duration_ns]
141
+ if data[:user_ns] || data[:sys_ns]
142
+ s[:cpu_ms] = (((data[:user_ns] || 0) + (data[:sys_ns] || 0)) / 1e6).round(1)
143
+ end
144
+ if (gc = data[:gc_stats])
145
+ s[:gc_count_minor] = gc[:minor_count] if gc[:minor_count]
146
+ s[:gc_count_major] = gc[:major_count] if gc[:major_count]
147
+ s[:gc_ms] = gc[:time_ms].to_f.round(1) if gc[:time_ms]
148
+ s[:allocated_objects] = gc[:allocated_objects] if gc[:allocated_objects]
149
+ s[:freed_objects] = gc[:freed_objects] if gc[:freed_objects]
150
+ end
151
+ s[:maxrss_mb] = data[:maxrss_mb] if data[:maxrss_mb]
152
+ s[:samples] = data[:sampling_count] if data[:sampling_count]
153
+ s[:top_methods] = top_methods(data)
154
+ s
155
+ end
156
+
157
+ # Top methods by self time, merged by method name (shares the by-name
158
+ # fold with Table so report/summary numbers can never diverge).
159
+ def top_methods(data, limit: TOP_METHODS_LIMIT)
160
+ samples = data[:aggregated_samples]
161
+ return [] if !samples || samples.empty?
162
+
163
+ flat_by_name, cum_by_name, total = Table.flat_cum_by_name(data)
164
+ return [] if total <= 0
165
+
166
+ flat_by_name.sort_by { |_, w| -w }.first(limit).map do |name, w|
167
+ {
168
+ name: name,
169
+ self_pct: (w * 100.0 / total).round(1),
170
+ total_pct: (cum_by_name[name] * 100.0 / total).round(1),
171
+ }
172
+ end
173
+ end
174
+
175
+ # --- meta/summary prefix reader ---
176
+
177
+ READ_CHUNK = 64 * 1024
178
+ READ_LIMIT = 8 * 1024 * 1024
179
+
180
+ # Read meta/summary from a .json(.gz) profile without loading the body.
181
+ # Returns { meta: Hash|nil, summary: Hash|nil } or nil for files without
182
+ # leading meta/summary keys (old format) and unreadable/corrupt files.
183
+ def read(path)
184
+ File.open(path, "rb") do |f|
185
+ magic = f.read(2)
186
+ f.rewind
187
+ io = (magic == "\x1f\x8b".b) ? Zlib::GzipReader.new(f) : f
188
+ begin
189
+ buf = "".b
190
+ loop do
191
+ chunk = io.read(READ_CHUNK)
192
+ buf << chunk if chunk
193
+ result = scan_prefix(buf)
194
+ return result unless result == :incomplete
195
+ return nil if chunk.nil? || buf.bytesize > READ_LIMIT
196
+ end
197
+ ensure
198
+ # Free the inflate zstream now — directory listings open many files
199
+ # and the buffers would otherwise linger until GC
200
+ io.close if io.is_a?(Zlib::GzipReader)
201
+ end
202
+ end
203
+ rescue Zlib::Error, SystemCallError, JSON::ParserError
204
+ # Zlib::Error covers GzipFile::Error (truncated) and also DataError /
205
+ # BufError (valid gzip header, corrupt deflate body) — one corrupt
206
+ # snapshot must not break listing an entire directory
207
+ nil
208
+ end
209
+
210
+ # Byte codes used by the scanner. Byte-wise scanning is safe in UTF-8:
211
+ # continuation bytes are >= 0x80 and never collide with ASCII syntax.
212
+ DQUOTE = 0x22
213
+ BSLASH = 0x5c
214
+ LBRACE = 0x7b
215
+ RBRACE = 0x7d
216
+ LBRACKET = 0x5b
217
+ RBRACKET = 0x5d
218
+ COMMA = 0x2c
219
+ COLON = 0x3a
220
+
221
+ # Scan the head of a JSON object for top-level "meta" / "summary" keys.
222
+ # rperf writes them first, so scanning stops at the first other key —
223
+ # large sample arrays are never traversed.
224
+ # Returns { meta:, summary: }, nil (old format / malformed),
225
+ # or :incomplete (need more input).
226
+ def scan_prefix(buf)
227
+ n = buf.bytesize
228
+ i = skip_ws(buf, 0, n)
229
+ return :incomplete if i >= n
230
+ return nil unless buf.getbyte(i) == LBRACE
231
+ i += 1
232
+ found = {}
233
+
234
+ loop do
235
+ i = skip_ws(buf, i, n)
236
+ return :incomplete if i >= n
237
+ return finalize_scan(found) if buf.getbyte(i) == RBRACE
238
+ return nil unless buf.getbyte(i) == DQUOTE
239
+
240
+ key_start = i
241
+ i = scan_string(buf, i, n)
242
+ return :incomplete unless i
243
+ key = buf.byteslice(key_start + 1, i - key_start - 2)
244
+
245
+ # First key that is not meta/summary ends the scan (old format or body)
246
+ return finalize_scan(found) unless key == "meta" || key == "summary"
247
+
248
+ i = skip_ws(buf, i, n)
249
+ return :incomplete if i >= n
250
+ return nil unless buf.getbyte(i) == COLON
251
+ i += 1
252
+ i = skip_ws(buf, i, n)
253
+ return :incomplete if i >= n
254
+
255
+ vstart = i
256
+ i = scan_value(buf, i, n)
257
+ return :incomplete unless i
258
+ fragment = buf.byteslice(vstart, i - vstart).force_encoding(Encoding::UTF_8)
259
+ found[key.to_sym] = JSON.parse(fragment, symbolize_names: true)
260
+ return finalize_scan(found) if found.key?(:meta) && found.key?(:summary)
261
+
262
+ i = skip_ws(buf, i, n)
263
+ return :incomplete if i >= n
264
+ case buf.getbyte(i)
265
+ when COMMA then i += 1
266
+ when RBRACE then return finalize_scan(found)
267
+ else return nil
268
+ end
269
+ end
270
+ rescue JSON::ParserError
271
+ nil
272
+ end
273
+
274
+ def finalize_scan(found)
275
+ found.empty? ? nil : { meta: found[:meta], summary: found[:summary] }
276
+ end
277
+
278
+ def skip_ws(buf, i, n)
279
+ while i < n
280
+ b = buf.getbyte(i)
281
+ break unless b == 0x20 || b == 0x09 || b == 0x0a || b == 0x0d
282
+ i += 1
283
+ end
284
+ i
285
+ end
286
+
287
+ # Scan a JSON string starting at the opening quote.
288
+ # Returns the index just past the closing quote, or nil if truncated.
289
+ def scan_string(buf, i, n)
290
+ j = i + 1
291
+ while j < n
292
+ b = buf.getbyte(j)
293
+ if b == BSLASH
294
+ j += 2
295
+ elsif b == DQUOTE
296
+ return j + 1
297
+ else
298
+ j += 1
299
+ end
300
+ end
301
+ nil
302
+ end
303
+
304
+ # Scan a JSON value (string, container, or scalar) starting at i.
305
+ # Returns the index just past the value, or nil if truncated.
306
+ def scan_value(buf, i, n)
307
+ case buf.getbyte(i)
308
+ when DQUOTE
309
+ scan_string(buf, i, n)
310
+ when LBRACE, LBRACKET
311
+ depth = 0
312
+ j = i
313
+ while j < n
314
+ b = buf.getbyte(j)
315
+ if b == DQUOTE
316
+ j = scan_string(buf, j, n)
317
+ return nil unless j
318
+ elsif b == LBRACE || b == LBRACKET
319
+ depth += 1
320
+ j += 1
321
+ elsif b == RBRACE || b == RBRACKET
322
+ depth -= 1
323
+ j += 1
324
+ return j if depth == 0
325
+ else
326
+ j += 1
327
+ end
328
+ end
329
+ nil
330
+ else
331
+ # scalar: number, true, false, null
332
+ j = i
333
+ while j < n
334
+ b = buf.getbyte(j)
335
+ break if b == COMMA || b == RBRACE || b == RBRACKET ||
336
+ b == 0x20 || b == 0x09 || b == 0x0a || b == 0x0d
337
+ j += 1
338
+ end
339
+ j < n ? j : nil
340
+ end
341
+ end
342
+ end
343
+ end
data/lib/rperf/rack.rb CHANGED
@@ -16,10 +16,15 @@ class Rperf::RackMiddleware
16
16
  @label_proc = label
17
17
  end
18
18
 
19
- UUID_RE = %r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}i
20
- NUMERIC_RE = %r{/\d+}
19
+ UUID_RE = %r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|\z)}i
20
+ NUMERIC_RE = %r{/\d+(?=/|\z)}
21
21
 
22
22
  def call(env)
23
+ # No-op when the profiler is not running (Rperf.profile would raise):
24
+ # the app may boot without Rperf.start, stop mid-run, or run in a forked
25
+ # worker where the atfork handler silently stopped profiling.
26
+ return @app.call(env) unless Rperf.running?
27
+
23
28
  endpoint = if @label_proc == :raw
24
29
  "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
25
30
  elsif @label_proc
@@ -0,0 +1,156 @@
1
+ # Flat table output for AI / machine consumption (`--format table` /
2
+ # `--format table-json`). Aggregation, diffing, and cutoff all happen here so
3
+ # consumers (LLMs, scripts) get a flat result table instead of a sample tree.
4
+ #
5
+ # TSV: header row, data rows, then a trailing "# summary" line of
6
+ # tab-separated key=value pairs.
7
+ # JSON: an array of row objects; the last element is { "summary": { ... } }.
8
+
9
+ require "json"
10
+
11
+ module Rperf
12
+ module Table
13
+ ROWS_LIMIT = 50
14
+
15
+ module_function
16
+
17
+ # --- single-profile report ---
18
+
19
+ # Rows: method, self_pct, total_pct, self_ms — self_pct descending,
20
+ # top 50 plus an "(other)" row aggregating the rest.
21
+ def report_rows(data, limit: ROWS_LIMIT)
22
+ flat, cum, total = flat_cum_by_name(data)
23
+ entries = flat.sort_by { |name, w| [-w, name] }
24
+ rows = entries.first(limit).map do |name, w|
25
+ {
26
+ method: name,
27
+ self_pct: pct(w, total),
28
+ total_pct: pct(cum[name], total),
29
+ self_ms: ms(w),
30
+ }
31
+ end
32
+ if entries.size > limit
33
+ # Aggregate the raw weights, not the already-rounded row values:
34
+ # per-row rounding errors are systematic and would make the (other)
35
+ # row inconsistent with itself (pct vs ms) and with the true total
36
+ rest_weight = entries.drop(limit).sum { |_, w| w }
37
+ rows << {
38
+ method: "(other)",
39
+ self_pct: pct(rest_weight, total),
40
+ total_pct: nil, # overlapping cumulative values cannot be summed
41
+ self_ms: ms(rest_weight),
42
+ }
43
+ end
44
+ rows
45
+ end
46
+
47
+ def report_summary(data)
48
+ s = data[:summary] || Meta.build_summary(data)
49
+ out = {}
50
+ out[:total_ms] = s[:total_ms] if s[:total_ms]
51
+ out[:cpu_ms] = s[:cpu_ms] if s[:cpu_ms]
52
+ out[:allocated_objects] = s[:allocated_objects] if s[:allocated_objects]
53
+ out[:gc_count_minor] = s[:gc_count_minor] if s[:gc_count_minor]
54
+ out[:gc_count_major] = s[:gc_count_major] if s[:gc_count_major]
55
+ out
56
+ end
57
+
58
+ def report_tsv(data)
59
+ rows = report_rows(data)
60
+ out = String.new
61
+ out << "method\tself_pct\ttotal_pct\tself_ms\n"
62
+ rows.each do |r|
63
+ out << [tsv_cell(r[:method]), r[:self_pct], r[:total_pct], r[:self_ms]].map { |v| v.nil? ? "" : v.to_s }.join("\t") << "\n"
64
+ end
65
+ out << summary_line(report_summary(data))
66
+ out
67
+ end
68
+
69
+ def report_json(data)
70
+ JSON.generate(report_rows(data) + [{ summary: report_summary(data) }])
71
+ end
72
+
73
+ # --- diff (head - base) ---
74
+
75
+ # Rows: method, self_pct_base, self_pct_head, delta_pt — |delta_pt|
76
+ # descending, top 50. Per-method allocation data does not exist in
77
+ # rperf profiles, so allocation appears only in the summary.
78
+ def diff_rows(base, head, limit: ROWS_LIMIT)
79
+ base_flat, _, base_total = flat_cum_by_name(base)
80
+ head_flat, _, head_total = flat_cum_by_name(head)
81
+ names = (base_flat.keys | head_flat.keys)
82
+ rows = names.map do |name|
83
+ b = pct(base_flat[name] || 0, base_total)
84
+ h = pct(head_flat[name] || 0, head_total)
85
+ {
86
+ method: name,
87
+ self_pct_base: b,
88
+ self_pct_head: h,
89
+ delta_pt: (h - b).round(2),
90
+ }
91
+ end
92
+ rows.sort_by! { |r| [-r[:delta_pt].abs, r[:method]] }
93
+ rows.first(limit)
94
+ end
95
+
96
+ def diff_summary(base, head)
97
+ b = report_summary(base)
98
+ h = report_summary(head)
99
+ out = {}
100
+ %i[total_ms allocated_objects gc_count_minor gc_count_major].each do |key|
101
+ out[:"#{key}_base"] = b[key] if b[key]
102
+ out[:"#{key}_head"] = h[key] if h[key]
103
+ out[:"#{key}_delta"] = (h[key] - b[key]).round(1) if b[key] && h[key]
104
+ end
105
+ out
106
+ end
107
+
108
+ def diff_tsv(base, head)
109
+ rows = diff_rows(base, head)
110
+ out = String.new
111
+ out << "method\tself_pct_base\tself_pct_head\tdelta_pt\n"
112
+ rows.each do |r|
113
+ out << [tsv_cell(r[:method]), r[:self_pct_base], r[:self_pct_head], r[:delta_pt]].join("\t") << "\n"
114
+ end
115
+ out << summary_line(diff_summary(base, head))
116
+ out
117
+ end
118
+
119
+ def diff_json(base, head)
120
+ JSON.generate(diff_rows(base, head) + [{ summary: diff_summary(base, head) }])
121
+ end
122
+
123
+ # --- helpers ---
124
+
125
+ # Flat / cumulative weights merged by method name (compute_flat_cum keys
126
+ # are [label, path]; a method split across paths counts once).
127
+ def flat_cum_by_name(data)
128
+ result = Rperf.send(:compute_flat_cum, data[:aggregated_samples] || [])
129
+ flat = Hash.new(0)
130
+ result[:flat].each { |(label, _path), w| flat[label] += w }
131
+ cum = Hash.new(0)
132
+ result[:cum].each { |(label, _path), w| cum[label] += w }
133
+ [flat, cum, result[:total_weight]]
134
+ end
135
+
136
+ def pct(weight, total)
137
+ total > 0 ? (weight * 100.0 / total).round(2) : 0.0
138
+ end
139
+
140
+ def ms(ns)
141
+ (ns / 1e6).round(1)
142
+ end
143
+
144
+ def summary_line(summary)
145
+ (["# summary"] + summary.map { |k, v| "#{k}=#{v}" }).join("\t") + "\n"
146
+ end
147
+
148
+ # TSV has no escaping convention — replace separator characters so a
149
+ # pathological method name (define_method allows any string) cannot
150
+ # shift columns or split a row.
151
+ def tsv_cell(value)
152
+ s = value.to_s
153
+ s.match?(/[\t\n\r]/) ? s.gsub(/[\t\n\r]/, " ") : s
154
+ end
155
+ end
156
+ end
data/lib/rperf/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Rperf
2
- VERSION = "0.8.0"
2
+ VERSION = "0.10.0"
3
3
  end