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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhoRendered
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []