who_rendered 0.1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/lib/who_rendered/call_site.rb +108 -0
- data/lib/who_rendered/configuration.rb +51 -0
- data/lib/who_rendered/controller_hooks.rb +60 -0
- data/lib/who_rendered/log_subscriber.rb +22 -0
- data/lib/who_rendered/payload.rb +84 -0
- data/lib/who_rendered/railtie.rb +74 -0
- data/lib/who_rendered/version.rb +5 -0
- data/lib/who_rendered.rb +90 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4713dd45c79efc90221e9f481db8474311cc4e7b5a8a7cbed88815625931b07b
|
|
4
|
+
data.tar.gz: 452bc6faa52f8b2661b068ca3163be94e4c17b83572745ebad691aa21e642baf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e20409b47a114845dd17c509718e566e8fa43cc42d60c75e60c048578ae945e6be012b77664cd19178c3054cd34825ae6c955e80ecdbde04dbbccebcf5bac4bd
|
|
7
|
+
data.tar.gz: 0c60f0421f81feafa2732d1d6fa62168cba635cd8ed153886d70d5033b9192799e1325bfde3611d2f950ac510bb05b0fe54b77315b245104b90a31cb0d85a42f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-09-11)
|
|
4
|
+
|
|
5
|
+
- Initial release. Adds `Rendered by: <file>:<line>` to the `Completed` log line, naming the
|
|
6
|
+
`render`, `head`, or `redirect_to` call site that produced the response.
|
|
7
|
+
- Tested against Rails 7.1, 7.2, 8.0, 8.1 and `main`, with no version branching.
|
|
8
|
+
- Installs on `ActionController::Instrumentation` rather than on `ActionController::Base` and
|
|
9
|
+
`ActionController::API`, so a gem that rebinds `append_info_to_payload` — lograge's
|
|
10
|
+
`custom_payload` does — cannot end up as its own `super`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stephen Crosby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# who_rendered
|
|
2
|
+
|
|
3
|
+
Find out who called render. Adds the file and line that produced a Rails response to the
|
|
4
|
+
`Completed` log line — so an unexplained 403 names its own source.
|
|
5
|
+
|
|
6
|
+
## Before
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Processing by Admin::ReportsController#index as HTML
|
|
10
|
+
Filter chain halted as :require_admin rendered or redirected
|
|
11
|
+
Completed 403 Forbidden in 3ms (Views: 0.0ms | ActiveRecord: 0.5ms)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## After
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Processing by Admin::ReportsController#index as HTML
|
|
18
|
+
Filter chain halted as :require_admin rendered or redirected
|
|
19
|
+
Completed 403 Forbidden in 3ms (Views: 0.0ms | ActiveRecord: 0.5ms | Rendered by: app/controllers/concerns/admin_gate.rb:14:in 'require_admin')
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem "who_rendered", group: :development
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No configuration is required. By default the gem is active in development and test, and adds
|
|
30
|
+
no log lines of its own — only the `Rendered by:` text on the `Completed` line Rails already
|
|
31
|
+
prints.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# config/initializers/who_rendered.rb
|
|
37
|
+
WhoRendered.configure do |config|
|
|
38
|
+
config.enabled = Rails.env.local? # default
|
|
39
|
+
config.frames = 1 # default
|
|
40
|
+
config.capture = :always # default
|
|
41
|
+
config.logger = nil # default: Rails.logger
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Setting | Default | Meaning |
|
|
46
|
+
| --------- | ------------------ | -------------------------------------------------------------------------------------- |
|
|
47
|
+
| `enabled` | `Rails.env.local?` | When false, every hook becomes a pass-through. |
|
|
48
|
+
| `frames` | `1` | Frames to report. `1` is the `Completed` line only; more adds `↳ from` lines after it. |
|
|
49
|
+
| `capture` | `:always` | `:non_2xx` skips the stack walk on successful responses. |
|
|
50
|
+
| `logger` | `nil` | Used for the `↳ from` lines only. |
|
|
51
|
+
|
|
52
|
+
## When the render comes from a dependency
|
|
53
|
+
|
|
54
|
+
If the response was produced entirely inside a gem — a `before_action` from an included
|
|
55
|
+
module, a gem's `rescue_from` handler — there is no application frame to report. The gem
|
|
56
|
+
names the dependency instead of going quiet:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Completed 401 Unauthorized in 2ms (Rendered by: devise-4.9.4/lib/devise/controllers/helpers.rb:99:in 'authenticate_user!')
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Wrapper helpers and `frames`
|
|
63
|
+
|
|
64
|
+
If your renders go through a helper, the innermost application frame is that helper — the
|
|
65
|
+
same line for every 403 in the app. Set `frames` above 1 so the helper cannot hide its
|
|
66
|
+
caller:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
WhoRendered.configure { |config| config.frames = 3 }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Completed 403 Forbidden in 3ms (Views: 0.0ms | Rendered by: app/controllers/application_controller.rb:88:in 'render_403')
|
|
74
|
+
↳ from app/controllers/concerns/admin_gate.rb:14:in 'require_admin'
|
|
75
|
+
↳ from app/controllers/admin/reports_controller.rb:3:in 'index'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What it does not report
|
|
79
|
+
|
|
80
|
+
Requests where the application never called `render` — the `def show; end` case, where Rails
|
|
81
|
+
renders the template for you. There is no decision to attribute, and `Processing by
|
|
82
|
+
PostsController#show` already tells you where to look.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
A prepended module wraps `render`, `head`, and `redirect_to`, calls `super`, and records the
|
|
87
|
+
call stack. At the end of the action, the documented hook `append_info_to_payload` adds the
|
|
88
|
+
frames to the `process_action.action_controller` payload, and an override of
|
|
89
|
+
`ActionController::Base.log_process_action` appends the `Rendered by:` text to the `Completed`
|
|
90
|
+
line — the same pair of hooks Active Record uses to add `ActiveRecord: 1.2ms` to that line.
|
|
91
|
+
|
|
92
|
+
The `Completed` line's text is handed between those two hooks out of band rather than through
|
|
93
|
+
the payload, since the payload `log_process_action` receives is not always the one
|
|
94
|
+
`append_info_to_payload` wrote to. That is what keeps the feature working on every supported
|
|
95
|
+
version without a version check.
|
|
96
|
+
|
|
97
|
+
The gem also subscribes to `start_processing.action_controller` to empty that hand-off slot at
|
|
98
|
+
the top of every instrumented request, which is what stops one request's attribution from
|
|
99
|
+
reaching the next. Unsubscribing that event *by name* — the usual recipe for silencing the
|
|
100
|
+
`Processing by` line — removes every listener for it, including this one. The write hook clears
|
|
101
|
+
the slot too, so almost nothing changes if that happens; misattribution needs both the missing
|
|
102
|
+
subscription *and* a controller whose own `append_info_to_payload` does not call `super`.
|
|
103
|
+
|
|
104
|
+
Payload keys, for anyone subscribing to `process_action.action_controller` directly:
|
|
105
|
+
|
|
106
|
+
| Key | Type |
|
|
107
|
+
| ----------------------- | ------------------------------------------------ |
|
|
108
|
+
| `:render_source` | `String` |
|
|
109
|
+
| `:render_source_frames` | `Array<String>` (only when `frames > 1`) |
|
|
110
|
+
| `:render_method` | `Symbol` — `:render`, `:head`, or `:redirect_to` |
|
|
111
|
+
|
|
112
|
+
If the gem hits an internal error it logs one warning and disables itself for the rest of the
|
|
113
|
+
process. It never raises into a request.
|
|
114
|
+
|
|
115
|
+
## Limitations
|
|
116
|
+
|
|
117
|
+
- **Structured events do not carry the data on Rails 8.1+.**
|
|
118
|
+
`ActionController::StructuredEventSubscriber#additions_for` is a hardcoded
|
|
119
|
+
`payload.slice(:view_runtime, :db_runtime, :queries_count, :cached_queries_count)`, so
|
|
120
|
+
custom keys are dropped from the `action_controller.request_completed` structured event.
|
|
121
|
+
`ActiveSupport::Notifications` subscribers are unaffected; they receive every key. The text
|
|
122
|
+
log is unaffected too, because the `Rendered by:` text does not travel through a payload at
|
|
123
|
+
all. Making the slice extensible is a small upstream change worth proposing separately.
|
|
124
|
+
- **lograge replaces the `Completed` line, so you have to bridge the payload yourself.**
|
|
125
|
+
lograge calls `remove_existing_log_subscriptions`, which unsubscribes
|
|
126
|
+
`ActionController::LogSubscriber` — a lograge app has no `Completed` line at all, and the
|
|
127
|
+
`Rendered by:` text has nowhere to go. `payload[:render_source]` is still there, so pass it
|
|
128
|
+
through:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
config.lograge.custom_options = lambda do |event|
|
|
132
|
+
{ rendered_by: event.payload[:render_source] }.compact
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **Overlaps with `verbose_redirect_logs` on Rails 8.1+.** Both may report a redirect's
|
|
137
|
+
origin. The gem does not suppress the Rails feature; the two disagree usefully, since Rails
|
|
138
|
+
prints nothing when the redirect comes from a gem.
|
|
139
|
+
|
|
140
|
+
## Requirements
|
|
141
|
+
|
|
142
|
+
Rails 7.1 or newer, Ruby 3.1 or newer.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhoRendered
|
|
4
|
+
# Selects the frames worth reporting from a raw call stack.
|
|
5
|
+
#
|
|
6
|
+
# Deliberately free of any Rails dependency: every root it needs is injected,
|
|
7
|
+
# so the whole tier ladder is testable without booting anything.
|
|
8
|
+
class CallSite
|
|
9
|
+
# Rails renders from here when the action did not call render itself.
|
|
10
|
+
IMPLICIT_RENDER_BASENAMES = ["implicit_render.rb", "basic_implicit_render.rb"].freeze
|
|
11
|
+
|
|
12
|
+
# Anchored and greedy on purpose. The rubygems layout nests a "gems"
|
|
13
|
+
# directory inside another (.../gems/3.4.0/gems/devise-4.9.4/...), so only
|
|
14
|
+
# the rightmost match names the gem. Bundler's git layout
|
|
15
|
+
# (.../bundler/gems/pundit-abc1234/...) falls out of the same pattern.
|
|
16
|
+
GEM_PATH = %r{\A.*/gems/([^/]+)/(.+)\z}
|
|
17
|
+
|
|
18
|
+
# Application subdirectories that are not really application code.
|
|
19
|
+
EXCLUDED_APP_PREFIXES = ["vendor/", "bin/"].freeze
|
|
20
|
+
|
|
21
|
+
# One reportable stack frame, formatted for humans.
|
|
22
|
+
class Frame
|
|
23
|
+
def initialize(location, display_path)
|
|
24
|
+
@location = location
|
|
25
|
+
@display_path = display_path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
label = @location.label
|
|
30
|
+
base = "#{@display_path}:#{@location.lineno}"
|
|
31
|
+
label.nil? || label.empty? ? base : "#{base}:in '#{label}'"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(app_root:, framework_roots:, own_root:)
|
|
36
|
+
@app_root = normalize(app_root)
|
|
37
|
+
@own_root = normalize(own_root)
|
|
38
|
+
@framework_roots = Array(framework_roots).filter_map { |root| normalize(root) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# True when Rails, not the application, called render.
|
|
42
|
+
def implicit_render?(location)
|
|
43
|
+
path = path_for(location)
|
|
44
|
+
return false unless path
|
|
45
|
+
|
|
46
|
+
IMPLICIT_RENDER_BASENAMES.include?(File.basename(path)) && framework?(path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def frames(locations, limit: 1)
|
|
50
|
+
candidates = locations.reject { |location| own?(path_for(location)) }
|
|
51
|
+
return [] if candidates.empty?
|
|
52
|
+
|
|
53
|
+
app = candidates.select { |location| app?(path_for(location)) }
|
|
54
|
+
return build(app, limit) if app.any?
|
|
55
|
+
|
|
56
|
+
external = candidates.reject { |location| framework?(path_for(location)) }
|
|
57
|
+
return build(external, limit) if external.any?
|
|
58
|
+
|
|
59
|
+
build(candidates, limit)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def build(locations, limit)
|
|
64
|
+
locations.first(limit).map { |location| Frame.new(location, display_path(path_for(location))) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def display_path(path)
|
|
68
|
+
return "?" unless path
|
|
69
|
+
return path.delete_prefix(@app_root) if @app_root && path.start_with?(@app_root)
|
|
70
|
+
|
|
71
|
+
match = GEM_PATH.match(path)
|
|
72
|
+
match ? "#{match[1]}/#{match[2]}" : path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def app?(path)
|
|
76
|
+
return false unless path && @app_root && path.start_with?(@app_root)
|
|
77
|
+
|
|
78
|
+
relative = path.delete_prefix(@app_root)
|
|
79
|
+
EXCLUDED_APP_PREFIXES.none? { |prefix| relative.start_with?(prefix) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def framework?(path)
|
|
83
|
+
return false unless path
|
|
84
|
+
|
|
85
|
+
@framework_roots.any? { |root| path.start_with?(root) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def own?(path)
|
|
89
|
+
return false unless path && @own_root
|
|
90
|
+
|
|
91
|
+
path.start_with?(@own_root)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def path_for(location)
|
|
95
|
+
return nil unless location
|
|
96
|
+
|
|
97
|
+
location.absolute_path || location.path
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trailing separator so start_with? cannot match a sibling directory
|
|
101
|
+
# whose name merely begins with the root's name.
|
|
102
|
+
def normalize(root)
|
|
103
|
+
return nil if root.nil? || root.to_s.empty?
|
|
104
|
+
|
|
105
|
+
File.join(File.expand_path(root.to_s), "")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhoRendered
|
|
4
|
+
# User-facing settings. See README for the meaning of each.
|
|
5
|
+
class Configuration
|
|
6
|
+
CAPTURE_MODES = [:always, :non_2xx].freeze
|
|
7
|
+
|
|
8
|
+
# nil means "decide from the Rails environment when asked".
|
|
9
|
+
attr_accessor :enabled
|
|
10
|
+
attr_reader :logger, :frames, :capture
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@enabled = nil
|
|
14
|
+
@frames = 1
|
|
15
|
+
@capture = :always
|
|
16
|
+
@logger = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enabled?
|
|
20
|
+
return !!@enabled unless @enabled.nil?
|
|
21
|
+
|
|
22
|
+
!!(defined?(::Rails.env) && ::Rails.env.local?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def logger=(value)
|
|
26
|
+
unless value.nil? || (value.respond_to?(:warn) && value.respond_to?(:info))
|
|
27
|
+
raise ArgumentError, "logger must be nil or respond to :warn and :info, got #{value.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@logger = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def frames=(value)
|
|
34
|
+
unless value.is_a?(Integer)
|
|
35
|
+
raise ArgumentError, "frames must be an Integer, got #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "frames must be >= 1, got #{value}" if value < 1
|
|
39
|
+
|
|
40
|
+
@frames = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def capture=(value)
|
|
44
|
+
unless CAPTURE_MODES.include?(value)
|
|
45
|
+
raise ArgumentError, "capture must be :always or :non_2xx, got #{value.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@capture = value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhoRendered
|
|
4
|
+
# Captures the call site of a response-producing method. Capture only: this
|
|
5
|
+
# module never logs, formats, or decides what to print.
|
|
6
|
+
#
|
|
7
|
+
# Each wrapper calls super first and inspects state afterwards, so the gem
|
|
8
|
+
# never has to parse Rails' render arguments — the part of that API most
|
|
9
|
+
# likely to change between versions. Bare `super` forwards the original
|
|
10
|
+
# arguments verbatim, keywords included.
|
|
11
|
+
module ControllerHooks
|
|
12
|
+
def render(*args, **kwargs, &block)
|
|
13
|
+
result = super
|
|
14
|
+
__who_rendered_note(:render, caller_locations(1)) if __who_rendered_capture?
|
|
15
|
+
result
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def head(*args, **kwargs, &block)
|
|
19
|
+
result = super
|
|
20
|
+
__who_rendered_note(:head, caller_locations(1)) if __who_rendered_capture?
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def redirect_to(*args, **kwargs, &block)
|
|
25
|
+
result = super
|
|
26
|
+
__who_rendered_note(:redirect_to, caller_locations(1)) if __who_rendered_capture?
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def __who_rendered_data # :nodoc:
|
|
31
|
+
defined?(@__who_rendered) ? @__who_rendered : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
# Cheap pre-check, so the stack is only walked when the result will be
|
|
36
|
+
# used. Returns nil on an internal error, which reads as false.
|
|
37
|
+
def __who_rendered_capture?
|
|
38
|
+
WhoRendered.safely do
|
|
39
|
+
next false unless WhoRendered.active?
|
|
40
|
+
next false if __who_rendered_data
|
|
41
|
+
next true if WhoRendered.config.capture == :always
|
|
42
|
+
|
|
43
|
+
status = response&.status
|
|
44
|
+
!status || !(200..299).cover?(status)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def __who_rendered_note(method_name, locations)
|
|
49
|
+
WhoRendered.safely do
|
|
50
|
+
next if locations.nil? || locations.empty?
|
|
51
|
+
next if WhoRendered.call_site.implicit_render?(locations.first)
|
|
52
|
+
|
|
53
|
+
frames = WhoRendered.call_site.frames(locations, limit: WhoRendered.config.frames)
|
|
54
|
+
next if frames.empty?
|
|
55
|
+
|
|
56
|
+
@__who_rendered = { method: method_name, frames: frames.map(&:to_s) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/log_subscriber"
|
|
4
|
+
|
|
5
|
+
module WhoRendered
|
|
6
|
+
# Emits the frames that cannot fit on the Completed line. Only active when
|
|
7
|
+
# config.frames > 1, since that is the only way more than one frame reaches
|
|
8
|
+
# the payload.
|
|
9
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
10
|
+
def process_action(event)
|
|
11
|
+
WhoRendered.safely do
|
|
12
|
+
frames = event.payload[:render_source_frames]
|
|
13
|
+
next if frames.nil? || frames.size < 2
|
|
14
|
+
|
|
15
|
+
target = WhoRendered.config.logger || logger
|
|
16
|
+
next unless target
|
|
17
|
+
|
|
18
|
+
frames.drop(1).each { |frame| target.info(" ↳ from #{frame}") }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhoRendered
|
|
4
|
+
# Publishes the captured call site: to the process_action payload for anyone
|
|
5
|
+
# subscribing to it, and to the Completed line through
|
|
6
|
+
# ActionController::Base.log_process_action — the same hook ActiveRecord uses
|
|
7
|
+
# to add "ActiveRecord: 1.2ms" to that line.
|
|
8
|
+
module Payload
|
|
9
|
+
# The Completed line's text travels out of band rather than through the
|
|
10
|
+
# payload, because the payload log_process_action receives is not always the
|
|
11
|
+
# one append_info_to_payload wrote to. On Rails main the Completed line is
|
|
12
|
+
# built from the structured event, whose additions are a hardcoded
|
|
13
|
+
# payload.slice, so a custom payload key is dropped before
|
|
14
|
+
# log_process_action runs. A fiber-local is written and read in the same
|
|
15
|
+
# fiber — the log subscriber is invoked synchronously inside process_action
|
|
16
|
+
# on every supported version — and depends on no Rails internals.
|
|
17
|
+
KEY = :__who_rendered_source # :nodoc:
|
|
18
|
+
|
|
19
|
+
# Cleared at the start of every instrumented request, from
|
|
20
|
+
# start_processing.action_controller.
|
|
21
|
+
#
|
|
22
|
+
# The read cannot be what clears it. Rails 7.1's BroadcastLogger#info hands
|
|
23
|
+
# the same block to each of its sinks with no memoization, and Rails builds
|
|
24
|
+
# the Completed line inside that block, so log_process_action runs once per
|
|
25
|
+
# sink. A destructive read would put the attribution in the first sink only —
|
|
26
|
+
# under `rails server` on 7.1 that means the log file has it and the terminal
|
|
27
|
+
# does not.
|
|
28
|
+
#
|
|
29
|
+
# Nor can clearing on write be enough. Payload is prepended to
|
|
30
|
+
# ActionController::Base, so a controller's own append_info_to_payload sits
|
|
31
|
+
# ahead of ours; one that omits super stops ours from running at all, leaving
|
|
32
|
+
# the previous request's value to be reported against this one.
|
|
33
|
+
#
|
|
34
|
+
# Clearing at the request boundary answers both, and needs neither a logger
|
|
35
|
+
# nor cooperation from the application: start_processing is emitted from
|
|
36
|
+
# ActionController::Instrumentation#process_action on every supported version,
|
|
37
|
+
# immediately before the action runs and always in the same fiber that will
|
|
38
|
+
# later read the value. ActionController::Live matters here — it copies the
|
|
39
|
+
# parent thread's fiber-locals into the streaming thread, so the child starts
|
|
40
|
+
# out holding a stale value, and this clear is what removes it.
|
|
41
|
+
def self.clear_source
|
|
42
|
+
Thread.current[KEY] = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module ClassMethods
|
|
46
|
+
def log_process_action(payload)
|
|
47
|
+
messages = super
|
|
48
|
+
|
|
49
|
+
WhoRendered.safely do
|
|
50
|
+
source = Thread.current[KEY]
|
|
51
|
+
messages << "Rendered by: #{source}" if source
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
messages
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
# Private to match ActionController::Instrumentation.
|
|
60
|
+
def append_info_to_payload(payload)
|
|
61
|
+
super
|
|
62
|
+
|
|
63
|
+
WhoRendered.safely do
|
|
64
|
+
# Redundant with the boundary clear for any request that reaches here,
|
|
65
|
+
# and kept anyway: unsubscribing "start_processing.action_controller" by
|
|
66
|
+
# name — the usual recipe for silencing the "Processing by" line — drops
|
|
67
|
+
# every listener for that event, ours included. Two independent guards,
|
|
68
|
+
# because the cost of the last one failing is naming the wrong file.
|
|
69
|
+
Thread.current[KEY] = nil
|
|
70
|
+
|
|
71
|
+
data = __who_rendered_data
|
|
72
|
+
next unless data
|
|
73
|
+
|
|
74
|
+
source = data[:frames].first
|
|
75
|
+
|
|
76
|
+
payload[:render_source] = source
|
|
77
|
+
payload[:render_source_frames] = data[:frames] if data[:frames].size > 1
|
|
78
|
+
payload[:render_method] = data[:method]
|
|
79
|
+
|
|
80
|
+
Thread.current[KEY] = source
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require "who_rendered/controller_hooks"
|
|
5
|
+
require "who_rendered/payload"
|
|
6
|
+
require "who_rendered/log_subscriber"
|
|
7
|
+
|
|
8
|
+
module WhoRendered
|
|
9
|
+
class Railtie < ::Rails::Railtie
|
|
10
|
+
config.who_rendered = WhoRendered.config
|
|
11
|
+
|
|
12
|
+
initializer "who_rendered.install" do
|
|
13
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
14
|
+
prepend WhoRendered::ControllerHooks
|
|
15
|
+
|
|
16
|
+
# ActionController::LogSubscriber calls this literally as
|
|
17
|
+
# ActionController::Base.log_process_action, even for API controllers,
|
|
18
|
+
# so Base is the only place it needs installing.
|
|
19
|
+
singleton_class.prepend WhoRendered::Payload::ClassMethods
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
23
|
+
prepend WhoRendered::ControllerHooks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Payload goes onto ActionController::Instrumentation, where
|
|
27
|
+
# append_info_to_payload is actually defined, rather than onto Base and API.
|
|
28
|
+
# Instrumentation is in the ancestors of both, so one prepend covers them —
|
|
29
|
+
# but the reason it must not be Base is compatibility, not brevity.
|
|
30
|
+
#
|
|
31
|
+
# lograge's custom_payload support does
|
|
32
|
+
# `m = Klass.instance_method(:append_info_to_payload)` and then
|
|
33
|
+
# `define_method(:append_info_to_payload) { m.bind(self).call(...) }` on
|
|
34
|
+
# ActionController::Base. Prepended to Base, Payload is the topmost owner, so
|
|
35
|
+
# that captures Payload itself and redefines the method Payload's super then
|
|
36
|
+
# resolves to: unbounded mutual recursion, SystemStackError on every request,
|
|
37
|
+
# and WhoRendered.safely cannot catch it because SystemStackError is not a
|
|
38
|
+
# StandardError.
|
|
39
|
+
#
|
|
40
|
+
# Prepended to Instrumentation, Payload sits below Base in the ancestors, so a
|
|
41
|
+
# method defined directly on Base wins dispatch and Payload's super continues
|
|
42
|
+
# downward into Instrumentation. The cycle cannot form, in either installation
|
|
43
|
+
# order.
|
|
44
|
+
#
|
|
45
|
+
# This relies on a prepend to a module propagating into the classes that already
|
|
46
|
+
# include it, since Base has usually included Instrumentation by the time this
|
|
47
|
+
# runs. ThirdPartyPayloadHookTest asserts Payload really is in the ancestors of
|
|
48
|
+
# both Base and API, so a Ruby where that does not hold fails loudly.
|
|
49
|
+
ActiveSupport.on_load(:action_controller, run_once: true) do
|
|
50
|
+
ActionController::Instrumentation.prepend WhoRendered::Payload
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Plain Notifications rather than the gem's LogSubscriber, because
|
|
54
|
+
# ActiveSupport::LogSubscriber#call skips the event when it has no logger,
|
|
55
|
+
# and this clear must not depend on logging at all. See
|
|
56
|
+
# WhoRendered::Payload.clear_source for why it happens here.
|
|
57
|
+
ActiveSupport::Notifications.subscribe("start_processing.action_controller") do
|
|
58
|
+
WhoRendered.safely { WhoRendered::Payload.clear_source }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ActionController requires and attaches its own LogSubscriber from the top
|
|
62
|
+
# of both action_controller/base.rb and action_controller/api.rb, and runs
|
|
63
|
+
# the :action_controller load hook at the end of those files. Subscribing
|
|
64
|
+
# from that hook therefore puts us after ActionController::LogSubscriber, so
|
|
65
|
+
# these lines print after the Completed line. Attaching in the initializer
|
|
66
|
+
# body instead would subscribe us first, because ActionController::Base has
|
|
67
|
+
# not been loaded at that point. run_once keeps an app that loads both Base
|
|
68
|
+
# and API from attaching twice.
|
|
69
|
+
ActiveSupport.on_load(:action_controller, run_once: true) do
|
|
70
|
+
WhoRendered::LogSubscriber.attach_to :action_controller
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/who_rendered.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "who_rendered/version"
|
|
4
|
+
require "who_rendered/configuration"
|
|
5
|
+
require "who_rendered/call_site"
|
|
6
|
+
|
|
7
|
+
module WhoRendered
|
|
8
|
+
# Rails components whose frames are never the answer to "who rendered?".
|
|
9
|
+
FRAMEWORK_GEMS = ["actionpack", "actionview", "activesupport", "railties"].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def config
|
|
13
|
+
@config ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield config
|
|
18
|
+
config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset!
|
|
22
|
+
@config = nil
|
|
23
|
+
@disabled = false
|
|
24
|
+
@call_site = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active?
|
|
28
|
+
!disabled? && config.enabled?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def disabled?
|
|
32
|
+
!!@disabled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the gem's own work. An internal bug must never reach the application,
|
|
36
|
+
# so any StandardError takes the gem out of service for the rest of the
|
|
37
|
+
# process instead of raising into a request.
|
|
38
|
+
def safely
|
|
39
|
+
yield
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
# The inner rescue ensures the gem is disabled even if warning fails.
|
|
42
|
+
begin
|
|
43
|
+
disable!(error)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
@disabled = true
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Warning once is best-effort under concurrency: two threads faulting at the
|
|
51
|
+
# same instant can both pass the guard and both warn. Harmless under MRI, and
|
|
52
|
+
# not worth a mutex in the render path.
|
|
53
|
+
def disable!(error)
|
|
54
|
+
return if disabled?
|
|
55
|
+
|
|
56
|
+
@disabled = true
|
|
57
|
+
warn_about(error)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call_site
|
|
61
|
+
@call_site ||= CallSite.new(
|
|
62
|
+
app_root: app_root,
|
|
63
|
+
framework_roots: framework_roots,
|
|
64
|
+
own_root: __dir__
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
def app_root
|
|
70
|
+
defined?(::Rails.root) && ::Rails.root ? ::Rails.root.to_s : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolved through loaded specs rather than a path pattern, so this is
|
|
74
|
+
# still correct when Rails is a path: or git: dependency.
|
|
75
|
+
def framework_roots
|
|
76
|
+
FRAMEWORK_GEMS.filter_map { |name| Gem.loaded_specs[name]&.full_gem_path }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def warn_about(error)
|
|
80
|
+
message = "who_rendered disabled for this process after an internal error: " \
|
|
81
|
+
"#{error.class}: #{error.message}"
|
|
82
|
+
logger = config.logger
|
|
83
|
+
logger ||= ::Rails.logger if defined?(::Rails.logger)
|
|
84
|
+
|
|
85
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
require "who_rendered/railtie" if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: who_rendered
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stephen Crosby
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
description: Adds the file and line that produced a Rails response to the Completed
|
|
41
|
+
log line, so an unexplained 403 names its own source.
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- CHANGELOG.md
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/who_rendered.rb
|
|
50
|
+
- lib/who_rendered/call_site.rb
|
|
51
|
+
- lib/who_rendered/configuration.rb
|
|
52
|
+
- lib/who_rendered/controller_hooks.rb
|
|
53
|
+
- lib/who_rendered/log_subscriber.rb
|
|
54
|
+
- lib/who_rendered/payload.rb
|
|
55
|
+
- lib/who_rendered/railtie.rb
|
|
56
|
+
- lib/who_rendered/version.rb
|
|
57
|
+
homepage: https://github.com/stevecrozz/who_rendered
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
source_code_uri: https://github.com/stevecrozz/who_rendered
|
|
62
|
+
changelog_uri: https://github.com/stevecrozz/who_rendered/blob/main/CHANGELOG.md
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.1'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.6.9
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: Find out who called render.
|
|
80
|
+
test_files: []
|