rails-profiler 0.30.12 → 0.30.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/lib/profiler/collectors/base_collector.rb +29 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -1
- data/lib/profiler/collectors/database_collector.rb +8 -2
- data/lib/profiler/collectors/dump_collector.rb +10 -3
- data/lib/profiler/collectors/exception_collector.rb +8 -1
- data/lib/profiler/collectors/flamegraph_collector.rb +7 -3
- data/lib/profiler/collectors/function_profiler_collector.rb +43 -18
- data/lib/profiler/collectors/http_collector.rb +8 -2
- data/lib/profiler/collectors/i18n_collector.rb +8 -2
- data/lib/profiler/collectors/lifecycle.rb +31 -0
- data/lib/profiler/collectors/log_collector.rb +41 -16
- data/lib/profiler/collectors/mailer_collector.rb +12 -6
- data/lib/profiler/collectors/view_collector.rb +5 -1
- data/lib/profiler/console_profiler.rb +4 -1
- data/lib/profiler/job_profiler.rb +4 -1
- data/lib/profiler/middleware/profiler_middleware.rb +88 -33
- data/lib/profiler/test_profiler.rb +4 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +6 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2613fd14e9adfbb7911645a1bd23fcd1043d4424f532e64c267601d021db7ad0
|
|
4
|
+
data.tar.gz: 1f474b73d65dd2aac23151a8ea4f61aba98399f7182cd7db9dda39947afb7b28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67dd8e4b429926a3fdef908c1491650c796497c177cb263cfdd94e7b14a5587a7a6ef48ea09c33b430ef5bca1ac0c921a4294d16c093127a39b1eac9db5c5cd3
|
|
7
|
+
data.tar.gz: 004f12ec514d606d212f49b0ac5c8c1f593437d62cf58e2a2b1242eef67fd144caa1ef165e8371947db4c9d8ae291a9bbdb83a2acf40058a8b53e5e50a78be71
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,48 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.30.13] - 2026-10-04
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Middleware:** A request that raises below the profiler no longer runs your application a
|
|
29
|
+
second time, and no longer leaves the collectors installed. Until now the exception made the
|
|
30
|
+
profiler call the application again, duplicating its side effects (two emails sent, two
|
|
31
|
+
records written), and every SQL, view, cache, mailer and controller subscriber, the log sink on
|
|
32
|
+
`Rails.logger`, the StackProf sampler or the `TracePoint` of the function profiler stayed active
|
|
33
|
+
for the life of the process: memory and request time grew with each failed request until the
|
|
34
|
+
process was killed. Any exception between the profiler and `ShowExceptions` triggers it, for
|
|
35
|
+
example `ActionDispatch::RemoteIp::IpSpoofAttackError` on contradictory `Client-IP` and
|
|
36
|
+
`X-Forwarded-For` headers, and in tests every exception a controller raises. The exception now
|
|
37
|
+
goes on to the server unchanged, and the profile of the failed request is kept, with status 500
|
|
38
|
+
and the exception in the Exception tab.
|
|
39
|
+
- **Collectors:** Collectors gain `unsubscribe`, which releases what `subscribe` installed and runs
|
|
40
|
+
after `collect` and on every error path, for requests, jobs, console commands and tests alike.
|
|
41
|
+
A collector that fails to subscribe no longer leaves the others installed: the work runs once,
|
|
42
|
+
unprofiled. A custom collector that subscribes to something should implement `unsubscribe`, as
|
|
43
|
+
the README example now does.
|
|
44
|
+
- **Logs:** On Rails 7.0, the log collector no longer extends `Rails.logger` with a new module on
|
|
45
|
+
every request; one shared sink is attached once, and only records while a profile runs on the
|
|
46
|
+
thread.
|
|
47
|
+
- **Collectors:** A job performed inline during a profiled request (`perform_now`, or the
|
|
48
|
+
`:inline` adapter) no longer takes the request's records with it. The job's collectors now hand
|
|
49
|
+
back the thread-local slots they borrow, so the request keeps its logs, dumps, outbound HTTP
|
|
50
|
+
calls and timeline events from before and after the job, and the job's profile gets only what
|
|
51
|
+
the job did. Until now the job erased the request's logs and dumps recorded before it, and the
|
|
52
|
+
request lost its HTTP calls and timeline events from after it.
|
|
53
|
+
- **Middleware:** A request cut short by a timeout (`Timeout::ExitException`,
|
|
54
|
+
`Rack::Timeout::RequestTimeoutException`) keeps its profile too, with status 500. A request
|
|
55
|
+
stopped by a signal or by `exit` keeps none. An exception outside `StandardError` raised while
|
|
56
|
+
the collectors are being set up no longer leaves them installed.
|
|
57
|
+
- **Function profiler:** In sampling (`lite`) mode, a request no longer stops the StackProf
|
|
58
|
+
sampler that a concurrent request started, nor takes its samples: its profile says the sampler
|
|
59
|
+
was busy instead.
|
|
60
|
+
- **Dumps:** `Profiler.dump` called outside a profile that collects dumps (a request the profiler
|
|
61
|
+
skips, a test profile) no longer keeps the value on the thread. Nothing ever read those dumps,
|
|
62
|
+
and the thread held on to them, and to everything they referenced, for good.
|
|
63
|
+
|
|
22
64
|
## [0.30.12] - 2026-10-04
|
|
23
65
|
|
|
24
66
|
<!-- stamped -->
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../redaction"
|
|
4
|
+
require_relative "lifecycle"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
module Collectors
|
|
@@ -59,6 +60,13 @@ module Profiler
|
|
|
59
60
|
# Override in subclasses to subscribe to ActiveSupport::Notifications
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# Releases whatever subscribe installed: notification subscribers, samplers, log sinks,
|
|
64
|
+
# thread-local slots. Called after collect, and on every path where collect never runs,
|
|
65
|
+
# so it has to be idempotent and safe when subscribe failed part way or never ran.
|
|
66
|
+
def unsubscribe
|
|
67
|
+
# Override in subclasses that install something in subscribe
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
def collect
|
|
63
71
|
# Override in subclasses to collect data
|
|
64
72
|
end
|
|
@@ -89,6 +97,27 @@ module Profiler
|
|
|
89
97
|
@data = data
|
|
90
98
|
@profile.add_collector_data(name, data)
|
|
91
99
|
end
|
|
100
|
+
|
|
101
|
+
# Thread-local slots follow a stack: subscribe takes one over and remembers what it held,
|
|
102
|
+
# unsubscribe hands it back. A job performed inline during a request thus records into
|
|
103
|
+
# its own slots, and the request gets its own back, with what it recorded before the job.
|
|
104
|
+
def claim_thread_slot(key, value)
|
|
105
|
+
@claimed_thread_slots ||= {}
|
|
106
|
+
@claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
|
|
107
|
+
Thread.current[key] = value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def restore_thread_slots
|
|
111
|
+
@claimed_thread_slots&.each { |key, previous| Thread.current[key] = previous }
|
|
112
|
+
@claimed_thread_slots = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unsubscribe_notifications(subscriptions)
|
|
116
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
117
|
+
|
|
118
|
+
subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
119
|
+
subscriptions.clear
|
|
120
|
+
end
|
|
92
121
|
end
|
|
93
122
|
end
|
|
94
123
|
end
|
|
@@ -62,7 +62,7 @@ module Profiler
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def collect
|
|
65
|
-
|
|
65
|
+
unsubscribe
|
|
66
66
|
|
|
67
67
|
hits = @cache_reads.count { |r| r[:hit] }
|
|
68
68
|
misses = @cache_reads.count { |r| !r[:hit] }
|
|
@@ -82,6 +82,10 @@ module Profiler
|
|
|
82
82
|
store_data(data)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def unsubscribe
|
|
86
|
+
unsubscribe_notifications(@subscriptions)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def toolbar_summary
|
|
86
90
|
hits = @cache_reads.count { |r| r[:hit] }
|
|
87
91
|
total = @cache_reads.size
|
|
@@ -55,8 +55,7 @@ module Profiler
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def collect
|
|
58
|
-
|
|
59
|
-
ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
|
|
58
|
+
unsubscribe
|
|
60
59
|
|
|
61
60
|
data = {
|
|
62
61
|
total_queries: @queries.size,
|
|
@@ -69,6 +68,13 @@ module Profiler
|
|
|
69
68
|
store_data(data)
|
|
70
69
|
end
|
|
71
70
|
|
|
71
|
+
def unsubscribe
|
|
72
|
+
return unless @subscription
|
|
73
|
+
|
|
74
|
+
ActiveSupport::Notifications.unsubscribe(@subscription)
|
|
75
|
+
@subscription = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
72
78
|
def toolbar_summary
|
|
73
79
|
total = @queries.size
|
|
74
80
|
slow = @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size
|
|
@@ -30,7 +30,8 @@ module Profiler
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def collect
|
|
33
|
-
dumps = Thread.current[:profiler_dumps] || []
|
|
33
|
+
dumps = @dumps || Thread.current[:profiler_dumps] || []
|
|
34
|
+
unsubscribe
|
|
34
35
|
|
|
35
36
|
formatted_dumps = dumps.map do |dump|
|
|
36
37
|
{
|
|
@@ -47,9 +48,15 @@ module Profiler
|
|
|
47
48
|
count: formatted_dumps.size,
|
|
48
49
|
dumps: formatted_dumps
|
|
49
50
|
})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Profiler.dump fills this slot while the profile runs.
|
|
54
|
+
def subscribe
|
|
55
|
+
@dumps = claim_thread_slot(:profiler_dumps, [])
|
|
56
|
+
end
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
def unsubscribe
|
|
59
|
+
restore_thread_slots
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def toolbar_summary
|
|
@@ -43,11 +43,18 @@ module Profiler
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def collect
|
|
46
|
-
|
|
46
|
+
unsubscribe
|
|
47
47
|
|
|
48
48
|
store_data(@exception_data || {})
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def unsubscribe
|
|
52
|
+
return unless @subscriber
|
|
53
|
+
|
|
54
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
55
|
+
@subscriber = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
def has_data?
|
|
52
59
|
!@exception_data.nil? && !@exception_data.empty?
|
|
53
60
|
end
|
|
@@ -35,7 +35,7 @@ module Profiler
|
|
|
35
35
|
def subscribe
|
|
36
36
|
return unless defined?(ActiveSupport::Notifications)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
claim_thread_slot(:profiler_flamegraph_collector, self)
|
|
39
39
|
|
|
40
40
|
# Controller action
|
|
41
41
|
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
|
|
@@ -133,8 +133,7 @@ module Profiler
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def collect
|
|
136
|
-
|
|
137
|
-
Thread.current[:profiler_flamegraph_collector] = nil
|
|
136
|
+
unsubscribe
|
|
138
137
|
|
|
139
138
|
root_events = build_hierarchy(@events)
|
|
140
139
|
|
|
@@ -145,6 +144,11 @@ module Profiler
|
|
|
145
144
|
})
|
|
146
145
|
end
|
|
147
146
|
|
|
147
|
+
def unsubscribe
|
|
148
|
+
unsubscribe_notifications(@subscriptions)
|
|
149
|
+
restore_thread_slots
|
|
150
|
+
end
|
|
151
|
+
|
|
148
152
|
def toolbar_summary
|
|
149
153
|
{
|
|
150
154
|
text: "#{@events.size} events",
|
|
@@ -55,42 +55,66 @@ module Profiler
|
|
|
55
55
|
|
|
56
56
|
mode = Profiler.function_profiling_mode
|
|
57
57
|
clock = Profiler.function_profiling_clock
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
@subscribed = true
|
|
59
|
+
claim_thread_slot(:fn_profiler_mode, mode)
|
|
60
|
+
claim_thread_slot(:fn_profiler_clock, clock)
|
|
60
61
|
|
|
61
62
|
if mode == "lite" && defined?(StackProf)
|
|
62
63
|
# Always record wall + cpu at request level for the comparison stat
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
claim_thread_slot(:fn_profiler_wall_start, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
65
|
+
claim_thread_slot(:fn_profiler_cpu_start, Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID))
|
|
65
66
|
|
|
66
67
|
sp_mode = case clock
|
|
67
68
|
when "cpu" then :cpu
|
|
68
69
|
when "object" then :object
|
|
69
70
|
else :wall
|
|
70
71
|
end
|
|
71
|
-
|
|
72
|
+
# false when another request already runs the process-wide sampler: then it is not
|
|
73
|
+
# this collector's to stop on release.
|
|
74
|
+
@stackprof_started = StackProf.start(mode: sp_mode, interval: 1000, raw: true)
|
|
72
75
|
else
|
|
73
76
|
subscribe_tracepoint(mode)
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
def collect
|
|
81
|
+
# Disabled: subscribe stored the "off" data, and there is no sampler of ours to read.
|
|
82
|
+
return unless @subscribed
|
|
83
|
+
|
|
78
84
|
mode = Thread.current[:fn_profiler_mode] || Profiler.function_profiling_mode
|
|
79
85
|
clock = Thread.current[:fn_profiler_clock] || Profiler.function_profiling_clock
|
|
80
|
-
Thread.current[:fn_profiler_mode] = nil
|
|
81
|
-
Thread.current[:fn_profiler_clock] = nil
|
|
82
86
|
|
|
83
87
|
if mode == "lite" && defined?(StackProf)
|
|
88
|
+
# The sampler is process-wide: when another request started it, its samples are not
|
|
89
|
+
# this request's to take, nor is it this collector's to stop.
|
|
90
|
+
return store_sampler_busy(mode, clock) unless @stackprof_started
|
|
91
|
+
|
|
84
92
|
StackProf.stop
|
|
93
|
+
@stackprof_started = false
|
|
85
94
|
result = StackProf.results
|
|
86
95
|
wall_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - (Thread.current[:fn_profiler_wall_start] || 0)) * 1000
|
|
87
96
|
cpu_ms = (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - (Thread.current[:fn_profiler_cpu_start] || 0)) * 1000
|
|
88
|
-
Thread.current[:fn_profiler_wall_start] = nil
|
|
89
|
-
Thread.current[:fn_profiler_cpu_start] = nil
|
|
90
97
|
collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
|
|
91
98
|
else
|
|
92
99
|
collect_tracepoint(mode)
|
|
93
100
|
end
|
|
101
|
+
ensure
|
|
102
|
+
unsubscribe
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unsubscribe
|
|
106
|
+
if @stackprof_started
|
|
107
|
+
@stackprof_started = false
|
|
108
|
+
if StackProf.running?
|
|
109
|
+
StackProf.stop
|
|
110
|
+
StackProf.results # discards the samples and frees the sampler's buffers
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@trace&.disable
|
|
115
|
+
@trace = nil
|
|
116
|
+
|
|
117
|
+
restore_thread_slots
|
|
94
118
|
end
|
|
95
119
|
|
|
96
120
|
def toolbar_summary
|
|
@@ -105,6 +129,12 @@ module Profiler
|
|
|
105
129
|
|
|
106
130
|
# ── StackProf (lite mode) ──────────────────────────────────────────────────
|
|
107
131
|
|
|
132
|
+
def store_sampler_busy(mode, clock)
|
|
133
|
+
store_data({ enabled: true, mode: mode, clock: clock, sampler_busy: true, max_frames: 0,
|
|
134
|
+
frame_cap_reached: false, total_calls: 0, total_duration: 0,
|
|
135
|
+
functions: [], root_calls: [] })
|
|
136
|
+
end
|
|
137
|
+
|
|
108
138
|
def collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
|
|
109
139
|
unless result
|
|
110
140
|
store_data({ enabled: true, mode: mode, clock: clock, max_frames: 0,
|
|
@@ -261,10 +291,10 @@ module Profiler
|
|
|
261
291
|
app_root = app_root_path
|
|
262
292
|
max_frames = Profiler.function_profiling_max_frames
|
|
263
293
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
294
|
+
claim_thread_slot(:fn_profiler_stack, [])
|
|
295
|
+
claim_thread_slot(:fn_profiler_roots, [])
|
|
296
|
+
claim_thread_slot(:fn_profiler_count, 0)
|
|
297
|
+
claim_thread_slot(:fn_profiler_depth, Hash.new(0))
|
|
268
298
|
|
|
269
299
|
@trace = TracePoint.new(:call, :return) do |tp|
|
|
270
300
|
next unless tp.path&.start_with?(app_root)
|
|
@@ -336,11 +366,6 @@ module Profiler
|
|
|
336
366
|
end
|
|
337
367
|
end
|
|
338
368
|
|
|
339
|
-
Thread.current[:fn_profiler_stack] = nil
|
|
340
|
-
Thread.current[:fn_profiler_roots] = nil
|
|
341
|
-
Thread.current[:fn_profiler_count] = nil
|
|
342
|
-
Thread.current[:fn_profiler_depth] = nil
|
|
343
|
-
|
|
344
369
|
stats = {}
|
|
345
370
|
aggregate(roots, stats)
|
|
346
371
|
sorted_functions = stats.values.sort_by { |s| -s[:total_duration] }
|
|
@@ -36,11 +36,17 @@ module Profiler
|
|
|
36
36
|
return unless Profiler.configuration.track_http
|
|
37
37
|
|
|
38
38
|
Profiler::Instrumentation::NetHttpInstrumentation.install!
|
|
39
|
-
|
|
39
|
+
claim_thread_slot(:profiler_http_collector, self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The Net::HTTP patch installed by subscribe stays: it is installed once per process and
|
|
43
|
+
# records nothing when no collector holds the thread-local slot.
|
|
44
|
+
def unsubscribe
|
|
45
|
+
restore_thread_slots
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def collect
|
|
43
|
-
|
|
49
|
+
unsubscribe
|
|
44
50
|
|
|
45
51
|
data = @mutex.synchronize do
|
|
46
52
|
@collected = true
|
|
@@ -53,11 +53,17 @@ module Profiler
|
|
|
53
53
|
I18n.singleton_class.prepend(Profiler::I18nLookupTracker)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
claim_thread_slot(:profiler_i18n_collector, self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The I18n patch installed by subscribe stays: it is prepended once per process and
|
|
60
|
+
# records nothing when no collector holds the thread-local slot.
|
|
61
|
+
def unsubscribe
|
|
62
|
+
restore_thread_slots
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
def collect
|
|
60
|
-
|
|
66
|
+
unsubscribe
|
|
61
67
|
|
|
62
68
|
missing_count = @lookups.count { |l| l[:missing] }
|
|
63
69
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Collectors
|
|
5
|
+
# Subscribes and releases the collectors of one profile, for every entry point that runs
|
|
6
|
+
# them (the request middleware, jobs, console commands, tests).
|
|
7
|
+
module Lifecycle
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Subscribes each collector in turn. When one fails, releases them all, the ones that
|
|
11
|
+
# never subscribed included, and returns false: the caller then runs the work unprofiled.
|
|
12
|
+
def subscribe_all(collectors, label)
|
|
13
|
+
collectors.each { |collector| collector.subscribe if collector.respond_to?(:subscribe) }
|
|
14
|
+
true
|
|
15
|
+
rescue => e
|
|
16
|
+
warn "Profiler #{label}: Collector subscribe failed: #{e.message}"
|
|
17
|
+
release_all(collectors)
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Releases every collector, whatever happened before, one failure not stopping the others.
|
|
22
|
+
def release_all(collectors)
|
|
23
|
+
collectors&.each do |collector|
|
|
24
|
+
collector.unsubscribe if collector.respond_to?(:unsubscribe)
|
|
25
|
+
rescue => e
|
|
26
|
+
warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -8,18 +8,26 @@ module Profiler
|
|
|
8
8
|
class LogCollector < BaseCollector
|
|
9
9
|
SEVERITY_LABELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
|
|
10
10
|
|
|
11
|
+
# Records into the thread's buffer, which only exists while a LogCollector of this thread
|
|
12
|
+
# is subscribed: lines logged by other threads, or after release, are not kept. A logger
|
|
13
|
+
# bound to one collector's buffer records only while that buffer is the thread's current
|
|
14
|
+
# one, so a job performed inline during a request does not log twice into the job.
|
|
11
15
|
class CaptureLogger < ::Logger
|
|
12
|
-
def initialize
|
|
16
|
+
def initialize(buffer = nil)
|
|
13
17
|
super(File::NULL)
|
|
18
|
+
@buffer = buffer
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
def add(severity, message = nil, progname = nil)
|
|
22
|
+
logs = Thread.current[:profiler_logs]
|
|
23
|
+
return true unless logs
|
|
24
|
+
return true if @buffer && !logs.equal?(@buffer)
|
|
25
|
+
|
|
17
26
|
msg = message || progname
|
|
18
27
|
msg = yield if block_given? && msg.nil?
|
|
19
28
|
return true if msg.nil?
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
Thread.current[:profiler_logs] << {
|
|
30
|
+
logs << {
|
|
23
31
|
level: SEVERITY_LABELS[severity] || "UNKNOWN",
|
|
24
32
|
message: msg.to_s.strip,
|
|
25
33
|
timestamp: Time.now.iso8601(3)
|
|
@@ -55,31 +63,48 @@ module Profiler
|
|
|
55
63
|
}
|
|
56
64
|
end
|
|
57
65
|
|
|
66
|
+
# Rails 7.0 broadcasts by extending the logger with a module, which cannot be taken off
|
|
67
|
+
# again: the logger gets one shared CaptureLogger, once, instead of one per request.
|
|
68
|
+
LEGACY_CAPTURE_LOGGER = CaptureLogger.new
|
|
69
|
+
LEGACY_BROADCAST_MUTEX = Mutex.new
|
|
70
|
+
|
|
71
|
+
def self.attach_legacy_broadcast(logger)
|
|
72
|
+
LEGACY_BROADCAST_MUTEX.synchronize do
|
|
73
|
+
return if logger.instance_variable_get(:@profiler_capture_attached)
|
|
74
|
+
|
|
75
|
+
logger.extend(ActiveSupport::Logger.broadcast(LEGACY_CAPTURE_LOGGER))
|
|
76
|
+
logger.instance_variable_set(:@profiler_capture_attached, true)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
58
80
|
def subscribe
|
|
59
|
-
|
|
60
|
-
@capture_logger = CaptureLogger.new
|
|
81
|
+
@logs = claim_thread_slot(:profiler_logs, [])
|
|
61
82
|
|
|
62
83
|
if defined?(Rails) && Rails.logger
|
|
63
84
|
if Rails.logger.respond_to?(:broadcast_to)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
@capture_logger = CaptureLogger.new(@logs)
|
|
86
|
+
@broadcaster = Rails.logger
|
|
87
|
+
@broadcaster.broadcast_to(@capture_logger)
|
|
88
|
+
elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
|
|
89
|
+
self.class.attach_legacy_broadcast(Rails.logger)
|
|
67
90
|
end
|
|
68
91
|
end
|
|
69
92
|
rescue => e
|
|
70
93
|
warn "LogCollector#subscribe failed: #{e.message}"
|
|
71
94
|
end
|
|
72
95
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Rails.logger.stop_broadcasting_to(@capture_logger)
|
|
79
|
-
end
|
|
96
|
+
def unsubscribe
|
|
97
|
+
if @capture_logger
|
|
98
|
+
@broadcaster.stop_broadcasting_to(@capture_logger) if @broadcaster.respond_to?(:stop_broadcasting_to)
|
|
99
|
+
@capture_logger = nil
|
|
100
|
+
@broadcaster = nil
|
|
80
101
|
end
|
|
102
|
+
restore_thread_slots
|
|
103
|
+
end
|
|
81
104
|
|
|
82
|
-
|
|
105
|
+
def collect
|
|
106
|
+
logs = @logs || Thread.current[:profiler_logs] || []
|
|
107
|
+
unsubscribe
|
|
83
108
|
|
|
84
109
|
errors = logs.count { |l| %w[ERROR FATAL].include?(l[:level]) }
|
|
85
110
|
warnings = logs.count { |l| l[:level] == "WARN" }
|
|
@@ -45,7 +45,7 @@ module Profiler
|
|
|
45
45
|
@subscriber_thread = Thread.current
|
|
46
46
|
|
|
47
47
|
# Use a stack so multiple deliver_later calls in the same request are all tracked.
|
|
48
|
-
|
|
48
|
+
claim_thread_slot(:profiler_pending_processes, [])
|
|
49
49
|
|
|
50
50
|
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
|
|
51
51
|
next unless Thread.current.equal?(@subscriber_thread)
|
|
@@ -53,7 +53,10 @@ module Profiler
|
|
|
53
53
|
|
|
54
54
|
mailer_class = payload[:mailer].to_s
|
|
55
55
|
action = payload[:action].to_s
|
|
56
|
-
|
|
56
|
+
pending = Thread.current[:profiler_pending_processes]
|
|
57
|
+
next unless pending
|
|
58
|
+
|
|
59
|
+
pending << {
|
|
57
60
|
mailer_class: mailer_class,
|
|
58
61
|
action: action,
|
|
59
62
|
duration_ms: ((finished - started) * 1000).round(2),
|
|
@@ -67,7 +70,7 @@ module Profiler
|
|
|
67
70
|
next if rails_preview_request?
|
|
68
71
|
|
|
69
72
|
delivery_ms = ((finished - started) * 1000).round(2)
|
|
70
|
-
process_info =
|
|
73
|
+
process_info = Thread.current[:profiler_pending_processes]&.pop || {}
|
|
71
74
|
mail = payload[:mail]
|
|
72
75
|
|
|
73
76
|
mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
|
|
@@ -87,7 +90,7 @@ module Profiler
|
|
|
87
90
|
# Any remaining pending process entries had no matching deliver event:
|
|
88
91
|
# they were enqueued via deliver_later in the HTTP context.
|
|
89
92
|
pending = Thread.current[:profiler_pending_processes] || []
|
|
90
|
-
|
|
93
|
+
unsubscribe
|
|
91
94
|
|
|
92
95
|
pending.each do |info|
|
|
93
96
|
@queued << {
|
|
@@ -102,8 +105,6 @@ module Profiler
|
|
|
102
105
|
}
|
|
103
106
|
end
|
|
104
107
|
|
|
105
|
-
@subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
106
|
-
|
|
107
108
|
detect_loops
|
|
108
109
|
|
|
109
110
|
truncated = @emails.size > MAX_EMAILS
|
|
@@ -124,6 +125,11 @@ module Profiler
|
|
|
124
125
|
)
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
def unsubscribe
|
|
129
|
+
unsubscribe_notifications(@subscriptions)
|
|
130
|
+
restore_thread_slots
|
|
131
|
+
end
|
|
132
|
+
|
|
127
133
|
def has_data?
|
|
128
134
|
@emails.any? || @errors.any? || @queued.any?
|
|
129
135
|
end
|
|
@@ -53,7 +53,7 @@ module Profiler
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def collect
|
|
56
|
-
|
|
56
|
+
unsubscribe
|
|
57
57
|
|
|
58
58
|
data = {
|
|
59
59
|
views: @views,
|
|
@@ -66,6 +66,10 @@ module Profiler
|
|
|
66
66
|
store_data(data)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def unsubscribe
|
|
70
|
+
unsubscribe_notifications(@subscriptions)
|
|
71
|
+
end
|
|
72
|
+
|
|
69
73
|
def toolbar_summary
|
|
70
74
|
total_duration = (@views + @partials).sum { |v| v[:duration] }.round(2)
|
|
71
75
|
|
|
@@ -44,7 +44,7 @@ module Profiler
|
|
|
44
44
|
|
|
45
45
|
console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
|
|
46
46
|
collectors = [console_collector] + CONSOLE_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
47
|
-
|
|
47
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "ConsoleProfiler")
|
|
48
48
|
|
|
49
49
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
50
50
|
|
|
@@ -82,6 +82,9 @@ module Profiler
|
|
|
82
82
|
|
|
83
83
|
Profiler.storage.save(profile.token, profile)
|
|
84
84
|
end
|
|
85
|
+
ensure
|
|
86
|
+
# After collect, and also when collect or the storage failed.
|
|
87
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
private
|
|
@@ -67,7 +67,7 @@ module Profiler
|
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
collectors = [job_collector] + JOB_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
70
|
-
|
|
70
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "JobProfiler")
|
|
71
71
|
|
|
72
72
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
73
73
|
|
|
@@ -109,6 +109,9 @@ module Profiler
|
|
|
109
109
|
|
|
110
110
|
Profiler.storage.save(profile.token, profile)
|
|
111
111
|
end
|
|
112
|
+
ensure
|
|
113
|
+
# After collect, and also when collect or the storage failed.
|
|
114
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
private
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../models/profile"
|
|
4
4
|
require_relative "../current_context"
|
|
5
|
+
require_relative "../collectors/lifecycle"
|
|
5
6
|
require_relative "toolbar_injector"
|
|
6
7
|
|
|
7
8
|
module Profiler
|
|
@@ -14,28 +15,55 @@ module Profiler
|
|
|
14
15
|
def call(env)
|
|
15
16
|
return @app.call(env) unless should_profile?(env)
|
|
16
17
|
|
|
17
|
-
status = nil
|
|
18
|
-
headers = nil
|
|
19
|
-
body = nil
|
|
20
18
|
collectors = nil
|
|
19
|
+
begin
|
|
20
|
+
begin
|
|
21
|
+
profile = Models::Profile.new(build_request(env))
|
|
22
|
+
profile.gem_version = Profiler::VERSION
|
|
23
|
+
Profiler::CurrentContext.token = profile.token
|
|
24
|
+
|
|
25
|
+
# Capture request body before app processes it
|
|
26
|
+
req_body_raw = read_rack_input(env)
|
|
27
|
+
|
|
28
|
+
# Store profile in env for collectors
|
|
29
|
+
env["profiler.profile"] = profile
|
|
30
|
+
|
|
31
|
+
collectors = create_collectors(profile)
|
|
32
|
+
env["profiler.collectors"] = collectors
|
|
33
|
+
subscribed = Collectors::Lifecycle.subscribe_all(collectors, "ProfilerMiddleware")
|
|
34
|
+
rescue => e
|
|
35
|
+
warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
36
|
+
subscribed = false
|
|
37
|
+
end
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
unless subscribed
|
|
40
|
+
# The application has not run yet: serve the request once, unprofiled.
|
|
41
|
+
release(collectors)
|
|
42
|
+
return @app.call(env)
|
|
43
|
+
end
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
# Measure memory before
|
|
46
|
+
memory_before = current_memory if Profiler.configuration.track_memory
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
begin
|
|
49
|
+
response = @app.call(env)
|
|
50
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
51
|
+
record_failed_request(env, profile, collectors, req_body_raw, memory_before, e) if failed_request?(e)
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
|
|
56
|
+
ensure
|
|
57
|
+
# Opened before the first subscribe: whatever happens from there on, an exception
|
|
58
|
+
# outside StandardError included, nothing a collector installed outlives the request.
|
|
59
|
+
release(collectors)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
memory_before = current_memory if Profiler.configuration.track_memory
|
|
63
|
+
private
|
|
37
64
|
|
|
38
|
-
|
|
65
|
+
def complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
|
|
66
|
+
status, headers, body = response
|
|
39
67
|
headers = headers.dup
|
|
40
68
|
|
|
41
69
|
# Measure memory after
|
|
@@ -56,17 +84,9 @@ module Profiler
|
|
|
56
84
|
resp_content_type: (headers["content-type"] || headers["Content-Type"]).to_s
|
|
57
85
|
)
|
|
58
86
|
|
|
59
|
-
collectors
|
|
60
|
-
begin
|
|
61
|
-
collector.collect if collector.respond_to?(:collect)
|
|
62
|
-
profile.add_collector_metadata(collector)
|
|
63
|
-
rescue => e
|
|
64
|
-
warn "Collector #{collector.class} failed: #{e.message}"
|
|
65
|
-
end
|
|
66
|
-
end
|
|
87
|
+
collect_all(profile, collectors)
|
|
67
88
|
|
|
68
89
|
Profiler.storage.save(profile.token, profile)
|
|
69
|
-
Profiler::CurrentContext.clear
|
|
70
90
|
|
|
71
91
|
headers["X-Profiler-Token"] = profile.token
|
|
72
92
|
|
|
@@ -77,13 +97,50 @@ module Profiler
|
|
|
77
97
|
|
|
78
98
|
[status, headers, body]
|
|
79
99
|
rescue => e
|
|
100
|
+
# The application has run: its response goes out, profiled or not.
|
|
80
101
|
warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
81
|
-
|
|
82
|
-
Profiler::CurrentContext.clear
|
|
83
|
-
status ? [status, headers, body || []] : @app.call(env)
|
|
102
|
+
[status, headers, body || []]
|
|
84
103
|
end
|
|
85
104
|
|
|
86
|
-
|
|
105
|
+
# A request cut short by a timeout (Timeout::ExitException, Rack::Timeout) is profiled like
|
|
106
|
+
# one that raised; one stopped by a signal or an exit is not, the process is going away.
|
|
107
|
+
def failed_request?(error)
|
|
108
|
+
!error.is_a?(SignalException) && !error.is_a?(SystemExit)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The profile of a request that raised is kept: status 500, which the server answers once
|
|
112
|
+
# the exception leaves the stack, and the exception itself. Never masks the exception.
|
|
113
|
+
def record_failed_request(env, profile, collectors, req_body_raw, memory_before, error)
|
|
114
|
+
collectors.each { |collector| collector.capture(error) if collector.respond_to?(:capture) }
|
|
115
|
+
profile.memory = current_memory - memory_before if Profiler.configuration.track_memory
|
|
116
|
+
profile.finish(500)
|
|
117
|
+
profile.set_bodies(
|
|
118
|
+
request_body: req_body_raw,
|
|
119
|
+
response_body: "",
|
|
120
|
+
req_content_type: env["CONTENT_TYPE"].to_s,
|
|
121
|
+
resp_content_type: ""
|
|
122
|
+
)
|
|
123
|
+
collect_all(profile, collectors)
|
|
124
|
+
Profiler.storage.save(profile.token, profile)
|
|
125
|
+
rescue => e
|
|
126
|
+
warn "Profiler error while recording a failed request: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def collect_all(profile, collectors)
|
|
130
|
+
collectors.each do |collector|
|
|
131
|
+
begin
|
|
132
|
+
collector.collect if collector.respond_to?(:collect)
|
|
133
|
+
profile.add_collector_metadata(collector)
|
|
134
|
+
rescue => e
|
|
135
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def release(collectors)
|
|
141
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
142
|
+
Profiler::CurrentContext.clear
|
|
143
|
+
end
|
|
87
144
|
|
|
88
145
|
def should_profile?(env)
|
|
89
146
|
return false unless Profiler.enabled?
|
|
@@ -104,12 +161,10 @@ module Profiler
|
|
|
104
161
|
end
|
|
105
162
|
end
|
|
106
163
|
|
|
164
|
+
# Builds the collectors without subscribing them: Lifecycle.subscribe_all does, so that
|
|
165
|
+
# a failure part way leaves every collector reachable for release.
|
|
107
166
|
def create_collectors(profile)
|
|
108
|
-
Profiler.configuration.collectors.map
|
|
109
|
-
collector = collector_class.new(profile)
|
|
110
|
-
collector.subscribe if collector.respond_to?(:subscribe)
|
|
111
|
-
collector
|
|
112
|
-
end
|
|
167
|
+
Profiler.configuration.collectors.map { |collector_class| collector_class.new(profile) }
|
|
113
168
|
end
|
|
114
169
|
|
|
115
170
|
def html_response?(headers)
|
|
@@ -52,7 +52,7 @@ module Profiler
|
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
collectors = [test_collector] + TEST_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
55
|
-
|
|
55
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "TestProfiler")
|
|
56
56
|
|
|
57
57
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
58
58
|
|
|
@@ -120,6 +120,9 @@ module Profiler
|
|
|
120
120
|
|
|
121
121
|
Profiler.storage.save(profile.token, profile)
|
|
122
122
|
end
|
|
123
|
+
ensure
|
|
124
|
+
# After collect, and also when collect or the storage failed.
|
|
125
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
private
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -77,16 +77,18 @@ module Profiler
|
|
|
77
77
|
def dump(value, label = nil)
|
|
78
78
|
return unless enabled?
|
|
79
79
|
|
|
80
|
+
# The slot exists only while a DumpCollector profiles this thread: outside of one, nobody
|
|
81
|
+
# would ever read the dump, and the thread would keep it for good.
|
|
82
|
+
dumps = Thread.current[:profiler_dumps]
|
|
83
|
+
return value unless dumps
|
|
84
|
+
|
|
80
85
|
# Get caller location
|
|
81
86
|
caller_location = caller_locations(1, 1).first
|
|
82
87
|
file = caller_location.path
|
|
83
88
|
line = caller_location.lineno
|
|
84
89
|
|
|
85
|
-
# Initialize dumps array if needed
|
|
86
|
-
Thread.current[:profiler_dumps] ||= []
|
|
87
|
-
|
|
88
90
|
# Store the dump
|
|
89
|
-
|
|
91
|
+
dumps << {
|
|
90
92
|
value: value,
|
|
91
93
|
label: label,
|
|
92
94
|
file: file,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- lib/profiler/collectors/http_collector.rb
|
|
153
153
|
- lib/profiler/collectors/i18n_collector.rb
|
|
154
154
|
- lib/profiler/collectors/job_collector.rb
|
|
155
|
+
- lib/profiler/collectors/lifecycle.rb
|
|
155
156
|
- lib/profiler/collectors/log_collector.rb
|
|
156
157
|
- lib/profiler/collectors/mailer_collector.rb
|
|
157
158
|
- lib/profiler/collectors/request_collector.rb
|