rails-profiler 0.31.2 → 0.31.3
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 +63 -0
- data/app/assets/builds/profiler.js +20 -4
- data/app/controllers/profiler/api/profiles_controller.rb +2 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
- data/app/controllers/profiler/profiles_controller.rb +2 -0
- data/lib/profiler/collectors/base_collector.rb +8 -3
- data/lib/profiler/collectors/cache_collector.rb +3 -3
- data/lib/profiler/collectors/database_collector.rb +128 -10
- data/lib/profiler/collectors/env_collector.rb +12 -4
- data/lib/profiler/collectors/exception_collector.rb +7 -9
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
- data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
- data/lib/profiler/collectors/mailer_collector.rb +2 -8
- data/lib/profiler/collectors/routes_collector.rb +7 -50
- data/lib/profiler/collectors/scoped_notifications.rb +229 -0
- data/lib/profiler/collectors/view_collector.rb +2 -2
- data/lib/profiler/configuration.rb +5 -1
- data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
- data/lib/profiler/instrumentation/request_context.rb +77 -0
- data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
- data/lib/profiler/job_profiler.rb +4 -2
- data/lib/profiler/mcp/tools/get_profile_detail.rb +8 -1
- data/lib/profiler/process_snapshot.rb +157 -0
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +5 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
|
|
4
|
+
data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
|
|
7
|
+
data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,69 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.31.3] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Collectors:** a profile no longer records the SQL queries, views, cache operations, timeline
|
|
29
|
+
events, exceptions and mails of other requests. The collectors subscribed to the process-wide
|
|
30
|
+
`ActiveSupport::Notifications` bus without looking at the thread, so on a multi-threaded server
|
|
31
|
+
(Puma, Sidekiq) a profile could show the queries of a concurrent request, another user's
|
|
32
|
+
included. Each collector now records the events of its own request: its thread, the threads it
|
|
33
|
+
starts with `Thread.new`, the thread `ActionController::Live` runs the action in, the fibers
|
|
34
|
+
it creates (Ruby 3.2+, the layout of `render stream: true` under Falcon), the server thread
|
|
35
|
+
iterating a streamed body, and the queries of `load_async`, which Rails reports on the
|
|
36
|
+
request's thread. The process holds one subscriber per event instead of one per profiled request,
|
|
37
|
+
so an SQL event costs the same however many requests are profiled at once (6 us with 8 requests
|
|
38
|
+
profiled, 260 us before, measured). A job performed inline still shows in both profiles; one
|
|
39
|
+
performed by ActiveJob's `:async` adapter is profiled apart from the request that enqueued it,
|
|
40
|
+
even while that request still runs. A test profile no longer shows the queries the application
|
|
41
|
+
server runs for a system test in its own thread: they are in the profile of that HTTP request.
|
|
42
|
+
- **Instrumentation:** a thread created by a pool while a request ran (a concurrent-ruby worker
|
|
43
|
+
behind `Concurrent::Promises`, ActiveJob's `:async` adapter or `ActionController::Live`, or the
|
|
44
|
+
thread Puma starts for a request marked as IO bound) no longer keeps that request's HTTP and
|
|
45
|
+
timeline collectors for good, which put the outgoing HTTP calls of other requests' tasks in its
|
|
46
|
+
profile. A task posted to a concurrent-ruby executor now runs in the context of the request that
|
|
47
|
+
posted it, and a pool thread inherits nothing. On Puma with 8 threads, 40 concurrent requests
|
|
48
|
+
each running a query and an HTTP call in a `Concurrent::Promises.future`: 0 queries and 0 HTTP
|
|
49
|
+
calls of another request in a profile, none lost, against 560 foreign queries, 35 misattributed
|
|
50
|
+
and 36 lost HTTP calls before (measured, `script/bench/puma_attribution.rb`).
|
|
51
|
+
- **Function profiler:** without the `stackprof` gem, which the gem does not depend on, the
|
|
52
|
+
function profiler stays off instead of falling back to a `TracePoint` enabled on every thread of
|
|
53
|
+
the process, which called `GC.stat` four times per method and tripled the cost of a request, for
|
|
54
|
+
a tab that is not shown. To trace again, set `Profiler.function_profiling_tracepoint_fallback =
|
|
55
|
+
true`; the `TracePoint`, like the one of the `full` mode chosen from the dashboard, is now
|
|
56
|
+
enabled on the request's thread only. The Timeline tab now says that stackprof is missing and
|
|
57
|
+
how to add it, instead of a button to enable function profiling that changed nothing, and no
|
|
58
|
+
longer says that sampling is on by default whatever the Gemfile.
|
|
59
|
+
- **Database:** the caller of a query is captured the first time a statement runs in the request
|
|
60
|
+
(values left out, as the N+1 detection groups them) and for every slow query, instead of for
|
|
61
|
+
every query, at about 0.1 ms each. The N+1 groups of the Database tab and of the MCP
|
|
62
|
+
`n1-patterns` resource keep their location. The backtrace now starts at the application's code
|
|
63
|
+
that ran the query, cleaned by `Rails.backtrace_cleaner` as in the Exception tab, or without
|
|
64
|
+
Rails, past the frames of gems and of Ruby; it often started inside Active Support, with the
|
|
65
|
+
application's first frame too far down for the three frames `n1-patterns` shows.
|
|
66
|
+
`config.sql_backtrace = :all` captures every query again, `:none` none.
|
|
67
|
+
- **Routes and Env tabs:** a profile no longer stores the whole route table and `ENV`, rebuilt and
|
|
68
|
+
sorted on every request (the route table alone cost 7.7 ms per request with 210 routes, and 29 KB
|
|
69
|
+
per profile, measured). It keeps the route its request matched, the one of the request's verb
|
|
70
|
+
(a `PUT` showed the `PATCH` route of the same action). The Routes tab, the toolbar and the MCP
|
|
71
|
+
`get_profile` tool list the route table of the process that serves them, rebuilt when the
|
|
72
|
+
routes are reloaded in development; the Env tab of an HTTP profile shows the current `ENV` of that
|
|
73
|
+
process, not the `ENV` as it was during the request, masked as before. The profiles of jobs,
|
|
74
|
+
console expressions and tests, which run in another process, keep that process's `ENV`, masked,
|
|
75
|
+
as before. Profiles saved by an earlier version keep
|
|
76
|
+
showing their own table and variables. With both changes, the profiler adds about 10 to 13 ms to
|
|
77
|
+
the reference page of `script/bench/request_overhead.rb` instead of 21 to 22 ms with stackprof
|
|
78
|
+
and 26 ms without it in 0.31.2 (Ruby 3.3, measured); the README gives this measured cost instead
|
|
79
|
+
of "< 5ms per request".
|
|
80
|
+
- **Upgrading:** to get the previous behaviours back, set `config.sql_backtrace = :all` and
|
|
81
|
+
`Profiler.function_profiling_tracepoint_fallback = true` in `config/initializers/profiler.rb`.
|
|
82
|
+
The Routes and Env tabs cannot show the table and the variables of the request's time any more:
|
|
83
|
+
they show the process's current ones.
|
|
84
|
+
|
|
22
85
|
## [0.31.2] - 2026-10-05
|
|
23
86
|
|
|
24
87
|
<!-- stamped -->
|
|
@@ -7413,7 +7413,9 @@
|
|
|
7413
7413
|
const [totalCount, setTotalCount] = d2(0);
|
|
7414
7414
|
const [fnSortKey, setFnSortKey] = d2("total_duration");
|
|
7415
7415
|
const [fnSortDir, setFnSortDir] = d2("desc");
|
|
7416
|
-
const [fnEnabled, setFnEnabled] = d2(
|
|
7416
|
+
const [fnEnabled, setFnEnabled] = d2(
|
|
7417
|
+
functionProfileData?.reason === "stackprof_missing" ? true : functionProfileData?.enabled ?? false
|
|
7418
|
+
);
|
|
7417
7419
|
const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
|
|
7418
7420
|
const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
|
|
7419
7421
|
const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
|
|
@@ -7722,6 +7724,7 @@
|
|
|
7722
7724
|
const [hoveredFnName, setHoveredFnName] = d2(null);
|
|
7723
7725
|
const fnRendererRef = A2(null);
|
|
7724
7726
|
const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
|
|
7727
|
+
const stackprofMissing = data?.reason === "stackprof_missing" && mode === "lite";
|
|
7725
7728
|
const dataMode = data?.mode ?? "full";
|
|
7726
7729
|
const dataClock = data?.clock ?? "wall";
|
|
7727
7730
|
const isSampling = dataMode === "lite";
|
|
@@ -7814,7 +7817,7 @@
|
|
|
7814
7817
|
class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
|
|
7815
7818
|
onClick: onToggle,
|
|
7816
7819
|
disabled: toggling,
|
|
7817
|
-
children: toggling ? "\u2026" : enabled ? "Enabled
|
|
7820
|
+
children: toggling ? "\u2026" : enabled ? "Enabled (click to disable)" : "Disabled (click to enable)"
|
|
7818
7821
|
}
|
|
7819
7822
|
)
|
|
7820
7823
|
] })
|
|
@@ -7823,12 +7826,25 @@
|
|
|
7823
7826
|
"Enable function profiling to see where your app spends time.",
|
|
7824
7827
|
" ",
|
|
7825
7828
|
/* @__PURE__ */ u3("strong", { children: "Lite" }),
|
|
7826
|
-
": statistical sampling
|
|
7829
|
+
": statistical sampling with the stackprof gem, to add to the application's Gemfile; very low overhead (<1%), on by default once stackprof is installed.",
|
|
7827
7830
|
" ",
|
|
7828
7831
|
/* @__PURE__ */ u3("strong", { children: "Full" }),
|
|
7829
7832
|
": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
|
|
7830
7833
|
] }),
|
|
7831
|
-
enabled &&
|
|
7834
|
+
enabled && stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
|
|
7835
|
+
"No data: sampling needs the ",
|
|
7836
|
+
/* @__PURE__ */ u3("code", { children: "stackprof" }),
|
|
7837
|
+
" gem, which is not installed. Add",
|
|
7838
|
+
" ",
|
|
7839
|
+
/* @__PURE__ */ u3("code", { children: 'gem "stackprof"' }),
|
|
7840
|
+
" to the application's Gemfile. To trace every method call instead, at a much higher cost, choose ",
|
|
7841
|
+
/* @__PURE__ */ u3("strong", { children: "Full" }),
|
|
7842
|
+
", or set",
|
|
7843
|
+
" ",
|
|
7844
|
+
/* @__PURE__ */ u3("code", { children: "Profiler.function_profiling_tracepoint_fallback = true" }),
|
|
7845
|
+
" in an initializer."
|
|
7846
|
+
] }),
|
|
7847
|
+
enabled && !hasData && !stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
|
|
7832
7848
|
hasData && /* @__PURE__ */ u3(k, { children: [
|
|
7833
7849
|
data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
|
|
7834
7850
|
"\u26A0 Frame cap reached (",
|
|
@@ -29,6 +29,8 @@ module Profiler
|
|
|
29
29
|
|
|
30
30
|
# Recalculate AJAX collector data (since AJAX requests happen after page load)
|
|
31
31
|
recalculate_ajax_data(profile)
|
|
32
|
+
# The route table and ENV are the process's, not stored in the profile
|
|
33
|
+
Profiler::ProcessSnapshot.hydrate(profile)
|
|
32
34
|
|
|
33
35
|
render json: profile.to_h.merge(
|
|
34
36
|
child_jobs: build_child_jobs(profile),
|
|
@@ -16,6 +16,8 @@ module Profiler
|
|
|
16
16
|
|
|
17
17
|
# Recalculate AJAX collector data (since AJAX requests happen after page load)
|
|
18
18
|
recalculate_ajax_data(profile)
|
|
19
|
+
# The route table and ENV are the process's, not stored in the profile
|
|
20
|
+
Profiler::ProcessSnapshot.hydrate(profile)
|
|
19
21
|
|
|
20
22
|
render json: {
|
|
21
23
|
profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
|
|
@@ -22,6 +22,8 @@ module Profiler
|
|
|
22
22
|
|
|
23
23
|
# Recalculate AJAX collector data (since AJAX requests happen after page load)
|
|
24
24
|
recalculate_ajax_data(@profile)
|
|
25
|
+
# The route table and ENV are the process's, not stored in the profile
|
|
26
|
+
Profiler::ProcessSnapshot.hydrate(@profile)
|
|
25
27
|
|
|
26
28
|
@profile_data = @profile.to_h.merge(
|
|
27
29
|
child_jobs: build_child_jobs(@profile),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../redaction"
|
|
4
4
|
require_relative "lifecycle"
|
|
5
|
+
require_relative "scoped_notifications"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
module Collectors
|
|
@@ -143,10 +144,14 @@ module Profiler
|
|
|
143
144
|
@claimed_thread_slots = nil
|
|
144
145
|
end
|
|
145
146
|
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
# Subscribes to +event+ for the request being profiled only: the block never sees what
|
|
148
|
+
# another request's thread emits (see ScopedNotifications). Returns the handle to release.
|
|
149
|
+
def subscribe_notification(event, &block)
|
|
150
|
+
ScopedNotifications.subscribe(event, &block)
|
|
151
|
+
end
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
def unsubscribe_notifications(subscriptions)
|
|
154
|
+
subscriptions.each { |handle| ScopedNotifications.unsubscribe(handle) }
|
|
150
155
|
subscriptions.clear
|
|
151
156
|
end
|
|
152
157
|
end
|
|
@@ -35,7 +35,7 @@ module Profiler
|
|
|
35
35
|
def subscribe
|
|
36
36
|
return unless defined?(ActiveSupport::Notifications)
|
|
37
37
|
|
|
38
|
-
@subscriptions <<
|
|
38
|
+
@subscriptions << subscribe_notification("cache_read.active_support") do |name, started, finished, unique_id, payload|
|
|
39
39
|
duration = ((finished - started) * 1000).round(2)
|
|
40
40
|
@cache_reads << {
|
|
41
41
|
key: payload[:key],
|
|
@@ -44,7 +44,7 @@ module Profiler
|
|
|
44
44
|
}
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
@subscriptions <<
|
|
47
|
+
@subscriptions << subscribe_notification("cache_write.active_support") do |name, started, finished, unique_id, payload|
|
|
48
48
|
duration = ((finished - started) * 1000).round(2)
|
|
49
49
|
@cache_writes << {
|
|
50
50
|
key: payload[:key],
|
|
@@ -52,7 +52,7 @@ module Profiler
|
|
|
52
52
|
}
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
@subscriptions <<
|
|
55
|
+
@subscriptions << subscribe_notification("cache_delete.active_support") do |name, started, finished, unique_id, payload|
|
|
56
56
|
duration = ((finished - started) * 1000).round(2)
|
|
57
57
|
@cache_deletes << {
|
|
58
58
|
key: payload[:key],
|
|
@@ -6,10 +6,24 @@ require_relative "../models/sql_query"
|
|
|
6
6
|
module Profiler
|
|
7
7
|
module Collectors
|
|
8
8
|
class DatabaseCollector < BaseCollector
|
|
9
|
+
BACKTRACE_DEPTH = 10
|
|
10
|
+
MAX_SCANNED_FRAMES = 100
|
|
11
|
+
FRAMES_PAST_APPLICATION = 15
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Frames between the application's code and the subscriber: the profiler itself, the
|
|
15
|
+
# notifications bus and Active Record.
|
|
16
|
+
INTERNAL_FRAMES = [
|
|
17
|
+
File.expand_path("..", __dir__) + "/",
|
|
18
|
+
"/active_support/notifications",
|
|
19
|
+
"/active_record/"
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
9
22
|
def initialize(profile)
|
|
10
23
|
super
|
|
11
24
|
@queries = []
|
|
12
|
-
@
|
|
25
|
+
@subscriptions = []
|
|
26
|
+
@seen_statements = {}
|
|
13
27
|
end
|
|
14
28
|
|
|
15
29
|
def icon
|
|
@@ -34,7 +48,10 @@ module Profiler
|
|
|
34
48
|
def subscribe
|
|
35
49
|
return unless defined?(ActiveSupport::Notifications)
|
|
36
50
|
|
|
37
|
-
@
|
|
51
|
+
@backtrace_mode = Profiler.configuration.sql_backtrace&.to_sym
|
|
52
|
+
@slow_query_threshold = Profiler.configuration.slow_query_threshold
|
|
53
|
+
|
|
54
|
+
@subscriptions << subscribe_notification("sql.active_record") do |name, started, finished, unique_id, payload|
|
|
38
55
|
duration = ((finished - started) * 1000).round(2) # milliseconds
|
|
39
56
|
|
|
40
57
|
# Skip schema queries and internal Rails queries
|
|
@@ -47,7 +64,7 @@ module Profiler
|
|
|
47
64
|
binds: extract_binds(payload[:binds]),
|
|
48
65
|
name: payload[:name],
|
|
49
66
|
connection: payload[:connection],
|
|
50
|
-
backtrace: extract_backtrace
|
|
67
|
+
backtrace: backtrace?(payload[:sql], duration) ? extract_backtrace : []
|
|
51
68
|
)
|
|
52
69
|
|
|
53
70
|
@queries << query
|
|
@@ -74,10 +91,7 @@ module Profiler
|
|
|
74
91
|
end
|
|
75
92
|
|
|
76
93
|
def unsubscribe
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
ActiveSupport::Notifications.unsubscribe(@subscription)
|
|
80
|
-
@subscription = nil
|
|
94
|
+
unsubscribe_notifications(@subscriptions)
|
|
81
95
|
end
|
|
82
96
|
|
|
83
97
|
def toolbar_summary
|
|
@@ -118,10 +132,114 @@ module Profiler
|
|
|
118
132
|
binds.map { Profiler::Redaction::MASK }
|
|
119
133
|
end
|
|
120
134
|
|
|
135
|
+
# Capturing the caller costs tens of microseconds. The first run of a statement locates
|
|
136
|
+
# it, an N+1 loop included (its repeats are the same statement with other values); a slow
|
|
137
|
+
# query is always located.
|
|
138
|
+
def backtrace?(sql, duration)
|
|
139
|
+
case @backtrace_mode
|
|
140
|
+
when :all then true
|
|
141
|
+
when :none then false
|
|
142
|
+
else
|
|
143
|
+
key = statement_key(sql)
|
|
144
|
+
first = !@seen_statements.key?(key)
|
|
145
|
+
@seen_statements[key] = true if first
|
|
146
|
+
first || duration > @slow_query_threshold
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The statement with its values left out, exactly as the N+1 detection of the Database tab
|
|
151
|
+
# (DatabaseTab.tsx) and of the MCP (Resources::N1Patterns) groups queries: the first query
|
|
152
|
+
# of each group is then always one whose caller was captured, values inlined in the SQL
|
|
153
|
+
# (MySQL) included.
|
|
154
|
+
def statement_key(sql)
|
|
155
|
+
sql.to_s
|
|
156
|
+
.gsub(/\$\d+/, "?")
|
|
157
|
+
.gsub(/\b\d+\b/, "?")
|
|
158
|
+
.gsub(/'[^']*'/, "?")
|
|
159
|
+
.gsub(/"[^"]*"/, "?")
|
|
160
|
+
.strip
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The code that ran the query: the frames Rails.backtrace_cleaner keeps, as the exception
|
|
164
|
+
# tab shows them, or without Rails, the frames outside gems and Ruby itself. Whatever stands
|
|
165
|
+
# in between (the profiler, Active Support, Active Record, the driver, other gems) is left
|
|
166
|
+
# out, so that the first frames are the application's.
|
|
121
167
|
def extract_backtrace
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
168
|
+
cleaner = rails_backtrace_cleaner
|
|
169
|
+
cleaned = []
|
|
170
|
+
plain = []
|
|
171
|
+
scanned = 0
|
|
172
|
+
last_kept = nil
|
|
173
|
+
while scanned < MAX_SCANNED_FRAMES && cleaned.size < BACKTRACE_DEPTH
|
|
174
|
+
chunk = caller_locations(1 + scanned, 25)
|
|
175
|
+
break if chunk.nil? || chunk.empty?
|
|
176
|
+
|
|
177
|
+
chunk.each do |loc|
|
|
178
|
+
scanned += 1
|
|
179
|
+
path = loc.path.to_s
|
|
180
|
+
next if INTERNAL_FRAMES.any? { |internal| path.include?(internal) }
|
|
181
|
+
# Gems and Ruby are never the application's code, and Rails' cleaner silences them
|
|
182
|
+
# too: a string test spares it most frames.
|
|
183
|
+
next if library_frame?(path)
|
|
184
|
+
|
|
185
|
+
line = "#{path}:#{loc.lineno}:in `#{loc.label}`"
|
|
186
|
+
if cleaner && (kept = clean_frame(cleaner, line))
|
|
187
|
+
cleaned << kept
|
|
188
|
+
last_kept = scanned
|
|
189
|
+
end
|
|
190
|
+
plain << line if plain.size < BACKTRACE_DEPTH
|
|
191
|
+
break if cleaned.size == BACKTRACE_DEPTH
|
|
192
|
+
end
|
|
193
|
+
# Without Rails, the first frames outside gems are enough; with it, the application's
|
|
194
|
+
# frames come together: a few frames past the last one kept, the rest is the framework.
|
|
195
|
+
break if cleaner.nil? && plain.size >= BACKTRACE_DEPTH
|
|
196
|
+
break if last_kept && scanned - last_kept > FRAMES_PAST_APPLICATION
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
frames = cleaned.empty? ? plain : cleaned
|
|
200
|
+
frames.empty? ? fallback_backtrace : frames
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def rails_backtrace_cleaner
|
|
204
|
+
Rails.backtrace_cleaner if defined?(Rails) && Rails.respond_to?(:backtrace_cleaner)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The frame as the cleaner shows it, or nil when it silences it.
|
|
210
|
+
def clean_frame(cleaner, line)
|
|
211
|
+
cleaner.respond_to?(:clean_frame) ? cleaner.clean_frame(line) : cleaner.clean([line]).first
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fallback_backtrace
|
|
217
|
+
(caller_locations(1, BACKTRACE_DEPTH + 20) || []).filter_map do |loc|
|
|
218
|
+
path = loc.path.to_s
|
|
219
|
+
"#{path}:#{loc.lineno}:in `#{loc.label}`" unless INTERNAL_FRAMES.any? { |internal| path.include?(internal) }
|
|
220
|
+
end.first(BACKTRACE_DEPTH)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Ruby itself and the directories gems are installed in; an application that lives under a
|
|
224
|
+
# directory called gems is not one.
|
|
225
|
+
def library_frame?(path)
|
|
226
|
+
path.start_with?("<internal:") || library_prefixes.any? { |dir| path.start_with?(dir) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def library_prefixes
|
|
230
|
+
@library_prefixes ||= begin
|
|
231
|
+
dirs = [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"], RbConfig::CONFIG["bindir"]]
|
|
232
|
+
dirs.concat(Gem.path) if defined?(Gem)
|
|
233
|
+
dirs << bundle_path
|
|
234
|
+
dirs.compact.reject(&:empty?).map { |dir| File.join(File.expand_path(dir), "") }.uniq.freeze
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Bundler raises without a Gemfile.
|
|
239
|
+
def bundle_path
|
|
240
|
+
Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
|
|
241
|
+
rescue StandardError
|
|
242
|
+
nil
|
|
125
243
|
end
|
|
126
244
|
end
|
|
127
245
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base_collector"
|
|
4
|
+
require_relative "../process_snapshot"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
module Collectors
|
|
@@ -24,14 +25,21 @@ module Profiler
|
|
|
24
25
|
}
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
# ENV is the process's, not the request's: an HTTP profile keeps none of it, and the Env tab
|
|
29
|
+
# shows the variables of the process that displays the profile, which is the one that served
|
|
30
|
+
# the request (ProcessSnapshot). A job, a console expression or a test runs in another
|
|
31
|
+
# process (Sidekiq, the console, rspec): its profile keeps that process's ENV, masked.
|
|
27
32
|
def collect
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
if @profile.profile_type.to_s == "http"
|
|
34
|
+
store_data({ scope: "process" })
|
|
35
|
+
else
|
|
36
|
+
variables = Profiler::Redaction.env_snapshot
|
|
37
|
+
store_data({ variables: variables, total: variables.size })
|
|
38
|
+
end
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
def toolbar_summary
|
|
33
|
-
|
|
34
|
-
{ text: "#{data[:total] || 0} vars", color: "gray" }
|
|
42
|
+
{ text: "#{ENV.size} vars", color: "gray" }
|
|
35
43
|
end
|
|
36
44
|
end
|
|
37
45
|
end
|
|
@@ -31,11 +31,12 @@ module Profiler
|
|
|
31
31
|
def subscribe
|
|
32
32
|
@exception_data = nil
|
|
33
33
|
|
|
34
|
-
@
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
@subscriptions = [
|
|
35
|
+
subscribe_notification("process_action.action_controller") do |_name, _started, _finished, _id, payload|
|
|
36
|
+
ex = payload[:exception_object]
|
|
37
|
+
capture_exception(ex) if ex && @exception_data.nil?
|
|
38
|
+
end
|
|
39
|
+
]
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def capture(ex)
|
|
@@ -54,10 +55,7 @@ module Profiler
|
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def unsubscribe
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
60
|
-
@subscriber = nil
|
|
58
|
+
unsubscribe_notifications(@subscriptions) if @subscriptions
|
|
61
59
|
end
|
|
62
60
|
|
|
63
61
|
def has_data?
|
|
@@ -38,7 +38,7 @@ module Profiler
|
|
|
38
38
|
claim_thread_slot(:profiler_flamegraph_collector, self)
|
|
39
39
|
|
|
40
40
|
# Controller action
|
|
41
|
-
@subscriptions <<
|
|
41
|
+
@subscriptions << subscribe_notification("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
|
|
42
42
|
add_event Models::TimelineEvent.new(
|
|
43
43
|
name: "#{payload[:controller]}##{payload[:action]}",
|
|
44
44
|
started_at: started,
|
|
@@ -56,7 +56,7 @@ module Profiler
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# Template rendering
|
|
59
|
-
@subscriptions <<
|
|
59
|
+
@subscriptions << subscribe_notification("render_template.action_view") do |_name, started, finished, _unique_id, payload|
|
|
60
60
|
identifier = short_identifier(payload[:identifier])
|
|
61
61
|
add_event Models::TimelineEvent.new(
|
|
62
62
|
name: "Render: #{identifier}",
|
|
@@ -68,7 +68,7 @@ module Profiler
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Partial rendering
|
|
71
|
-
@subscriptions <<
|
|
71
|
+
@subscriptions << subscribe_notification("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
|
|
72
72
|
identifier = short_identifier(payload[:identifier])
|
|
73
73
|
add_event Models::TimelineEvent.new(
|
|
74
74
|
name: "Partial: #{identifier}",
|
|
@@ -80,7 +80,7 @@ module Profiler
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# SQL queries
|
|
83
|
-
@subscriptions <<
|
|
83
|
+
@subscriptions << subscribe_notification("sql.active_record") do |_name, started, finished, _unique_id, payload|
|
|
84
84
|
next if payload[:name] == "SCHEMA"
|
|
85
85
|
next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
|
|
86
86
|
|
|
@@ -96,7 +96,7 @@ module Profiler
|
|
|
96
96
|
|
|
97
97
|
# Cache operations
|
|
98
98
|
%w[cache_read.active_support cache_write.active_support cache_delete.active_support].each do |event_name|
|
|
99
|
-
@subscriptions <<
|
|
99
|
+
@subscriptions << subscribe_notification(event_name) do |name, started, finished, _unique_id, payload|
|
|
100
100
|
op = name.split(".").first.sub("cache_", "")
|
|
101
101
|
key = payload[:key].to_s
|
|
102
102
|
add_event Models::TimelineEvent.new(
|
|
@@ -5,7 +5,7 @@ require_relative "base_collector"
|
|
|
5
5
|
begin
|
|
6
6
|
require "stackprof"
|
|
7
7
|
rescue LoadError
|
|
8
|
-
# stackprof not available
|
|
8
|
+
# stackprof not available: lite mode stays off, unless Profiler.function_profiling_tracepoint_fallback
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
module Profiler
|
|
@@ -55,6 +55,20 @@ module Profiler
|
|
|
55
55
|
|
|
56
56
|
mode = Profiler.function_profiling_mode
|
|
57
57
|
clock = Profiler.function_profiling_clock
|
|
58
|
+
|
|
59
|
+
# Sampling needs stackprof, which the gem does not depend on. Tracing every method call
|
|
60
|
+
# instead costs several times the rest of the profiler: only when asked for.
|
|
61
|
+
if stackprof_missing?(mode)
|
|
62
|
+
store_data({
|
|
63
|
+
enabled: false,
|
|
64
|
+
reason: "stackprof_missing",
|
|
65
|
+
max_frames: Profiler.function_profiling_max_frames,
|
|
66
|
+
mode: mode,
|
|
67
|
+
clock: clock
|
|
68
|
+
})
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
@subscribed = true
|
|
59
73
|
claim_thread_slot(:fn_profiler_mode, mode)
|
|
60
74
|
claim_thread_slot(:fn_profiler_clock, clock)
|
|
@@ -119,6 +133,7 @@ module Profiler
|
|
|
119
133
|
|
|
120
134
|
def toolbar_summary
|
|
121
135
|
return { text: "off", color: "gray" } unless Profiler.function_profiling_enabled
|
|
136
|
+
return { text: "off", color: "gray" } if stackprof_missing?(Profiler.function_profiling_mode)
|
|
122
137
|
case Profiler.function_profiling_mode
|
|
123
138
|
when "lite" then { text: "sampling on", color: "blue" }
|
|
124
139
|
else { text: "fn profiling on", color: "purple" }
|
|
@@ -127,6 +142,10 @@ module Profiler
|
|
|
127
142
|
|
|
128
143
|
private
|
|
129
144
|
|
|
145
|
+
def stackprof_missing?(mode)
|
|
146
|
+
mode == "lite" && !defined?(StackProf) && !Profiler.function_profiling_tracepoint_fallback
|
|
147
|
+
end
|
|
148
|
+
|
|
130
149
|
# ── StackProf (lite mode) ──────────────────────────────────────────────────
|
|
131
150
|
|
|
132
151
|
def store_sampler_busy(mode, clock)
|
|
@@ -297,10 +316,9 @@ module Profiler
|
|
|
297
316
|
claim_thread_slot(:fn_profiler_depth, Hash.new(0))
|
|
298
317
|
|
|
299
318
|
@trace = TracePoint.new(:call, :return) do |tp|
|
|
300
|
-
next unless tp.path&.start_with?(app_root)
|
|
301
|
-
|
|
302
319
|
stack = Thread.current[:fn_profiler_stack]
|
|
303
320
|
next unless stack
|
|
321
|
+
next unless tp.path&.start_with?(app_root)
|
|
304
322
|
|
|
305
323
|
case tp.event
|
|
306
324
|
when :call
|
|
@@ -339,7 +357,13 @@ module Profiler
|
|
|
339
357
|
end
|
|
340
358
|
end
|
|
341
359
|
|
|
342
|
-
|
|
360
|
+
# On the request's thread only: a TracePoint enabled for the whole process slows down
|
|
361
|
+
# every other thread, other requests included.
|
|
362
|
+
begin
|
|
363
|
+
@trace.enable(target_thread: Thread.current)
|
|
364
|
+
rescue ArgumentError
|
|
365
|
+
@trace.enable # a Ruby without target_thread: the hook returns at once on other threads
|
|
366
|
+
end
|
|
343
367
|
end
|
|
344
368
|
|
|
345
369
|
def collect_tracepoint(mode)
|
|
@@ -40,15 +40,10 @@ module Profiler
|
|
|
40
40
|
return unless defined?(ActiveSupport::Notifications)
|
|
41
41
|
return unless Profiler.configuration.track_mailers
|
|
42
42
|
|
|
43
|
-
# Capture the subscriber thread so notifications from other threads (e.g. async
|
|
44
|
-
# job threads delivering mail enqueued via deliver_later) are ignored by this collector.
|
|
45
|
-
@subscriber_thread = Thread.current
|
|
46
|
-
|
|
47
43
|
# Use a stack so multiple deliver_later calls in the same request are all tracked.
|
|
48
44
|
claim_thread_slot(:profiler_pending_processes, [])
|
|
49
45
|
|
|
50
|
-
@subscriptions <<
|
|
51
|
-
next unless Thread.current.equal?(@subscriber_thread)
|
|
46
|
+
@subscriptions << subscribe_notification("process.action_mailer") do |_name, started, finished, _id, payload|
|
|
52
47
|
next if rails_preview_request?
|
|
53
48
|
|
|
54
49
|
mailer_class = payload[:mailer].to_s
|
|
@@ -64,8 +59,7 @@ module Profiler
|
|
|
64
59
|
}
|
|
65
60
|
end
|
|
66
61
|
|
|
67
|
-
@subscriptions <<
|
|
68
|
-
next unless Thread.current.equal?(@subscriber_thread)
|
|
62
|
+
@subscriptions << subscribe_notification("deliver.action_mailer") do |_name, started, finished, _id, payload|
|
|
69
63
|
next unless payload[:perform_deliveries]
|
|
70
64
|
next if rails_preview_request?
|
|
71
65
|
|