ruby_method_tracer 0.3.3 → 0.4.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +61 -0
- data/README.md +48 -1
- data/lib/ruby_method_tracer/configuration.rb +57 -0
- data/lib/ruby_method_tracer/enhanced_tracer.rb +12 -1
- data/lib/ruby_method_tracer/exportable.rb +73 -0
- data/lib/ruby_method_tracer/formatters/flat_formatter.rb +119 -0
- data/lib/ruby_method_tracer/formatters/json_formatter.rb +105 -0
- data/lib/ruby_method_tracer/simple_tracer.rb +16 -4
- data/lib/ruby_method_tracer/version.rb +1 -1
- data/lib/ruby_method_tracer.rb +36 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 172daf6a55bb4350983cdf15aafbe4b954f418183401ef2be69c6b68c31a575d
|
|
4
|
+
data.tar.gz: '019cd6fd2053967bff554ae244eed994c614a585b16d16f1509e57a5ec5a444e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86454302178091a987db42dc9eff6e3fd320822ec38c29b27b35c7c0e92ffc16b4f2844131716202969929df8c90f3870e5b0a403ad290df7339d4fc986d2dc9
|
|
7
|
+
data.tar.gz: 3acee9f7dd4398d23ce23d34150dd88e258b9fe09cc420f364132d1fbdea4fb6bed07cdcef7a2710b2264763fb83b7dc8365534991c3057488aa3f41e3d45440
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-06-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Global configuration via `RubyMethodTracer.configure { |c| ... }`, with `RubyMethodTracer.configuration` and `RubyMethodTracer.reset_configuration!`. Tracers created through the mixin now use these as defaults; explicit per-tracer options still take precedence.
|
|
7
|
+
- `Formatters::JsonFormatter` — serializes flat results or a call tree to JSON. Method arguments are never captured; exceptions are reduced to class + message; backtraces are opt-in (`include_backtrace:`) and length-bounded (`backtrace_limit:`). Uses `JSON.generate` only (no `Marshal`/`eval`/`YAML`).
|
|
8
|
+
- `Formatters::FlatFormatter` — renders an aggregated text table (method, calls, total, avg, errors) sorted by total time.
|
|
9
|
+
- `Exportable` mixin adding `render(format:)` and `export(path, format:)` to both tracers. Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
- File export never invokes a shell and never interpolates the path into a command; it writes via `File.open` with `O_NOFOLLOW`, requires the destination directory to already exist (no recursive mkdir of attacker-influenced paths), and refuses to write through an existing symlink.
|
|
13
|
+
- Export format dispatch compares on the string form to avoid interning arbitrary symbols from potentially untrusted input.
|
|
14
|
+
|
|
3
15
|
## [0.3.3] - 2026-06-08
|
|
4
16
|
|
|
5
17
|
### Changed
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle exec rspec # Run all tests (also generates coverage report in coverage/)
|
|
9
|
+
bundle exec rubocop # Lint
|
|
10
|
+
bundle exec rake # Run both spec and rubocop (default task)
|
|
11
|
+
|
|
12
|
+
# Run a single spec file
|
|
13
|
+
bundle exec rspec spec/ruby_method_tracer/simple_tracer_spec.rb
|
|
14
|
+
|
|
15
|
+
# Run a specific example by description
|
|
16
|
+
bundle exec rspec spec/ruby_method_tracer/simple_tracer_spec.rb -e "records call details"
|
|
17
|
+
|
|
18
|
+
# Install gem locally for manual testing
|
|
19
|
+
bundle exec rake install
|
|
20
|
+
|
|
21
|
+
# Release: bump version in lib/ruby_method_tracer/version.rb, then:
|
|
22
|
+
bundle exec rake release
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Commit conventions
|
|
26
|
+
|
|
27
|
+
- Do NOT add a `Co-authored-by:` trailer (or any "Generated with"/agent attribution) to commit messages.
|
|
28
|
+
|
|
29
|
+
## Architecture
|
|
30
|
+
|
|
31
|
+
The gem provides two ways to trace methods:
|
|
32
|
+
|
|
33
|
+
1. **Mixin API** — `include RubyMethodTracer` in a class, then call `trace_methods(:method_name, **opts)` at the class level. This creates a `SimpleTracer` internally and is the simplest entry point.
|
|
34
|
+
|
|
35
|
+
2. **Direct tracer API** — instantiate `SimpleTracer` or `EnhancedTracer` directly for programmatic access to results.
|
|
36
|
+
|
|
37
|
+
### Tracing mechanism (SimpleTracer)
|
|
38
|
+
|
|
39
|
+
`SimpleTracer#trace_method` wraps a method by:
|
|
40
|
+
1. Detecting visibility (public/protected/private) via `method_defined?` etc.
|
|
41
|
+
2. Aliasing the original to `__ruby_method_tracer_original_<name>__`
|
|
42
|
+
3. Redefining the method with a proc that times the call and delegates to the alias
|
|
43
|
+
4. Restoring the original visibility
|
|
44
|
+
|
|
45
|
+
The wrapper uses `Thread.current[:__ruby_method_tracer_in_trace]` as a reentrancy flag to avoid recursive double-recording. Kwargs are forwarded conditionally (`kwargs.empty?` check) to stay compatible with Ruby 3.x.
|
|
46
|
+
|
|
47
|
+
Results are stored in `@calls` (an Array) guarded by a `Mutex`. The `max_calls` option enforces a sliding window by `shift`-ing the oldest entry.
|
|
48
|
+
|
|
49
|
+
### EnhancedTracer
|
|
50
|
+
|
|
51
|
+
Inherits from `SimpleTracer` and adds call-tree tracking via `CallTree`. It overrides `trace_method` to use a per-method reentrancy key (`__ruby_method_tracer_in_trace_<method_name>`) so that *different* wrapped methods can nest inside each other (unlike `SimpleTracer` which blocks all nesting).
|
|
52
|
+
|
|
53
|
+
### CallTree
|
|
54
|
+
|
|
55
|
+
Stack-based hierarchy tracker. `start_call` pushes a record (with a `parent` pointer and `children` array) onto `@call_stack`; `end_call` pops it, fills timing/status, and appends to the flat `@calls` list. Root-level calls (depth 0) are also collected in `@root_calls` for tree rendering.
|
|
56
|
+
|
|
57
|
+
### Formatters
|
|
58
|
+
|
|
59
|
+
`Formatters::BaseFormatter` — abstract base providing `format_time` and `colorize` (ANSI).
|
|
60
|
+
|
|
61
|
+
`Formatters::TreeFormatter < BaseFormatter` — renders a `CallTree` as an ASCII tree with `└──`/`├──` connectors, then appends a statistics block (slowest methods, most-called methods).
|
data/README.md
CHANGED
|
@@ -8,7 +8,9 @@ RubyMethodTracer is a lightweight Ruby mixin for targeted method tracing. It wra
|
|
|
8
8
|
## Highlights
|
|
9
9
|
- Wrap only the methods you care about; public, protected, and private methods are supported.
|
|
10
10
|
- Records duration, success/error state, and timestamps with thread-safe storage.
|
|
11
|
-
- **
|
|
11
|
+
- **Hierarchical call tree visualization** to understand nested method calls and dependencies.
|
|
12
|
+
- **NEW: JSON and flat-table formatters plus file export**, alongside the existing tree output.
|
|
13
|
+
- **NEW: Global configuration** via `RubyMethodTracer.configure` for process-wide defaults.
|
|
12
14
|
- Configurable threshold to ignore fast calls and optional log streaming via `Logger`.
|
|
13
15
|
- Zero dependencies beyond the Ruby standard library, keeping overhead minimal.
|
|
14
16
|
|
|
@@ -214,6 +216,51 @@ Most Called Methods:
|
|
|
214
216
|
- Color-coded output for better readability
|
|
215
217
|
|
|
216
218
|
|
|
219
|
+
### Global Configuration
|
|
220
|
+
|
|
221
|
+
Set process-wide defaults once at boot. Per-tracer options always override these globals.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
RubyMethodTracer.configure do |config|
|
|
225
|
+
config.threshold = 0.005 # record calls slower than 5ms
|
|
226
|
+
config.auto_output = false
|
|
227
|
+
config.max_calls = 1000
|
|
228
|
+
config.logger = Rails.logger if defined?(Rails)
|
|
229
|
+
config.track_hierarchy = true # EnhancedTracer call-tree tracking
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`RubyMethodTracer.reset_configuration!` restores the built-in defaults (useful in tests).
|
|
234
|
+
|
|
235
|
+
### Reporting & Export
|
|
236
|
+
|
|
237
|
+
Both tracers can render their results to a string or write them to a file via the formatter layer.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
tracer = RubyMethodTracer::EnhancedTracer.new(OrderProcessor, threshold: 0.0)
|
|
241
|
+
tracer.trace_method(:process_order)
|
|
242
|
+
OrderProcessor.new.process_order(order)
|
|
243
|
+
|
|
244
|
+
# Render to a string
|
|
245
|
+
puts tracer.render(format: :flat) # aggregated table
|
|
246
|
+
json = tracer.render(format: :json, pretty: true)
|
|
247
|
+
tree = tracer.render(format: :tree) # EnhancedTracer only
|
|
248
|
+
|
|
249
|
+
# Write to a file (returns the absolute path written)
|
|
250
|
+
tracer.export("tmp/trace.json", format: :json)
|
|
251
|
+
tracer.export("tmp/trace.txt", format: :flat, colorize: false)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
|
|
255
|
+
|
|
256
|
+
**JSON options**
|
|
257
|
+
|
|
258
|
+
- `pretty` (Boolean, default `false`): pretty-print the JSON.
|
|
259
|
+
- `include_backtrace` (Boolean, default `false`): include exception backtraces. Off by default to avoid leaking internal paths.
|
|
260
|
+
- `backtrace_limit` (Integer, default `10`): maximum backtrace lines when `include_backtrace` is enabled.
|
|
261
|
+
|
|
262
|
+
> Security/privacy: method **arguments are never captured**, so secrets passed as parameters are not recorded. Exceptions are reduced to class + message (backtrace is opt-in). Export never invokes a shell, requires the target directory to already exist, and refuses to write through a symlink. Call `render`/`export` when tracing is quiescent (after the traced work completes).
|
|
263
|
+
|
|
217
264
|
### Options (SimpleTracer)
|
|
218
265
|
|
|
219
266
|
- `threshold` (Float, default `0.001`): minimum duration (in seconds) to record.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyMethodTracer
|
|
4
|
+
# Configuration holds global defaults for tracers created through the
|
|
5
|
+
# mixin API. Per-tracer options passed to `trace_methods` or to a tracer's
|
|
6
|
+
# constructor always take precedence over these globals.
|
|
7
|
+
#
|
|
8
|
+
# Configure once during application boot:
|
|
9
|
+
#
|
|
10
|
+
# RubyMethodTracer.configure do |config|
|
|
11
|
+
# config.threshold = 0.005
|
|
12
|
+
# config.max_calls = 500
|
|
13
|
+
# config.auto_output = true
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The object is intended to be set at boot and then read concurrently.
|
|
17
|
+
# Mutating it after threads are tracing is not recommended.
|
|
18
|
+
class Configuration
|
|
19
|
+
# Minimum duration (seconds) a call must take to be recorded.
|
|
20
|
+
attr_accessor :threshold
|
|
21
|
+
# When true, each recorded call is emitted to the logger.
|
|
22
|
+
attr_accessor :auto_output
|
|
23
|
+
# Maximum number of calls retained in memory (sliding window).
|
|
24
|
+
attr_accessor :max_calls
|
|
25
|
+
# Logger instance used for auto output. Nil means each tracer builds its own.
|
|
26
|
+
attr_accessor :logger
|
|
27
|
+
# Whether EnhancedTracer builds a hierarchical call tree.
|
|
28
|
+
attr_accessor :track_hierarchy
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
reset!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Restore all settings to their built-in defaults.
|
|
35
|
+
def reset!
|
|
36
|
+
@threshold = 0.001
|
|
37
|
+
@auto_output = false
|
|
38
|
+
@max_calls = 1000
|
|
39
|
+
@logger = nil
|
|
40
|
+
@track_hierarchy = true
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Snapshot of the option keys consumed by the tracers.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash]
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
threshold: @threshold,
|
|
50
|
+
auto_output: @auto_output,
|
|
51
|
+
max_calls: @max_calls,
|
|
52
|
+
logger: @logger,
|
|
53
|
+
track_hierarchy: @track_hierarchy
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -83,6 +83,17 @@ module RubyMethodTracer
|
|
|
83
83
|
|
|
84
84
|
private
|
|
85
85
|
|
|
86
|
+
# Expose the call tree so JSON/flat exports include hierarchy + statistics.
|
|
87
|
+
def report_source
|
|
88
|
+
@call_tree
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_formatter(format)
|
|
92
|
+
return Formatters::TreeFormatter.new if format.to_sym == :tree
|
|
93
|
+
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
86
97
|
def build_enhanced_wrapper(aliased, method_name, key, tracer)
|
|
87
98
|
track_hierarchy = tracer.instance_variable_get(:@track_hierarchy)
|
|
88
99
|
# Use method-specific key to prevent only SELF-recursion, not all nested calls
|
|
@@ -139,7 +150,7 @@ module RubyMethodTracer
|
|
|
139
150
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
140
151
|
|
|
141
152
|
def default_options
|
|
142
|
-
super.merge(track_hierarchy:
|
|
153
|
+
super.merge(track_hierarchy: RubyMethodTracer.configuration.track_hierarchy)
|
|
143
154
|
end
|
|
144
155
|
end
|
|
145
156
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyMethodTracer
|
|
4
|
+
# Exportable provides render/export helpers shared by the tracers.
|
|
5
|
+
#
|
|
6
|
+
# Host classes must implement a private `#report_source` returning the data
|
|
7
|
+
# handed to formatters (a results hash or a CallTree).
|
|
8
|
+
#
|
|
9
|
+
# Security notes:
|
|
10
|
+
# - `export` never invokes a shell and never interpolates the path into a
|
|
11
|
+
# command; it writes via `File.open` with `O_NOFOLLOW`.
|
|
12
|
+
# - The destination directory must already exist (no recursive mkdir of
|
|
13
|
+
# attacker-influenced paths) and the tracer refuses to write through an
|
|
14
|
+
# existing symlink, guarding against symlink-redirection.
|
|
15
|
+
#
|
|
16
|
+
# Concurrency: `render`/`export` read the tracer's in-memory buffers and are
|
|
17
|
+
# intended to be called when tracing is quiescent (e.g. after the traced work
|
|
18
|
+
# has finished). The flat results path is snapshotted under a lock; the call
|
|
19
|
+
# tree is read via its locked hierarchy/statistics accessors.
|
|
20
|
+
module Exportable
|
|
21
|
+
# Render the current results to a string.
|
|
22
|
+
#
|
|
23
|
+
# @param format [Symbol] :json, :flat (and :tree for EnhancedTracer)
|
|
24
|
+
# @return [String]
|
|
25
|
+
def render(format: :json, **opts)
|
|
26
|
+
build_formatter(format).format(report_source, opts)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Render results and write them to a file.
|
|
30
|
+
#
|
|
31
|
+
# @param path [String] Destination file path
|
|
32
|
+
# @param format [Symbol] :json, :flat (and :tree for EnhancedTracer)
|
|
33
|
+
# @return [String] The absolute path written
|
|
34
|
+
def export(path, format: :json, **opts)
|
|
35
|
+
write_export(path, render(format: format, **opts))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Compare on the string form so an unknown format never interns an
|
|
41
|
+
# arbitrary symbol (avoids unbounded symbol-table growth if a caller
|
|
42
|
+
# forwards untrusted input as the format).
|
|
43
|
+
def build_formatter(format)
|
|
44
|
+
case format.to_s
|
|
45
|
+
when "json" then Formatters::JsonFormatter.new
|
|
46
|
+
when "flat" then Formatters::FlatFormatter.new
|
|
47
|
+
else raise ArgumentError, "unknown export format: #{format.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_export(path, content)
|
|
52
|
+
safe_path = validate_export_path(path)
|
|
53
|
+
# O_NOFOLLOW makes the open fail if the final component is a symlink,
|
|
54
|
+
# closing the check-then-write race left by the stat-based guard below.
|
|
55
|
+
flags = File::WRONLY | File::CREAT | File::TRUNC | File::NOFOLLOW
|
|
56
|
+
File.open(safe_path, flags) { |file| file.write(content) }
|
|
57
|
+
safe_path
|
|
58
|
+
rescue Errno::ELOOP
|
|
59
|
+
raise ArgumentError, "refusing to write through symlink: #{path}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_export_path(path)
|
|
63
|
+
raise ArgumentError, "export path must be provided" if path.nil? || path.to_s.strip.empty?
|
|
64
|
+
|
|
65
|
+
expanded = File.expand_path(path.to_s)
|
|
66
|
+
dir = File.dirname(expanded)
|
|
67
|
+
raise ArgumentError, "export directory does not exist: #{dir}" unless File.directory?(dir)
|
|
68
|
+
raise ArgumentError, "refusing to overwrite symlink: #{expanded}" if File.symlink?(expanded)
|
|
69
|
+
|
|
70
|
+
expanded
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_formatter"
|
|
4
|
+
|
|
5
|
+
module RubyMethodTracer
|
|
6
|
+
module Formatters
|
|
7
|
+
# FlatFormatter renders trace data as a flat, aggregated text table:
|
|
8
|
+
# one row per unique method with call count, total time, average time,
|
|
9
|
+
# and error count, sorted by total time descending.
|
|
10
|
+
#
|
|
11
|
+
# Accepts either a CallTree or a flat results hash from
|
|
12
|
+
# `SimpleTracer#fetch_results`.
|
|
13
|
+
class FlatFormatter < BaseFormatter
|
|
14
|
+
HEADERS = %w[Method Calls Total Avg Errors].freeze
|
|
15
|
+
|
|
16
|
+
def format(data, options = {})
|
|
17
|
+
opts = default_options.merge(options)
|
|
18
|
+
calls = extract_calls(data)
|
|
19
|
+
return "No method calls recorded.\n" if calls.empty?
|
|
20
|
+
|
|
21
|
+
rows = build_rows(calls)
|
|
22
|
+
render(rows, opts)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_options
|
|
28
|
+
{ colorize: true }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_calls(data)
|
|
32
|
+
if data.respond_to?(:call_hierarchy)
|
|
33
|
+
# Read the call tree through its lock-protected accessor, then flatten
|
|
34
|
+
# the hierarchy into a single list of calls.
|
|
35
|
+
data.call_hierarchy.flat_map { |node| flatten_node(node) }
|
|
36
|
+
elsif data.is_a?(Hash)
|
|
37
|
+
data[:calls] || []
|
|
38
|
+
else
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def flatten_node(node)
|
|
44
|
+
[node, *(node[:children] || []).flat_map { |child| flatten_node(child) }]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_rows(calls)
|
|
48
|
+
rows = aggregate(calls).map { |name, agg| build_row(name, agg) }
|
|
49
|
+
rows.sort_by { |row| -row[:total] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_row(name, agg)
|
|
53
|
+
{
|
|
54
|
+
method: name,
|
|
55
|
+
calls: agg[:count],
|
|
56
|
+
total: agg[:total_time],
|
|
57
|
+
avg: agg[:total_time] / agg[:count],
|
|
58
|
+
errors: agg[:errors]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def aggregate(calls)
|
|
63
|
+
stats = Hash.new { |h, k| h[k] = { count: 0, total_time: 0.0, errors: 0 } }
|
|
64
|
+
calls.each do |call|
|
|
65
|
+
agg = stats[call[:method_name]]
|
|
66
|
+
agg[:count] += 1
|
|
67
|
+
agg[:total_time] += call[:execution_time].to_f
|
|
68
|
+
agg[:errors] += 1 if call[:status] == :error
|
|
69
|
+
end
|
|
70
|
+
stats
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render(rows, opts)
|
|
74
|
+
cell_rows = rows.map { |row| cells_for(row) }
|
|
75
|
+
widths = column_widths(cell_rows)
|
|
76
|
+
lines = [align(HEADERS, widths), separator(widths)]
|
|
77
|
+
rows.zip(cell_rows).each { |row, cells| lines << data_line(row, cells, widths, opts) }
|
|
78
|
+
"#{lines.join("\n")}\n"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Plain (uncolored) cell strings, used for both width calc and rendering.
|
|
82
|
+
def cells_for(row)
|
|
83
|
+
[row[:method], row[:calls].to_s, format_time(row[:total]), format_time(row[:avg]), row[:errors].to_s]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column_widths(cell_rows)
|
|
87
|
+
HEADERS.each_index.map do |i|
|
|
88
|
+
(cell_rows.map { |cells| cells[i].length } + [HEADERS[i].length]).max
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def align(cells, widths)
|
|
93
|
+
cells.each_index.map { |i| pad(cells[i], widths[i], i.zero?) }.join(" ")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Justify a plain cell, then wrap it in color so ANSI codes never affect
|
|
97
|
+
# the computed column width.
|
|
98
|
+
def data_line(row, cells, widths, opts)
|
|
99
|
+
cells.each_index.map { |i| color_cell(pad(cells[i], widths[i], i.zero?), i, row, opts) }.join(" ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def pad(text, width, left)
|
|
103
|
+
left ? text.ljust(width) : text.rjust(width)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def color_cell(padded, index, row, opts)
|
|
107
|
+
return padded unless opts[:colorize]
|
|
108
|
+
return colorize(padded, :cyan) if index.zero?
|
|
109
|
+
return colorize(padded, :red) if index == 4 && row[:errors].positive?
|
|
110
|
+
|
|
111
|
+
padded
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def separator(widths)
|
|
115
|
+
widths.map { |w| "-" * w }.join(" ")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "base_formatter"
|
|
6
|
+
|
|
7
|
+
module RubyMethodTracer
|
|
8
|
+
module Formatters
|
|
9
|
+
# JsonFormatter serializes trace data to JSON.
|
|
10
|
+
#
|
|
11
|
+
# Security/privacy notes:
|
|
12
|
+
# - Serialization uses `JSON.generate` only; no `Marshal`/`eval`/`YAML` is
|
|
13
|
+
# involved, so the output cannot be used as a deserialization gadget.
|
|
14
|
+
# - Method arguments are never captured or emitted, avoiding accidental
|
|
15
|
+
# leakage of secrets passed as parameters.
|
|
16
|
+
# - Exceptions are reduced to their class name and message. Backtraces are
|
|
17
|
+
# opt-in (`include_backtrace: true`) and truncated to `backtrace_limit`
|
|
18
|
+
# lines to avoid leaking large amounts of internal path information.
|
|
19
|
+
#
|
|
20
|
+
# Accepts either a CallTree (serializes hierarchy + statistics) or a flat
|
|
21
|
+
# results hash as produced by `SimpleTracer#fetch_results`.
|
|
22
|
+
class JsonFormatter < BaseFormatter
|
|
23
|
+
def format(data, options = {})
|
|
24
|
+
opts = default_options.merge(options)
|
|
25
|
+
payload = build_payload(data, opts)
|
|
26
|
+
opts[:pretty] ? JSON.pretty_generate(payload) : JSON.generate(payload)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def default_options
|
|
32
|
+
{
|
|
33
|
+
pretty: false,
|
|
34
|
+
include_backtrace: false,
|
|
35
|
+
backtrace_limit: 10
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_payload(data, opts)
|
|
40
|
+
if data.respond_to?(:call_hierarchy) && data.respond_to?(:statistics)
|
|
41
|
+
{
|
|
42
|
+
generated_at: Time.now.utc.iso8601,
|
|
43
|
+
call_hierarchy: data.call_hierarchy.map { |node| serialize_node(node, opts) },
|
|
44
|
+
statistics: serialize_statistics(data.statistics)
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
serialize_flat(data, opts)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialize_flat(results, opts)
|
|
52
|
+
results = {} unless results.is_a?(Hash)
|
|
53
|
+
calls = results[:calls] || []
|
|
54
|
+
{
|
|
55
|
+
generated_at: Time.now.utc.iso8601,
|
|
56
|
+
total_calls: results[:total_calls] || calls.size,
|
|
57
|
+
total_time: results[:total_time] || 0.0,
|
|
58
|
+
calls: calls.map { |call| serialize_call(call, opts) }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def serialize_call(call, opts)
|
|
63
|
+
{
|
|
64
|
+
method_name: call[:method_name],
|
|
65
|
+
execution_time: call[:execution_time],
|
|
66
|
+
status: call[:status],
|
|
67
|
+
error: serialize_error(call[:error], opts),
|
|
68
|
+
timestamp: iso8601(call[:timestamp])
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def serialize_node(node, opts)
|
|
73
|
+
{
|
|
74
|
+
method_name: node[:method_name],
|
|
75
|
+
execution_time: node[:execution_time],
|
|
76
|
+
status: node[:status],
|
|
77
|
+
depth: node[:depth],
|
|
78
|
+
error: serialize_error(node[:error], opts),
|
|
79
|
+
timestamp: iso8601(node[:timestamp]),
|
|
80
|
+
children: (node[:children] || []).map { |child| serialize_node(child, opts) }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def serialize_statistics(stats)
|
|
85
|
+
stats
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reduce an exception to a safe, bounded representation.
|
|
89
|
+
def serialize_error(error, opts)
|
|
90
|
+
return nil unless error
|
|
91
|
+
|
|
92
|
+
result = { class: error.class.name, message: error.message.to_s }
|
|
93
|
+
if opts[:include_backtrace] && error.backtrace
|
|
94
|
+
limit = opts[:backtrace_limit].to_i
|
|
95
|
+
result[:backtrace] = limit.positive? ? error.backtrace.first(limit) : []
|
|
96
|
+
end
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def iso8601(time)
|
|
101
|
+
time.respond_to?(:iso8601) ? time.iso8601(6) : time
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "set"
|
|
4
4
|
require "logger"
|
|
5
5
|
require_relative "formatters/base_formatter"
|
|
6
|
+
require_relative "exportable"
|
|
6
7
|
|
|
7
8
|
module RubyMethodTracer
|
|
8
9
|
# SimpleTracer wraps instance methods on a target class and records
|
|
@@ -20,7 +21,10 @@ module RubyMethodTracer
|
|
|
20
21
|
# tracer = RubyMethodTracer::SimpleTracer.new(MyClass, threshold: 0.005)
|
|
21
22
|
# tracer.trace_method(:expensive_call)
|
|
22
23
|
# results = tracer.fetch_results
|
|
24
|
+
# rubocop:disable Metrics/ClassLength
|
|
23
25
|
class SimpleTracer
|
|
26
|
+
include Exportable
|
|
27
|
+
|
|
24
28
|
def initialize(target_class, **options)
|
|
25
29
|
@target_class = target_class
|
|
26
30
|
@options = default_options.merge(options)
|
|
@@ -88,12 +92,19 @@ module RubyMethodTracer
|
|
|
88
92
|
|
|
89
93
|
private
|
|
90
94
|
|
|
95
|
+
# Data passed to formatters by Exportable. Overridden by EnhancedTracer to
|
|
96
|
+
# expose the call tree.
|
|
97
|
+
def report_source
|
|
98
|
+
fetch_results
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
def default_options
|
|
102
|
+
config = RubyMethodTracer.configuration
|
|
92
103
|
{
|
|
93
|
-
threshold:
|
|
94
|
-
auto_output:
|
|
95
|
-
max_calls:
|
|
96
|
-
logger:
|
|
104
|
+
threshold: config.threshold,
|
|
105
|
+
auto_output: config.auto_output,
|
|
106
|
+
max_calls: config.max_calls,
|
|
107
|
+
logger: config.logger
|
|
97
108
|
}
|
|
98
109
|
end
|
|
99
110
|
|
|
@@ -172,4 +183,5 @@ module RubyMethodTracer
|
|
|
172
183
|
@formatter.colorize(text, color)
|
|
173
184
|
end
|
|
174
185
|
end
|
|
186
|
+
# rubocop:enable Metrics/ClassLength
|
|
175
187
|
end
|
data/lib/ruby_method_tracer.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ruby_method_tracer/version"
|
|
4
|
+
require_relative "ruby_method_tracer/configuration"
|
|
4
5
|
require_relative "ruby_method_tracer/formatters/base_formatter"
|
|
5
6
|
require_relative "ruby_method_tracer/simple_tracer"
|
|
6
7
|
require_relative "ruby_method_tracer/call_tree"
|
|
7
8
|
require_relative "ruby_method_tracer/enhanced_tracer"
|
|
8
9
|
require_relative "ruby_method_tracer/formatters/tree_formatter"
|
|
10
|
+
require_relative "ruby_method_tracer/formatters/json_formatter"
|
|
11
|
+
require_relative "ruby_method_tracer/formatters/flat_formatter"
|
|
9
12
|
|
|
10
13
|
# Public: Mixin that adds lightweight method tracing to classes.
|
|
11
14
|
#
|
|
@@ -26,6 +29,39 @@ require_relative "ruby_method_tracer/formatters/tree_formatter"
|
|
|
26
29
|
module RubyMethodTracer
|
|
27
30
|
class Error < StandardError; end
|
|
28
31
|
|
|
32
|
+
@configuration = Configuration.new
|
|
33
|
+
@configuration_mutex = Mutex.new
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Global configuration shared as defaults by tracers created via the mixin.
|
|
37
|
+
#
|
|
38
|
+
# @return [RubyMethodTracer::Configuration]
|
|
39
|
+
attr_reader :configuration
|
|
40
|
+
|
|
41
|
+
# Yield the global configuration for mutation. Intended to be called once
|
|
42
|
+
# at application boot.
|
|
43
|
+
#
|
|
44
|
+
# RubyMethodTracer.configure do |config|
|
|
45
|
+
# config.threshold = 0.005
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# @yieldparam config [RubyMethodTracer::Configuration]
|
|
49
|
+
# @return [RubyMethodTracer::Configuration]
|
|
50
|
+
def configure
|
|
51
|
+
@configuration_mutex.synchronize do
|
|
52
|
+
yield(@configuration) if block_given?
|
|
53
|
+
end
|
|
54
|
+
@configuration
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reset the global configuration back to built-in defaults.
|
|
58
|
+
#
|
|
59
|
+
# @return [RubyMethodTracer::Configuration]
|
|
60
|
+
def reset_configuration!
|
|
61
|
+
@configuration_mutex.synchronize { @configuration.reset! }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
29
65
|
def self.included(base)
|
|
30
66
|
base.extend(ClassMethods)
|
|
31
67
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_method_tracer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seun Adekunle
|
|
@@ -20,14 +20,19 @@ files:
|
|
|
20
20
|
- ".rspec"
|
|
21
21
|
- ".rubocop.yml"
|
|
22
22
|
- CHANGELOG.md
|
|
23
|
+
- CLAUDE.md
|
|
23
24
|
- CODE_OF_CONDUCT.md
|
|
24
25
|
- LICENSE.txt
|
|
25
26
|
- README.md
|
|
26
27
|
- Rakefile
|
|
27
28
|
- lib/ruby_method_tracer.rb
|
|
28
29
|
- lib/ruby_method_tracer/call_tree.rb
|
|
30
|
+
- lib/ruby_method_tracer/configuration.rb
|
|
29
31
|
- lib/ruby_method_tracer/enhanced_tracer.rb
|
|
32
|
+
- lib/ruby_method_tracer/exportable.rb
|
|
30
33
|
- lib/ruby_method_tracer/formatters/base_formatter.rb
|
|
34
|
+
- lib/ruby_method_tracer/formatters/flat_formatter.rb
|
|
35
|
+
- lib/ruby_method_tracer/formatters/json_formatter.rb
|
|
31
36
|
- lib/ruby_method_tracer/formatters/tree_formatter.rb
|
|
32
37
|
- lib/ruby_method_tracer/simple_tracer.rb
|
|
33
38
|
- lib/ruby_method_tracer/version.rb
|