rails_tracepoint_stack 0.3.5 → 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/README.md +174 -0
- data/changelog.md +132 -0
- data/lib/generators/rails_tracepoint_stack/install/install_generator.rb +29 -0
- data/lib/rails_tracepoint_stack/depth_tracker.rb +54 -0
- data/lib/rails_tracepoint_stack/filter/gem_path.rb +20 -1
- data/lib/rails_tracepoint_stack/limits.rb +41 -0
- data/lib/rails_tracepoint_stack/log_formatter.rb +5 -1
- data/lib/rails_tracepoint_stack/renderer/summary.rb +37 -0
- data/lib/rails_tracepoint_stack/renderer/tree.rb +115 -0
- data/lib/rails_tracepoint_stack/sink/collector.rb +69 -0
- data/lib/rails_tracepoint_stack/sink/log.rb +17 -0
- data/lib/rails_tracepoint_stack/skill_installer.rb +39 -0
- data/lib/rails_tracepoint_stack/templates/skill.md +122 -0
- data/lib/rails_tracepoint_stack/trace.rb +30 -3
- data/lib/rails_tracepoint_stack/trace_record.rb +30 -0
- data/lib/rails_tracepoint_stack/trace_session.rb +77 -0
- data/lib/rails_tracepoint_stack/tracer.rb +57 -7
- data/lib/rails_tracepoint_stack/truncator.rb +50 -0
- data/lib/rails_tracepoint_stack/version.rb +1 -1
- data/lib/rails_tracepoint_stack.rb +35 -0
- metadata +26 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 612ce09f08d0dfebf77cfddc8dfbe9712598aaf324579f688bac6148354c92fb
|
|
4
|
+
data.tar.gz: aec94f6a1a148f16199b6835932a5b1fe91ff458a2915fab60efe937244b7cac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de2e858cfad1406ee8becdb5d3be65b956286c802a1f95023d8698eaa80500569ee603be1b28e06404845a41eebb25e364ea936cdd886fcdab02b14113caaf10
|
|
7
|
+
data.tar.gz: 17b4beea42cb83b2058b32652acf0ddeec66a1b2fabe32e61a07fcf446738fd4c92b238dd6ce603d5426d68af4e1bbe397e23c7afd7c739c4385ed3d8cf61177
|
data/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# rails_tracepoint_stack
|
|
2
|
+
|
|
3
|
+
**[carlosdanielpohlod.github.io/rails_tracepoint_stack](https://carlosdanielpohlod.github.io/rails_tracepoint_stack/)**
|
|
4
|
+
|
|
5
|
+
See what your Rails code actually did at runtime: which of *your* methods ran,
|
|
6
|
+
what arguments they got, what each one returned, and where an exception was
|
|
7
|
+
first raised — as a call tree, with gems, the framework and stdlib filtered out.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
session = RailsTracepointStack.capture(max_depth: 4) do
|
|
11
|
+
Order.find(42).recalculate!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
puts session.to_tree
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Order#recalculate! (app/models/order.rb:88) {}
|
|
19
|
+
Order#apply_discount (app/models/order.rb:102) {"total":200.0}
|
|
20
|
+
Discount#rate_for (app/models/discount.rb:12) {"order":"#<Order id: 42>"}
|
|
21
|
+
-> null
|
|
22
|
+
-> 200.0
|
|
23
|
+
-> 200.0
|
|
24
|
+
3 calls, 3 returns, 0 raises, 2 classes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`rate_for` returned nil. No `puts`, no log file, no restart.
|
|
28
|
+
|
|
29
|
+
A whole Rails request comes out about this long, because everything the
|
|
30
|
+
framework runs underneath is filtered away:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
RealEstateAgenciesController#index (app/controllers/real_estate_agencies_controller.rb:14) {}
|
|
34
|
+
IpLocation.guess_state_uf_and_city_name (app/services/ip_location.rb:15) {"ip":"127.0.0.1"}
|
|
35
|
+
-> [null,null]
|
|
36
|
+
-> null
|
|
37
|
+
render app/views/real_estate_agencies/index.html.erb {}
|
|
38
|
+
-> "<!-- BEGIN app/views/real_estate_agencies/index.html.erb\n-->… (2507 chars)"
|
|
39
|
+
4 calls, 4 returns, 0 raises, 3 classes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That is 4 kept traces out of 13869 the tracer saw.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Gemfile
|
|
48
|
+
gem "rails_tracepoint_stack"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Debugging with an AI agent
|
|
52
|
+
|
|
53
|
+
Agents reach for `puts` and log lines because they do not know this exists.
|
|
54
|
+
Install the packaged skill into your app and yours will use the gem instead:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bin/rails generate rails_tracepoint_stack:install
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That writes `.claude/skills/debug-with-tracepoint/SKILL.md`, which tells the
|
|
61
|
+
agent when tracing beats reading code, how to keep the output inside its
|
|
62
|
+
context window, and how to read the tree. Pass `--force` to overwrite an
|
|
63
|
+
existing copy.
|
|
64
|
+
|
|
65
|
+
## Capturing
|
|
66
|
+
|
|
67
|
+
`capture` traces the block, returns a session and re-raises anything the block
|
|
68
|
+
raised. It watches only the calling thread, so it stays clean under Puma.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
session = RailsTracepointStack.capture { Order.find(42).recalculate! }
|
|
72
|
+
|
|
73
|
+
session.to_tree # the indented call tree above
|
|
74
|
+
session.as_json # the same data, structured, one entry per trace
|
|
75
|
+
session.summary # {calls:, returns:, raises:, classes:, truncated:}
|
|
76
|
+
session.result # what the block returned
|
|
77
|
+
session.error # the exception that escaped, if any
|
|
78
|
+
session.traces # the raw TraceRecord list
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To keep the traces when the block blows up, take the session from the block
|
|
82
|
+
argument:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
session = nil
|
|
86
|
+
begin
|
|
87
|
+
RailsTracepointStack.capture { |s| session = s; thing_that_blows_up }
|
|
88
|
+
rescue => error
|
|
89
|
+
puts session.to_tree
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Keeping the output small
|
|
94
|
+
|
|
95
|
+
A real request produces tens of thousands of traces, so captures are bounded.
|
|
96
|
+
|
|
97
|
+
| Option | Default | What it does |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `max_depth` | none | Drops traces nested deeper than this |
|
|
100
|
+
| `max_traces` | 5000 | Stops collecting; `session.truncated?` becomes true |
|
|
101
|
+
| `max_string_length` | 200 | Shortens long strings |
|
|
102
|
+
| `max_collection_size` | 20 | Shortens long arrays and hashes |
|
|
103
|
+
| `capture_params` | `true` | Set false to show only the flow |
|
|
104
|
+
| `capture_return` | `true` | Set false to show only the calls |
|
|
105
|
+
| `threads` | `:current` | `:all` also records background threads |
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
RailsTracepointStack.capture(max_depth: 3, capture_return: false) { ... }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Tracing the whole process
|
|
112
|
+
|
|
113
|
+
For code you cannot wrap in a block — boot, a rake task, a request handled by a
|
|
114
|
+
running server — enable the tracer globally instead. Output goes to
|
|
115
|
+
`log/rails_tracepoint_stack.log`.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
RAILS_TRACEPOINT_STACK_ENABLED=true bin/rails server
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
called: Bar#perform in /path/to/app/services/bar.rb:12 with params: {}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`RailsTracepointStack.enable_trace { ... }` does the same for one block, also
|
|
126
|
+
writing to the log rather than returning a session.
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# config/initializers/rails_tracepoint_stack.rb
|
|
132
|
+
|
|
133
|
+
RailsTracepointStack.configure do |config|
|
|
134
|
+
config.file_path_to_filter_patterns << %r{app/services/}
|
|
135
|
+
config.ignore_patterns << %r{app/models/concerns/}
|
|
136
|
+
config.log_format = :json
|
|
137
|
+
config.log_external_sources = false
|
|
138
|
+
config.logger = Rails.logger
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Configuration | Description |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `file_path_to_filter_patterns` | Trace **only** files whose path matches one of these patterns |
|
|
145
|
+
| `ignore_patterns` | Skip traces whose file path matches one of these patterns |
|
|
146
|
+
| `log_format` | `:text` (default) or `:json`. Applies to the log, not to `capture` |
|
|
147
|
+
| `log_external_sources` | Include gems, bundler and stdlib. Default `false` |
|
|
148
|
+
| `logger` | Your own logger. Defaults to `log/rails_tracepoint_stack.log` |
|
|
149
|
+
|
|
150
|
+
A method missing from a trace is usually one the filters dropped as external —
|
|
151
|
+
add its path to `file_path_to_filter_patterns` to force it in.
|
|
152
|
+
|
|
153
|
+
## Worth knowing
|
|
154
|
+
|
|
155
|
+
- TracePoint makes the traced code noticeably slower — around 8x on a real
|
|
156
|
+
Rails request. Fine for an investigation, not for something left running in
|
|
157
|
+
production.
|
|
158
|
+
- Ruby >= 3.0.
|
|
159
|
+
- A `-> null` line directly under a `!!` line is the frame unwinding, not a
|
|
160
|
+
method that returned nil.
|
|
161
|
+
- An empty tree says `no app code ran` along with how much was filtered. That
|
|
162
|
+
usually means the block ran entirely inside gems — `Order.where(...).map(&:name)`
|
|
163
|
+
calls no method you wrote, since `name` is generated by ActiveRecord.
|
|
164
|
+
|
|
165
|
+
## Links
|
|
166
|
+
|
|
167
|
+
- [Website](https://carlosdanielpohlod.github.io/rails_tracepoint_stack/) — the
|
|
168
|
+
same story with real captured output, including a whole Rails request
|
|
169
|
+
- [RubyGems](https://rubygems.org/gems/rails_tracepoint_stack)
|
|
170
|
+
- [Changelog](changelog.md)
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
data/changelog.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
**Features**
|
|
6
|
+
|
|
7
|
+
- `RailsTracepointStack.capture` traces a block and returns the traces in
|
|
8
|
+
memory, with no env var, log file or restart involved
|
|
9
|
+
- Traces now carry return values and raised exceptions, not just calls and params
|
|
10
|
+
- Each trace knows its call depth, so a session renders as a call tree
|
|
11
|
+
- `session.to_tree`, `session.as_json` and `session.summary` render a capture
|
|
12
|
+
- Captures are bounded by `max_depth`, `max_traces`, `max_string_length` and
|
|
13
|
+
`max_collection_size`, and report `truncated?` when a limit cut them short
|
|
14
|
+
- Captures watch only the calling thread by default; `threads: :all` opts out
|
|
15
|
+
- `rails g rails_tracepoint_stack:install` writes an agent skill into the app,
|
|
16
|
+
so AI coding agents know when and how to trace it
|
|
17
|
+
|
|
18
|
+
**BugFix**
|
|
19
|
+
|
|
20
|
+
- Params reported every local the method body declared, not just the arguments
|
|
21
|
+
it received, so unassigned locals showed up as arguments passed as nil.
|
|
22
|
+
`TracePoint#parameters` now decides what is read from the binding
|
|
23
|
+
- `GemPath` referenced `Bundler` without requiring it, raising `NameError` on
|
|
24
|
+
the first trace outside a bundle
|
|
25
|
+
- A raise coming out of a C method landed one level too deep in the tree
|
|
26
|
+
|
|
27
|
+
**Changes**
|
|
28
|
+
|
|
29
|
+
- The tracer writes to a sink; the default one keeps the previous logging
|
|
30
|
+
behaviour, so global tracing is unchanged
|
|
31
|
+
- Class methods render as `Foo.bar` rather than `#<Class:Foo>#bar`
|
|
32
|
+
- Compiled templates render as `render app/views/…` with their locals, instead
|
|
33
|
+
of the generated method name and the rendering internals
|
|
34
|
+
- An empty capture reports how many traces the filters dropped, so "nothing
|
|
35
|
+
ran" is distinguishable from "nothing of yours ran"
|
|
36
|
+
- Serialized string values are frozen copies, so a snapshot no longer follows
|
|
37
|
+
later mutations of the traced object
|
|
38
|
+
|
|
39
|
+
## 0.3.5
|
|
40
|
+
|
|
41
|
+
**BugFix**
|
|
42
|
+
|
|
43
|
+
- Sanitize traced params before formatting so JSON logging does not overflow on recursive or problematic objects [Issue #37](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/issues/37)
|
|
44
|
+
- Stabilize text log param rendering across Ruby versions
|
|
45
|
+
|
|
46
|
+
## 0.3.4
|
|
47
|
+
|
|
48
|
+
**Changes**
|
|
49
|
+
|
|
50
|
+
- Fix flaky tests by @danielmbrasil [PR #27](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/pull/27)
|
|
51
|
+
|
|
52
|
+
- Huge refactor of filters class, separating in modules by filter type [PR #18](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/pull/18)
|
|
53
|
+
|
|
54
|
+
- Some other code refactors
|
|
55
|
+
|
|
56
|
+
## 0.3.3
|
|
57
|
+
|
|
58
|
+
**Changes**
|
|
59
|
+
|
|
60
|
+
- Add autoload of all lib files on test_helper
|
|
61
|
+
- Fix some tests
|
|
62
|
+
|
|
63
|
+
**BugFix**
|
|
64
|
+
|
|
65
|
+
- Fixed the Configuration module not loading the default value for the configuration attributes
|
|
66
|
+
|
|
67
|
+
## 0.3.1
|
|
68
|
+
|
|
69
|
+
**Changes:**
|
|
70
|
+
|
|
71
|
+
- Add the ability to include the external sources to the log using `config.log_external_sources = true`
|
|
72
|
+
|
|
73
|
+
## 0.3.0
|
|
74
|
+
|
|
75
|
+
**Changes:**
|
|
76
|
+
|
|
77
|
+
- Refactor classes, formatting a trace using a value object class `RailsTracepointStack::Trace`
|
|
78
|
+
- include configuration `log_format` option, allowing choose an output as `text` or `json`
|
|
79
|
+
- Include configuration `file_path_to_filter_patterns`, allowing filter traces only when the origin file path matches a pattern
|
|
80
|
+
- Improve test coverage
|
|
81
|
+
|
|
82
|
+
## 0.2.1
|
|
83
|
+
|
|
84
|
+
**Changes:**
|
|
85
|
+
|
|
86
|
+
- Update the ENV enable to be more semantic
|
|
87
|
+
- Add the VERSION constant module
|
|
88
|
+
- Sorted the files inside of gemspec
|
|
89
|
+
- Fix the depencies on the gemspec
|
|
90
|
+
- Add the "log_format" configuration support for text and json formats
|
|
91
|
+
|
|
92
|
+
## 0.2.0
|
|
93
|
+
|
|
94
|
+
**Changes:**
|
|
95
|
+
|
|
96
|
+
- Refactor by separating Logger and Filter into their own classes.
|
|
97
|
+
|
|
98
|
+
- Introduce `RailsTracepointStack.configure`, which allows ignoring traces with a custom pattern and customizing the logs output. Example:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
RailsTracepointStack.configure do |config|
|
|
102
|
+
config.ignore_patterns << /services\/foo.rb/
|
|
103
|
+
config.logger = YourLogger
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The default log destination is a file located on `log/rails_tracepoint_stack.log`
|
|
108
|
+
|
|
109
|
+
- Add The possibility of enable the tracer locally, by calling:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Foo
|
|
113
|
+
def bar
|
|
114
|
+
RailsTracepointStack.enable_trace do
|
|
115
|
+
p "your code"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Add Rspec and Rake development dependencies, and add partial test coverage.
|
|
122
|
+
|
|
123
|
+
## 0.1.4
|
|
124
|
+
|
|
125
|
+
**Changes:**
|
|
126
|
+
|
|
127
|
+
- Ignore logs containing `gems/bundler`.
|
|
128
|
+
- Require ruby >= 3.0.
|
|
129
|
+
|
|
130
|
+
**Breaking Changes:**
|
|
131
|
+
|
|
132
|
+
- To enable logs catch, it is necessary to set `RAILS_TRACEPOINT_STACK` as `true`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
require "rails_tracepoint_stack/skill_installer"
|
|
3
|
+
|
|
4
|
+
module RailsTracepointStack
|
|
5
|
+
module Generators
|
|
6
|
+
# Thin wrapper over SkillInstaller so the behaviour stays testable without
|
|
7
|
+
# booting Rails.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
desc "Installs the debug-with-tracepoint skill so agents working in " \
|
|
10
|
+
"this app know how to trace it"
|
|
11
|
+
|
|
12
|
+
def install_agent_skill
|
|
13
|
+
installer = RailsTracepointStack::SkillInstaller.new(
|
|
14
|
+
destination: destination_root,
|
|
15
|
+
force: options[:force]
|
|
16
|
+
)
|
|
17
|
+
written = installer.install
|
|
18
|
+
|
|
19
|
+
if written
|
|
20
|
+
say_status :create, relative_to_original_destination_root(written), :green
|
|
21
|
+
else
|
|
22
|
+
say_status :skip,
|
|
23
|
+
"#{relative_to_original_destination_root(installer.target_path)} already exists (--force to replace)",
|
|
24
|
+
:yellow
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
# Turns raw stack positions into app-level nesting.
|
|
3
|
+
#
|
|
4
|
+
# Counting :call and :return events would not work here, for two reasons.
|
|
5
|
+
# Most frames between two traced methods belong to gems and get filtered out,
|
|
6
|
+
# so an event counter would indent app code by the depth of the framework
|
|
7
|
+
# underneath it. And the tracer does not always watch :return at all - the
|
|
8
|
+
# global tracer only subscribes to :call - so nothing would ever pop.
|
|
9
|
+
#
|
|
10
|
+
# Reading the real stack position of each kept trace avoids both. A frame
|
|
11
|
+
# recorded at an equal or deeper position has necessarily finished, whether
|
|
12
|
+
# it returned, raised, or was abandoned, so it gets dropped on the next
|
|
13
|
+
# event rather than waiting for one that may never arrive.
|
|
14
|
+
class DepthTracker
|
|
15
|
+
def initialize
|
|
16
|
+
@stack = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enter(raw_position)
|
|
20
|
+
drop_finished_frames(raw_position, inclusive: true)
|
|
21
|
+
depth = @stack.size
|
|
22
|
+
@stack.push(raw_position)
|
|
23
|
+
depth
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def leave(raw_position)
|
|
27
|
+
drop_finished_frames(raw_position, inclusive: false)
|
|
28
|
+
return @stack.size unless @stack.last == raw_position
|
|
29
|
+
|
|
30
|
+
@stack.pop
|
|
31
|
+
@stack.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A raise belongs to the innermost frame being tracked. It may report a
|
|
35
|
+
# position deeper than that frame when the exception comes out of a C
|
|
36
|
+
# method the app called, such as an arithmetic coercion, and there is no
|
|
37
|
+
# tracked frame down there to attribute it to.
|
|
38
|
+
def raised(raw_position)
|
|
39
|
+
drop_finished_frames(raw_position, inclusive: false)
|
|
40
|
+
|
|
41
|
+
[@stack.size - 1, 0].max
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def drop_finished_frames(raw_position, inclusive:)
|
|
47
|
+
if inclusive
|
|
48
|
+
@stack.pop while @stack.last && @stack.last >= raw_position
|
|
49
|
+
else
|
|
50
|
+
@stack.pop while @stack.last && @stack.last > raw_position
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
module RailsTracepointStack
|
|
2
2
|
module Filter
|
|
3
3
|
class GemPath
|
|
4
|
+
# Bundler gives the tightest answer, but it is not always there: a plain
|
|
5
|
+
# `ruby -e` or an irb session against an installed gem has no bundle, and
|
|
6
|
+
# asking Bundler then raises instead of filtering. Falling back to every
|
|
7
|
+
# installed gem is wider than needed and still filters out the code the
|
|
8
|
+
# developer did not write.
|
|
4
9
|
def self.full_gem_path
|
|
5
|
-
@full_gem_path ||=
|
|
10
|
+
@full_gem_path ||= bundled_gem_paths || gem_install_roots
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.bundled_gem_paths
|
|
14
|
+
return nil unless defined?(::Bundler)
|
|
15
|
+
|
|
16
|
+
::Bundler.load.specs.map(&:full_gem_path)
|
|
17
|
+
rescue
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Without a bundle there is no resolved list to ask for, but everything
|
|
22
|
+
# installed lives under these roots, which is all the filter needs.
|
|
23
|
+
def self.gem_install_roots
|
|
24
|
+
Gem.path.map { |dir| File.join(dir, "gems") }
|
|
6
25
|
end
|
|
7
26
|
end
|
|
8
27
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
# How much a capture is allowed to keep. The defaults aim at an output a
|
|
3
|
+
# person or an agent can read in one go rather than at completeness: a
|
|
4
|
+
# single Rails request can easily produce tens of thousands of traces.
|
|
5
|
+
class Limits
|
|
6
|
+
DEFAULT_MAX_TRACES = 5_000
|
|
7
|
+
DEFAULT_MAX_STRING_LENGTH = 200
|
|
8
|
+
DEFAULT_MAX_COLLECTION_SIZE = 20
|
|
9
|
+
|
|
10
|
+
attr_reader :max_depth,
|
|
11
|
+
:max_traces,
|
|
12
|
+
:max_string_length,
|
|
13
|
+
:max_collection_size,
|
|
14
|
+
:capture_params,
|
|
15
|
+
:capture_return
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
max_depth: nil,
|
|
19
|
+
max_traces: DEFAULT_MAX_TRACES,
|
|
20
|
+
max_string_length: DEFAULT_MAX_STRING_LENGTH,
|
|
21
|
+
max_collection_size: DEFAULT_MAX_COLLECTION_SIZE,
|
|
22
|
+
capture_params: true,
|
|
23
|
+
capture_return: true
|
|
24
|
+
)
|
|
25
|
+
@max_depth = max_depth
|
|
26
|
+
@max_traces = max_traces
|
|
27
|
+
@max_string_length = max_string_length
|
|
28
|
+
@max_collection_size = max_collection_size
|
|
29
|
+
@capture_params = capture_params
|
|
30
|
+
@capture_return = capture_return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def too_deep?(depth)
|
|
34
|
+
!max_depth.nil? && !depth.nil? && depth > max_depth
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def room_for?(count)
|
|
38
|
+
max_traces.nil? || count < max_traces
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -35,8 +35,12 @@ module RailsTracepointStack
|
|
|
35
35
|
|
|
36
36
|
def self.safe_value(value, ancestry = {})
|
|
37
37
|
case value
|
|
38
|
-
when nil, true, false, Numeric
|
|
38
|
+
when nil, true, false, Numeric
|
|
39
39
|
value
|
|
40
|
+
when String
|
|
41
|
+
# Callers may keep the result around after the traced code moved on,
|
|
42
|
+
# so a live reference to a mutable string would drift.
|
|
43
|
+
value.frozen? ? value : value.dup.freeze
|
|
40
44
|
when Symbol
|
|
41
45
|
value.to_s
|
|
42
46
|
when Array
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
module Renderer
|
|
3
|
+
# Counts what a session holds, so a reader can tell at a glance whether
|
|
4
|
+
# the capture saw what they expected before reading the tree itself.
|
|
5
|
+
module Summary
|
|
6
|
+
def self.call(session)
|
|
7
|
+
{
|
|
8
|
+
calls: count(session, :call),
|
|
9
|
+
returns: count(session, :return),
|
|
10
|
+
raises: count(session, :raise),
|
|
11
|
+
classes: session.traces.map(&:class_name).uniq.size,
|
|
12
|
+
filtered: session.filtered_count,
|
|
13
|
+
truncated: session.truncated?
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.line(session)
|
|
18
|
+
counts = call(session)
|
|
19
|
+
|
|
20
|
+
[
|
|
21
|
+
pluralize(counts[:calls], "call"),
|
|
22
|
+
pluralize(counts[:returns], "return"),
|
|
23
|
+
pluralize(counts[:raises], "raise"),
|
|
24
|
+
pluralize(counts[:classes], "class", "classes")
|
|
25
|
+
].join(", ")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.count(session, kind)
|
|
29
|
+
session.traces.count { |record| record.kind == kind }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.pluralize(count, singular, plural = "#{singular}s")
|
|
33
|
+
"#{count} #{(count == 1) ? singular : plural}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsTracepointStack
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders a session as an indented call tree.
|
|
6
|
+
#
|
|
7
|
+
# The format is built for reading in a terminal or pasting into a prompt,
|
|
8
|
+
# so it favours short lines: paths are relative to the working directory,
|
|
9
|
+
# values are compact JSON, and a return sits one level under the call it
|
|
10
|
+
# belongs to.
|
|
11
|
+
module Tree
|
|
12
|
+
INDENT = " ".freeze
|
|
13
|
+
|
|
14
|
+
def self.call(session)
|
|
15
|
+
lines = session.traces.map { |record| line_for(record) }
|
|
16
|
+
lines << truncation_notice if session.truncated?
|
|
17
|
+
lines << Summary.line(session)
|
|
18
|
+
lines << empty_notice(session) if session.traces.empty?
|
|
19
|
+
lines.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# An empty tree reads like a broken tool. It usually means the block ran
|
|
23
|
+
# entirely inside gems - a bare ActiveRecord query calls no method the
|
|
24
|
+
# developer wrote - so say which of the two happened.
|
|
25
|
+
def self.empty_notice(session)
|
|
26
|
+
return "no code ran inside the block" if session.filtered_count.to_i.zero?
|
|
27
|
+
|
|
28
|
+
"no app code ran: #{session.filtered_count} traces from gems, the framework " \
|
|
29
|
+
"and Ruby itself were filtered out"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.line_for(record)
|
|
33
|
+
case record.kind
|
|
34
|
+
when :call then call_line(record)
|
|
35
|
+
when :return then return_line(record)
|
|
36
|
+
when :raise then raise_line(record)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
SINGLETON_CLASS = /\A#<Class:([A-Z][\w:]*)>\z/
|
|
41
|
+
TEMPLATE_FILE = /\.(erb|haml|slim|builder|jbuilder|rabl)\z/i
|
|
42
|
+
# Everything a template receives besides the locals belongs to the
|
|
43
|
+
# rendering machinery, not to the developer.
|
|
44
|
+
TEMPLATE_LOCALS = "local_assigns".freeze
|
|
45
|
+
|
|
46
|
+
def self.call_line(record)
|
|
47
|
+
return template_line(record) if template?(record)
|
|
48
|
+
|
|
49
|
+
"#{indent(record.depth)}#{qualified_name(record)} " \
|
|
50
|
+
"(#{location(record)}) #{compact(record.params)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A class method is defined on the singleton class, which prints as
|
|
54
|
+
# #<Class:Foo>. Ruby writes that call as Foo.bar, so the tree does too.
|
|
55
|
+
# An anonymous singleton prints as an address and is left alone, since
|
|
56
|
+
# turning that into `0x00007f1234.render` helps nobody.
|
|
57
|
+
def self.qualified_name(record)
|
|
58
|
+
singleton = SINGLETON_CLASS.match(record.class_name.to_s)
|
|
59
|
+
return "#{singleton[1]}.#{record.method_name}" if singleton
|
|
60
|
+
|
|
61
|
+
"#{record.class_name}##{record.method_name}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A compiled template is a generated method on an anonymous class, named
|
|
65
|
+
# after a hash of the file. None of that is worth showing: the path is.
|
|
66
|
+
def self.template?(record)
|
|
67
|
+
TEMPLATE_FILE.match?(record.file_path.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.template_line(record)
|
|
71
|
+
"#{indent(record.depth)}render #{relative_path(record.file_path)} " \
|
|
72
|
+
"#{compact(template_locals(record))}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.template_locals(record)
|
|
76
|
+
params = record.params
|
|
77
|
+
return {} unless params.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
params.fetch(TEMPLATE_LOCALS, {})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.return_line(record)
|
|
83
|
+
"#{indent(record.depth + 1)}-> #{compact(record.return_value)}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.raise_line(record)
|
|
87
|
+
"#{indent(record.depth + 1)}!! #{record.exception_class}: #{record.exception_message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.truncation_notice
|
|
91
|
+
"... truncated: the limit was reached before the block finished"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.indent(depth)
|
|
95
|
+
INDENT * depth
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.location(record)
|
|
99
|
+
"#{relative_path(record.file_path)}:#{record.line_number}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.relative_path(file_path)
|
|
103
|
+
return file_path unless file_path.to_s.start_with?("#{Dir.pwd}/")
|
|
104
|
+
|
|
105
|
+
file_path[(Dir.pwd.length + 1)..]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.compact(value)
|
|
109
|
+
JSON.generate(value)
|
|
110
|
+
rescue SystemStackError, StandardError
|
|
111
|
+
value.to_s
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "rails_tracepoint_stack/limits"
|
|
2
|
+
require "rails_tracepoint_stack/log_formatter"
|
|
3
|
+
require "rails_tracepoint_stack/trace_record"
|
|
4
|
+
require "rails_tracepoint_stack/trace_session"
|
|
5
|
+
require "rails_tracepoint_stack/truncator"
|
|
6
|
+
|
|
7
|
+
module RailsTracepointStack
|
|
8
|
+
module Sink
|
|
9
|
+
# Turns live traces into immutable records held in memory, so a caller can
|
|
10
|
+
# inspect them once the traced block is done.
|
|
11
|
+
class Collector
|
|
12
|
+
attr_reader :session, :limits
|
|
13
|
+
|
|
14
|
+
def initialize(session: RailsTracepointStack::TraceSession.new, limits: RailsTracepointStack::Limits.new)
|
|
15
|
+
@session = session
|
|
16
|
+
@limits = limits
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record(trace)
|
|
20
|
+
return if limits.too_deep?(trace.depth)
|
|
21
|
+
|
|
22
|
+
unless limits.room_for?(session.traces.size)
|
|
23
|
+
session.truncated = true
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
session.add(build_record(trace))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_record(trace)
|
|
33
|
+
exception = trace.exception
|
|
34
|
+
|
|
35
|
+
RailsTracepointStack::TraceRecord.new(
|
|
36
|
+
kind: trace.kind,
|
|
37
|
+
class_name: RailsTracepointStack::LogFormatter.stringify(trace.class_name),
|
|
38
|
+
method_name: trace.method_name,
|
|
39
|
+
file_path: trace.file_path,
|
|
40
|
+
line_number: trace.line_number,
|
|
41
|
+
params: params_for(trace),
|
|
42
|
+
return_value: return_value_for(trace),
|
|
43
|
+
exception_class: exception && exception.class.to_s,
|
|
44
|
+
exception_message: exception && RailsTracepointStack::LogFormatter.stringify(exception.message),
|
|
45
|
+
depth: trace.depth || 0
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def params_for(trace)
|
|
50
|
+
return {} unless limits.capture_params
|
|
51
|
+
|
|
52
|
+
snapshot(trace.params)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def return_value_for(trace)
|
|
56
|
+
return nil unless limits.capture_return
|
|
57
|
+
|
|
58
|
+
snapshot(trace.return_value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def snapshot(value)
|
|
62
|
+
RailsTracepointStack::Truncator.call(
|
|
63
|
+
RailsTracepointStack::LogFormatter.safe_value(value),
|
|
64
|
+
limits
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "rails_tracepoint_stack/logger"
|
|
2
|
+
require "rails_tracepoint_stack/log_formatter"
|
|
3
|
+
|
|
4
|
+
module RailsTracepointStack
|
|
5
|
+
module Sink
|
|
6
|
+
# Formats each trace and writes it out, which is what the gem has always
|
|
7
|
+
# done. Kept as the default so enabling the tracer globally behaves the
|
|
8
|
+
# same as before sinks existed.
|
|
9
|
+
class Log
|
|
10
|
+
def record(trace)
|
|
11
|
+
RailsTracepointStack::Logger.log(
|
|
12
|
+
RailsTracepointStack::LogFormatter.message(trace)
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module RailsTracepointStack
|
|
4
|
+
# Copies the packaged agent skill into the host app.
|
|
5
|
+
#
|
|
6
|
+
# A skill living in this gem's repository only helps someone working on the
|
|
7
|
+
# gem. Agents look for skills inside the project they are working on, so the
|
|
8
|
+
# file has to land there for the gem to ever get picked up on its own.
|
|
9
|
+
class SkillInstaller
|
|
10
|
+
SKILL_PATH = ".claude/skills/debug-with-tracepoint/SKILL.md".freeze
|
|
11
|
+
TEMPLATE_PATH = File.expand_path("templates/skill.md", __dir__).freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :destination, :force
|
|
14
|
+
|
|
15
|
+
def initialize(destination: Dir.pwd, force: false)
|
|
16
|
+
@destination = destination
|
|
17
|
+
@force = force
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the path written, or nil when a skill was already there.
|
|
21
|
+
def install
|
|
22
|
+
return nil if File.exist?(target_path) && !force
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
25
|
+
File.write(target_path, template)
|
|
26
|
+
target_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def target_path
|
|
30
|
+
File.join(destination, SKILL_PATH)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def template
|
|
36
|
+
File.read(TEMPLATE_PATH)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: debug-with-tracepoint
|
|
3
|
+
description: Use when you need to know what a Ruby/Rails code path actually did at runtime - which methods ran, what arguments they got, what each one returned, and where an exception was first raised. Reach for this before adding puts/Rails.logger lines or reading call sites by hand, and whenever a value is wrong but you cannot tell which method produced it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Debugging with rails_tracepoint_stack
|
|
7
|
+
|
|
8
|
+
Traces one block of code and hands back the call tree: every method your app
|
|
9
|
+
defined that ran, with its arguments, its return value, and any exception,
|
|
10
|
+
nested by call depth. Gems, the framework and stdlib are filtered out.
|
|
11
|
+
|
|
12
|
+
## When this beats the alternatives
|
|
13
|
+
|
|
14
|
+
| Question | Use this |
|
|
15
|
+
|---|---|
|
|
16
|
+
| "Which of these methods is returning nil?" | Yes - return values are the point |
|
|
17
|
+
| "Where did this exception actually start?" | Yes - the raise is tagged in the tree |
|
|
18
|
+
| "What is the real order of calls here?" | Yes - the tree shows nesting |
|
|
19
|
+
| "What is the value of one variable right here?" | No - a `binding.irb` is cheaper |
|
|
20
|
+
| "Is this endpoint slow?" | No - this measures nothing |
|
|
21
|
+
|
|
22
|
+
## Run it
|
|
23
|
+
|
|
24
|
+
One shot, no config file, no server restart, output bounded:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rails runner '
|
|
28
|
+
session = RailsTracepointStack.capture(max_depth: 4) do
|
|
29
|
+
Order.find(42).recalculate!
|
|
30
|
+
end
|
|
31
|
+
puts session.to_tree
|
|
32
|
+
'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Reading the output:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Order#recalculate! (app/models/order.rb:88) {}
|
|
39
|
+
Order#apply_discount (app/models/order.rb:102) {"total":200.0}
|
|
40
|
+
Discount#rate_for (app/models/discount.rb:12) {"order":"#<Order id: 42>"}
|
|
41
|
+
-> null <- rate_for returned nil, which is the bug
|
|
42
|
+
-> 200.0
|
|
43
|
+
-> 200.0
|
|
44
|
+
3 calls, 3 returns, 0 raises, 2 classes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- indentation is call depth
|
|
48
|
+
- `-> value` is what the method on the line above returned
|
|
49
|
+
- `!! ArgumentError: message` marks where an exception was raised
|
|
50
|
+
- a `-> null` right after a `!!` is the frame unwinding, not a real return
|
|
51
|
+
- `render app/views/...` is a template; the `{...}` after it are its locals
|
|
52
|
+
- the last line is the summary; `... truncated` means a limit cut it short
|
|
53
|
+
|
|
54
|
+
### An empty tree is an answer, not a failure
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
0 calls, 0 returns, 0 raises, 0 classes
|
|
58
|
+
no app code ran: 34025 traces from gems, the framework and Ruby itself were filtered out
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This means the block ran entirely inside gems. A bare `Order.where(...).map(&:name)`
|
|
62
|
+
calls no method anyone in this app wrote — `name` is generated by ActiveRecord.
|
|
63
|
+
Do not conclude the tool is broken and fall back to `puts`. Either widen the
|
|
64
|
+
capture to include the code that calls into this, or accept that the bug is not
|
|
65
|
+
in app code. `session.filtered_count` is that number if you want it directly.
|
|
66
|
+
|
|
67
|
+
## Keep the output small
|
|
68
|
+
|
|
69
|
+
`capture` is scoped to the calling thread and takes limits. Use them - an
|
|
70
|
+
unbounded capture of a real request is tens of thousands of lines.
|
|
71
|
+
|
|
72
|
+
| Option | Default | Use it to |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `max_depth:` | none | Stay near the top of the tree |
|
|
75
|
+
| `max_traces:` | 5000 | Cap total lines |
|
|
76
|
+
| `max_string_length:` | 200 | Keep big payloads readable |
|
|
77
|
+
| `max_collection_size:` | 20 | Keep loaded associations readable |
|
|
78
|
+
| `capture_params: false` | on | Show only the flow |
|
|
79
|
+
| `capture_return: false` | on | Show only the calls |
|
|
80
|
+
| `threads: :all` | current only | Include background threads |
|
|
81
|
+
|
|
82
|
+
To narrow by file instead, set patterns before capturing:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
RailsTracepointStack.configure do |config|
|
|
86
|
+
config.file_path_to_filter_patterns << %r{app/services/}
|
|
87
|
+
config.ignore_patterns << %r{app/models/concerns/}
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Other shapes of the same data
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
session.as_json # structured: one entry per trace
|
|
95
|
+
session.summary # counts only: {calls:, returns:, raises:, classes:, truncated:}
|
|
96
|
+
session.result # what the block itself returned
|
|
97
|
+
session.error # the exception that escaped, if any
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`capture` re-raises whatever the block raised. To keep the traces in that
|
|
101
|
+
case, take the session from the block argument:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
session = nil
|
|
105
|
+
begin
|
|
106
|
+
RailsTracepointStack.capture { |s| session = s; thing_that_blows_up }
|
|
107
|
+
rescue => error
|
|
108
|
+
puts session.to_tree
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Worth knowing
|
|
113
|
+
|
|
114
|
+
- Requires the gem in the app: `gem "rails_tracepoint_stack"`.
|
|
115
|
+
- TracePoint slows the traced block down noticeably. Fine for a one-off
|
|
116
|
+
investigation, not for anything left running.
|
|
117
|
+
- Only methods defined in the app appear. If a method you expected is
|
|
118
|
+
missing, it is probably in a gem - add its path to
|
|
119
|
+
`file_path_to_filter_patterns` to force it in.
|
|
120
|
+
- `RAILS_TRACEPOINT_STACK_ENABLED=true` traces the whole process to
|
|
121
|
+
`log/rails_tracepoint_stack.log` instead. Prefer `capture` unless you need
|
|
122
|
+
to trace something you cannot wrap in a block, such as boot.
|
|
@@ -5,6 +5,7 @@ module RailsTracepointStack
|
|
|
5
5
|
extend Forwardable
|
|
6
6
|
|
|
7
7
|
attr_reader :params, :trace_point
|
|
8
|
+
attr_accessor :depth
|
|
8
9
|
|
|
9
10
|
def_delegator :@trace_point, :defined_class, :class_name
|
|
10
11
|
def_delegator :@trace_point, :method_id, :method_name
|
|
@@ -15,16 +16,42 @@ module RailsTracepointStack
|
|
|
15
16
|
@trace_point = trace_point
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
def kind
|
|
20
|
+
trace_point.event
|
|
21
|
+
end
|
|
22
|
+
|
|
18
23
|
def params
|
|
24
|
+
return {} unless kind == :call
|
|
25
|
+
|
|
19
26
|
@params ||= fetch_params(trace_point)
|
|
20
27
|
end
|
|
21
28
|
|
|
29
|
+
def return_value
|
|
30
|
+
return nil unless kind == :return
|
|
31
|
+
|
|
32
|
+
trace_point.return_value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exception
|
|
36
|
+
return nil unless kind == :raise
|
|
37
|
+
|
|
38
|
+
trace_point.raised_exception
|
|
39
|
+
end
|
|
40
|
+
|
|
22
41
|
private
|
|
23
42
|
|
|
43
|
+
# Every local the method body declares is already in scope at :call time,
|
|
44
|
+
# holding nil. Reading the whole binding would report those as arguments
|
|
45
|
+
# the caller passed as nil, so the parameter list decides what to read.
|
|
24
46
|
def fetch_params(trace_point)
|
|
25
|
-
trace_point.binding
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
binding = trace_point.binding
|
|
48
|
+
declared = binding.local_variables
|
|
49
|
+
|
|
50
|
+
trace_point.parameters.each_with_object({}) do |(_type, name), params|
|
|
51
|
+
next if name.nil? || !declared.include?(name)
|
|
52
|
+
|
|
53
|
+
params[name] = binding.local_variable_get(name)
|
|
54
|
+
end
|
|
28
55
|
end
|
|
29
56
|
end
|
|
30
57
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
# An immutable snapshot of a single trace. The TracePoint object is only
|
|
3
|
+
# valid while its event is being handled, so anything worth keeping has to
|
|
4
|
+
# be copied out before the handler returns.
|
|
5
|
+
TraceRecord = Struct.new(
|
|
6
|
+
:kind,
|
|
7
|
+
:class_name,
|
|
8
|
+
:method_name,
|
|
9
|
+
:file_path,
|
|
10
|
+
:line_number,
|
|
11
|
+
:params,
|
|
12
|
+
:return_value,
|
|
13
|
+
:exception_class,
|
|
14
|
+
:exception_message,
|
|
15
|
+
:depth,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
) do
|
|
18
|
+
def call?
|
|
19
|
+
kind == :call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def return?
|
|
23
|
+
kind == :return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def raise?
|
|
27
|
+
kind == :raise
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "rails_tracepoint_stack/renderer/summary"
|
|
3
|
+
require "rails_tracepoint_stack/renderer/tree"
|
|
4
|
+
|
|
5
|
+
module RailsTracepointStack
|
|
6
|
+
# The traces gathered by one capture, plus whatever the traced block itself
|
|
7
|
+
# produced.
|
|
8
|
+
class TraceSession
|
|
9
|
+
attr_reader :traces
|
|
10
|
+
attr_accessor :result, :error, :filtered_count
|
|
11
|
+
attr_writer :truncated
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@traces = []
|
|
15
|
+
@truncated = false
|
|
16
|
+
@filtered_count = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(record)
|
|
20
|
+
@traces << record
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def empty?
|
|
24
|
+
@traces.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when a limit stopped the collection, so the traces are a prefix of
|
|
28
|
+
# what actually ran rather than the whole story.
|
|
29
|
+
def truncated?
|
|
30
|
+
@truncated
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_tree
|
|
34
|
+
RailsTracepointStack::Renderer::Tree.call(self)
|
|
35
|
+
end
|
|
36
|
+
alias_method :to_s, :to_tree
|
|
37
|
+
|
|
38
|
+
def summary
|
|
39
|
+
RailsTracepointStack::Renderer::Summary.call(self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def as_json
|
|
43
|
+
{
|
|
44
|
+
summary: summary,
|
|
45
|
+
traces: traces.map { |record| entry_for(record) }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_json(*args)
|
|
50
|
+
JSON.generate(as_json, *args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def entry_for(record)
|
|
56
|
+
entry = {
|
|
57
|
+
kind: record.kind,
|
|
58
|
+
depth: record.depth,
|
|
59
|
+
class_name: record.class_name,
|
|
60
|
+
method_name: record.method_name,
|
|
61
|
+
file_path: "#{RailsTracepointStack::Renderer::Tree.relative_path(record.file_path)}:#{record.line_number}",
|
|
62
|
+
params: record.params
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# A method that returned nil is often the answer being looked for, so
|
|
66
|
+
# the key stays even when there is nothing in it.
|
|
67
|
+
entry[:return_value] = record.return_value if record.return?
|
|
68
|
+
|
|
69
|
+
if record.raise?
|
|
70
|
+
entry[:exception_class] = record.exception_class
|
|
71
|
+
entry[:exception_message] = record.exception_message
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
entry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -3,30 +3,80 @@ require "rails_tracepoint_stack/logger"
|
|
|
3
3
|
require "rails_tracepoint_stack/trace_filter"
|
|
4
4
|
require "rails_tracepoint_stack/trace"
|
|
5
5
|
require "rails_tracepoint_stack/log_formatter"
|
|
6
|
+
require "rails_tracepoint_stack/sink/log"
|
|
7
|
+
require "rails_tracepoint_stack/depth_tracker"
|
|
6
8
|
|
|
7
9
|
module RailsTracepointStack
|
|
8
10
|
class Tracer
|
|
9
11
|
include RailsTracepointStack::TraceFilter
|
|
10
12
|
extend Forwardable
|
|
11
|
-
|
|
13
|
+
|
|
14
|
+
DEFAULT_EVENTS = [:call].freeze
|
|
15
|
+
|
|
12
16
|
def_delegators :@tracer, :enable, :disable
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
# How many traces the filters dropped. A capture that keeps nothing is
|
|
19
|
+
# ambiguous on its own: this tells the difference between code that never
|
|
20
|
+
# ran and code that ran entirely inside gems.
|
|
21
|
+
attr_reader :filtered_count
|
|
22
|
+
|
|
23
|
+
# A nil thread watches every thread in the process, which is what the
|
|
24
|
+
# global tracer wants. Passing one confines tracing to it, so a capture
|
|
25
|
+
# running inside a threaded server does not pick up other requests.
|
|
26
|
+
def initialize(sink: RailsTracepointStack::Sink::Log.new, events: DEFAULT_EVENTS, thread: nil)
|
|
27
|
+
@sink = sink
|
|
28
|
+
@events = events
|
|
29
|
+
@thread = thread
|
|
30
|
+
@filtered_count = 0
|
|
15
31
|
generate_tracer
|
|
16
32
|
end
|
|
17
33
|
|
|
18
34
|
private
|
|
19
35
|
|
|
20
36
|
def generate_tracer
|
|
21
|
-
@tracer ||= TracePoint.new(
|
|
37
|
+
@tracer ||= TracePoint.new(*@events) do |tracepoint|
|
|
38
|
+
next if out_of_scope_thread?
|
|
39
|
+
|
|
22
40
|
trace = RailsTracepointStack::Trace.new(trace_point: tracepoint)
|
|
23
41
|
|
|
24
|
-
|
|
42
|
+
if ignore_trace?(trace: trace)
|
|
43
|
+
@filtered_count += 1
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
trace.depth = depth_for(trace)
|
|
48
|
+
@sink.record(trace)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def out_of_scope_thread?
|
|
53
|
+
!@thread.nil? && Thread.current != @thread
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Only kept traces pay for reading the stack position, so the cost stays
|
|
57
|
+
# proportional to the app code being traced rather than to everything the
|
|
58
|
+
# VM runs underneath it.
|
|
59
|
+
def depth_for(trace)
|
|
60
|
+
tracker = depth_tracker
|
|
61
|
+
raw_position = raw_stack_position
|
|
25
62
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
63
|
+
case trace.kind
|
|
64
|
+
when :call then tracker.enter(raw_position)
|
|
65
|
+
when :return then tracker.leave(raw_position)
|
|
66
|
+
when :raise then tracker.raised(raw_position)
|
|
29
67
|
end
|
|
30
68
|
end
|
|
69
|
+
|
|
70
|
+
def raw_stack_position
|
|
71
|
+
caller_locations(1)&.length || 0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def depth_tracker
|
|
75
|
+
Thread.current[depth_tracker_key] ||= RailsTracepointStack::DepthTracker.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def depth_tracker_key
|
|
79
|
+
@depth_tracker_key ||= :"rails_tracepoint_stack_depth_#{object_id}"
|
|
80
|
+
end
|
|
31
81
|
end
|
|
32
82
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
# Shrinks an already-serialized value so one fat argument (a big payload, a
|
|
3
|
+
# long SQL string, a loaded association) cannot dominate the output. Runs
|
|
4
|
+
# over the plain structures LogFormatter.safe_value produces, so it only
|
|
5
|
+
# ever sees strings, numbers, booleans, nil, arrays and hashes.
|
|
6
|
+
module Truncator
|
|
7
|
+
ELLIPSIS = "…".freeze
|
|
8
|
+
|
|
9
|
+
def self.call(value, limits)
|
|
10
|
+
case value
|
|
11
|
+
when String then truncate_string(value, limits)
|
|
12
|
+
when Array then truncate_array(value, limits)
|
|
13
|
+
when Hash then truncate_hash(value, limits)
|
|
14
|
+
else value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.truncate_string(value, limits)
|
|
19
|
+
max = limits.max_string_length
|
|
20
|
+
return value if max.nil? || value.length <= max
|
|
21
|
+
|
|
22
|
+
"#{value[0, max]}#{ELLIPSIS} (#{value.length} chars)"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.truncate_array(value, limits)
|
|
26
|
+
max = limits.max_collection_size
|
|
27
|
+
kept = (max.nil? || value.length <= max) ? value : value.first(max)
|
|
28
|
+
result = kept.map { |item| call(item, limits) }
|
|
29
|
+
|
|
30
|
+
return result if kept.length == value.length
|
|
31
|
+
|
|
32
|
+
result << "(+#{value.length - kept.length} more)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.truncate_hash(value, limits)
|
|
36
|
+
max = limits.max_collection_size
|
|
37
|
+
dropped = (max.nil? || value.size <= max) ? 0 : value.size - max
|
|
38
|
+
kept = dropped.zero? ? value : value.first(max).to_h
|
|
39
|
+
|
|
40
|
+
result = kept.each_with_object({}) do |(key, item), memo|
|
|
41
|
+
memo[key] = call(item, limits)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return result if dropped.zero?
|
|
45
|
+
|
|
46
|
+
result[ELLIPSIS] = "(+#{dropped} more)"
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
require 'rails_tracepoint_stack/configuration'
|
|
2
2
|
require 'rails_tracepoint_stack/log_formatter'
|
|
3
3
|
require 'rails_tracepoint_stack/tracer'
|
|
4
|
+
require 'rails_tracepoint_stack/limits'
|
|
5
|
+
require 'rails_tracepoint_stack/skill_installer'
|
|
6
|
+
require 'rails_tracepoint_stack/sink/collector'
|
|
7
|
+
require 'rails_tracepoint_stack/trace_session'
|
|
4
8
|
|
|
5
9
|
$rails_tracer_rtps = nil
|
|
6
10
|
|
|
@@ -17,6 +21,37 @@ module RailsTracepointStack
|
|
|
17
21
|
yield(configuration)
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
CAPTURE_EVENTS = [:call, :return, :raise].freeze
|
|
25
|
+
|
|
26
|
+
# Traces a block and hands back everything that happened inside it, instead
|
|
27
|
+
# of writing to a log. Meant to be run as a one-off: capture, read, done.
|
|
28
|
+
def self.capture(threads: :current, **limit_options)
|
|
29
|
+
raise ArgumentError, "Block not given to #capture" unless block_given?
|
|
30
|
+
|
|
31
|
+
collector = RailsTracepointStack::Sink::Collector.new(
|
|
32
|
+
limits: RailsTracepointStack::Limits.new(**limit_options)
|
|
33
|
+
)
|
|
34
|
+
session = collector.session
|
|
35
|
+
tracer = RailsTracepointStack::Tracer.new(
|
|
36
|
+
sink: collector,
|
|
37
|
+
events: CAPTURE_EVENTS,
|
|
38
|
+
thread: (threads == :all) ? nil : Thread.current
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
tracer.enable
|
|
42
|
+
begin
|
|
43
|
+
session.result = yield(session)
|
|
44
|
+
rescue Exception => error
|
|
45
|
+
session.error = error
|
|
46
|
+
raise
|
|
47
|
+
ensure
|
|
48
|
+
tracer.disable
|
|
49
|
+
session.filtered_count = tracer.filtered_count
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
session
|
|
53
|
+
end
|
|
54
|
+
|
|
20
55
|
def self.enable_trace
|
|
21
56
|
raise ArgumentError, "Block not given to #enable_trace" unless block_given?
|
|
22
57
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_tracepoint_stack
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carlos Daniel Pohlod
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -70,33 +70,50 @@ dependencies:
|
|
|
70
70
|
- - ">="
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
72
|
version: 1.39.1
|
|
73
|
-
description:
|
|
74
|
-
code
|
|
75
|
-
|
|
73
|
+
description: Traces a block of your Rails app and returns the call tree of your own
|
|
74
|
+
code - arguments, return values, raised exceptions and call depth - with gems, the
|
|
75
|
+
framework and stdlib filtered out. Bounded output meant to be read directly, by
|
|
76
|
+
a developer or by an AI coding agent debugging the app.
|
|
76
77
|
email: carlospohlod@gmail.com
|
|
77
78
|
executables: []
|
|
78
79
|
extensions: []
|
|
79
80
|
extra_rdoc_files: []
|
|
80
81
|
files:
|
|
82
|
+
- README.md
|
|
83
|
+
- changelog.md
|
|
84
|
+
- lib/generators/rails_tracepoint_stack/install/install_generator.rb
|
|
81
85
|
- lib/rails_tracepoint_stack.rb
|
|
82
86
|
- lib/rails_tracepoint_stack/configuration.rb
|
|
87
|
+
- lib/rails_tracepoint_stack/depth_tracker.rb
|
|
83
88
|
- lib/rails_tracepoint_stack/filter/custom_trace_selector_filter.rb
|
|
84
89
|
- lib/rails_tracepoint_stack/filter/gem_path.rb
|
|
85
90
|
- lib/rails_tracepoint_stack/filter/rb_config.rb
|
|
86
91
|
- lib/rails_tracepoint_stack/filter/trace_from_dependencies_filter.rb
|
|
87
92
|
- lib/rails_tracepoint_stack/filter/trace_from_ruby_code_filter.rb
|
|
88
93
|
- lib/rails_tracepoint_stack/filter/trace_to_ignore_filter.rb
|
|
94
|
+
- lib/rails_tracepoint_stack/limits.rb
|
|
89
95
|
- lib/rails_tracepoint_stack/log_formatter.rb
|
|
90
96
|
- lib/rails_tracepoint_stack/logger.rb
|
|
97
|
+
- lib/rails_tracepoint_stack/renderer/summary.rb
|
|
98
|
+
- lib/rails_tracepoint_stack/renderer/tree.rb
|
|
99
|
+
- lib/rails_tracepoint_stack/sink/collector.rb
|
|
100
|
+
- lib/rails_tracepoint_stack/sink/log.rb
|
|
101
|
+
- lib/rails_tracepoint_stack/skill_installer.rb
|
|
102
|
+
- lib/rails_tracepoint_stack/templates/skill.md
|
|
91
103
|
- lib/rails_tracepoint_stack/trace.rb
|
|
92
104
|
- lib/rails_tracepoint_stack/trace_filter.rb
|
|
105
|
+
- lib/rails_tracepoint_stack/trace_record.rb
|
|
106
|
+
- lib/rails_tracepoint_stack/trace_session.rb
|
|
93
107
|
- lib/rails_tracepoint_stack/tracer.rb
|
|
108
|
+
- lib/rails_tracepoint_stack/truncator.rb
|
|
94
109
|
- lib/rails_tracepoint_stack/version.rb
|
|
95
|
-
homepage: https://github.
|
|
110
|
+
homepage: https://carlosdanielpohlod.github.io/rails_tracepoint_stack/
|
|
96
111
|
licenses:
|
|
97
112
|
- MIT
|
|
98
113
|
metadata:
|
|
99
|
-
|
|
114
|
+
source_code_uri: https://github.com/carlosdanielpohlod/rails_tracepoint_stack
|
|
115
|
+
documentation_uri: https://github.com/carlosdanielpohlod/rails_tracepoint_stack#readme
|
|
116
|
+
bug_tracker_uri: https://github.com/carlosdanielpohlod/rails_tracepoint_stack/issues
|
|
100
117
|
changelog_uri: https://github.com/carlosdanielpohlod/rails_tracepoint_stack/blob/main/changelog.md
|
|
101
118
|
post_install_message:
|
|
102
119
|
rdoc_options: []
|
|
@@ -116,5 +133,6 @@ requirements: []
|
|
|
116
133
|
rubygems_version: 3.4.19
|
|
117
134
|
signing_key:
|
|
118
135
|
specification_version: 4
|
|
119
|
-
summary:
|
|
136
|
+
summary: 'Runtime call tree for a Rails app: which methods ran, with what params,
|
|
137
|
+
and what they returned.'
|
|
120
138
|
test_files: []
|