rails-profiler 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/assets/builds/profiler-toolbar.js +1191 -0
- data/app/assets/builds/profiler.css +2668 -0
- data/app/assets/builds/profiler.js +2772 -0
- data/app/controllers/profiler/api/ajax_controller.rb +36 -0
- data/app/controllers/profiler/api/jobs_controller.rb +39 -0
- data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
- data/app/controllers/profiler/api/profiles_controller.rb +60 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
- data/app/controllers/profiler/application_controller.rb +19 -0
- data/app/controllers/profiler/assets_controller.rb +29 -0
- data/app/controllers/profiler/profiles_controller.rb +107 -0
- data/app/views/layouts/profiler/application.html.erb +16 -0
- data/app/views/layouts/profiler/embedded.html.erb +34 -0
- data/app/views/profiler/profiles/index.html.erb +1 -0
- data/app/views/profiler/profiles/show.html.erb +4 -0
- data/config/routes.rb +36 -0
- data/exe/profiler-mcp +8 -0
- data/lib/profiler/collectors/ajax_collector.rb +109 -0
- data/lib/profiler/collectors/base_collector.rb +92 -0
- data/lib/profiler/collectors/cache_collector.rb +96 -0
- data/lib/profiler/collectors/database_collector.rb +113 -0
- data/lib/profiler/collectors/dump_collector.rb +98 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
- data/lib/profiler/collectors/http_collector.rb +112 -0
- data/lib/profiler/collectors/job_collector.rb +50 -0
- data/lib/profiler/collectors/performance_collector.rb +103 -0
- data/lib/profiler/collectors/request_collector.rb +80 -0
- data/lib/profiler/collectors/view_collector.rb +79 -0
- data/lib/profiler/configuration.rb +81 -0
- data/lib/profiler/engine.rb +17 -0
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
- data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
- data/lib/profiler/job_profiler.rb +118 -0
- data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
- data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
- data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
- data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
- data/lib/profiler/mcp/server.rb +217 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
- data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
- data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
- data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
- data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
- data/lib/profiler/middleware/cors_middleware.rb +55 -0
- data/lib/profiler/middleware/profiler_middleware.rb +151 -0
- data/lib/profiler/middleware/toolbar_injector.rb +378 -0
- data/lib/profiler/models/profile.rb +182 -0
- data/lib/profiler/models/sql_query.rb +48 -0
- data/lib/profiler/models/timeline_event.rb +40 -0
- data/lib/profiler/railtie.rb +75 -0
- data/lib/profiler/storage/base_store.rb +41 -0
- data/lib/profiler/storage/blob_store.rb +46 -0
- data/lib/profiler/storage/file_store.rb +119 -0
- data/lib/profiler/storage/memory_store.rb +94 -0
- data/lib/profiler/storage/redis_store.rb +98 -0
- data/lib/profiler/storage/sqlite_store.rb +272 -0
- data/lib/profiler/tasks/profiler.rake +79 -0
- data/lib/profiler/version.rb +5 -0
- data/lib/profiler.rb +68 -0
- metadata +194 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
class CacheCollector < BaseCollector
|
|
8
|
+
def initialize(profile)
|
|
9
|
+
super
|
|
10
|
+
@cache_reads = []
|
|
11
|
+
@cache_writes = []
|
|
12
|
+
@cache_deletes = []
|
|
13
|
+
@subscriptions = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def icon
|
|
17
|
+
"💾"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def priority
|
|
21
|
+
50
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tab_config
|
|
25
|
+
{
|
|
26
|
+
key: "cache",
|
|
27
|
+
label: "Cache",
|
|
28
|
+
icon: icon,
|
|
29
|
+
priority: priority,
|
|
30
|
+
enabled: true,
|
|
31
|
+
default_active: false
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subscribe
|
|
36
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
37
|
+
|
|
38
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_read.active_support") do |name, started, finished, unique_id, payload|
|
|
39
|
+
duration = ((finished - started) * 1000).round(2)
|
|
40
|
+
@cache_reads << {
|
|
41
|
+
key: payload[:key],
|
|
42
|
+
hit: payload[:hit],
|
|
43
|
+
duration: duration
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_write.active_support") do |name, started, finished, unique_id, payload|
|
|
48
|
+
duration = ((finished - started) * 1000).round(2)
|
|
49
|
+
@cache_writes << {
|
|
50
|
+
key: payload[:key],
|
|
51
|
+
duration: duration
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_delete.active_support") do |name, started, finished, unique_id, payload|
|
|
56
|
+
duration = ((finished - started) * 1000).round(2)
|
|
57
|
+
@cache_deletes << {
|
|
58
|
+
key: payload[:key],
|
|
59
|
+
duration: duration
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def collect
|
|
65
|
+
@subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
66
|
+
|
|
67
|
+
hits = @cache_reads.count { |r| r[:hit] }
|
|
68
|
+
misses = @cache_reads.count { |r| !r[:hit] }
|
|
69
|
+
|
|
70
|
+
data = {
|
|
71
|
+
reads: @cache_reads,
|
|
72
|
+
writes: @cache_writes,
|
|
73
|
+
deletes: @cache_deletes,
|
|
74
|
+
total_reads: @cache_reads.size,
|
|
75
|
+
total_writes: @cache_writes.size,
|
|
76
|
+
total_deletes: @cache_deletes.size,
|
|
77
|
+
hits: hits,
|
|
78
|
+
misses: misses,
|
|
79
|
+
hit_rate: @cache_reads.empty? ? 0 : (hits.to_f / @cache_reads.size * 100).round(2)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
store_data(data)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def toolbar_summary
|
|
86
|
+
hits = @cache_reads.count { |r| r[:hit] }
|
|
87
|
+
total = @cache_reads.size
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
text: "#{hits}/#{total} hits",
|
|
91
|
+
color: "cyan"
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
require_relative "../models/sql_query"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Collectors
|
|
8
|
+
class DatabaseCollector < BaseCollector
|
|
9
|
+
def initialize(profile)
|
|
10
|
+
super
|
|
11
|
+
@queries = []
|
|
12
|
+
@subscription = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def icon
|
|
16
|
+
"🗄️"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def priority
|
|
20
|
+
20
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tab_config
|
|
24
|
+
{
|
|
25
|
+
key: "database",
|
|
26
|
+
label: "Database",
|
|
27
|
+
icon: icon,
|
|
28
|
+
priority: priority,
|
|
29
|
+
enabled: true,
|
|
30
|
+
default_active: true
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def subscribe
|
|
35
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
36
|
+
|
|
37
|
+
@subscription = ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
|
|
38
|
+
duration = ((finished - started) * 1000).round(2) # milliseconds
|
|
39
|
+
|
|
40
|
+
# Skip schema queries and internal Rails queries
|
|
41
|
+
next if payload[:name] == "SCHEMA"
|
|
42
|
+
next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
|
|
43
|
+
|
|
44
|
+
query = Models::SqlQuery.new(
|
|
45
|
+
sql: payload[:sql],
|
|
46
|
+
duration: duration,
|
|
47
|
+
binds: extract_binds(payload[:binds]),
|
|
48
|
+
name: payload[:name],
|
|
49
|
+
connection: payload[:connection],
|
|
50
|
+
backtrace: extract_backtrace
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@queries << query
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def collect
|
|
58
|
+
# Unsubscribe from notifications
|
|
59
|
+
ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
|
|
60
|
+
|
|
61
|
+
data = {
|
|
62
|
+
total_queries: @queries.size,
|
|
63
|
+
total_duration: @queries.sum(&:duration).round(2),
|
|
64
|
+
slow_queries: @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size,
|
|
65
|
+
cached_queries: @queries.count(&:cached?),
|
|
66
|
+
queries: @queries.map(&:to_h)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
store_data(data)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def toolbar_summary
|
|
73
|
+
total = @queries.size
|
|
74
|
+
slow = @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size
|
|
75
|
+
duration = @queries.sum(&:duration).round(2)
|
|
76
|
+
|
|
77
|
+
color = if slow > 0
|
|
78
|
+
"red"
|
|
79
|
+
elsif total > Profiler.configuration.max_queries_warning
|
|
80
|
+
"orange"
|
|
81
|
+
else
|
|
82
|
+
"green"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
text: "#{total} queries (#{duration}ms)",
|
|
87
|
+
color: color,
|
|
88
|
+
slow_queries: slow
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def extract_binds(binds)
|
|
95
|
+
return [] unless binds
|
|
96
|
+
|
|
97
|
+
binds.map do |bind|
|
|
98
|
+
if bind.respond_to?(:value)
|
|
99
|
+
bind.value
|
|
100
|
+
else
|
|
101
|
+
bind
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_backtrace
|
|
107
|
+
caller_locations(5, 10)
|
|
108
|
+
.reject { |loc| loc.path.include?("active_record") }
|
|
109
|
+
.map { |loc| "#{loc.path}:#{loc.lineno}:in `#{loc.label}`" }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
require "pp"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Collectors
|
|
8
|
+
class DumpCollector < BaseCollector
|
|
9
|
+
def icon
|
|
10
|
+
"🔍"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def priority
|
|
14
|
+
15
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def name
|
|
18
|
+
"dump"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tab_config
|
|
22
|
+
{
|
|
23
|
+
key: "dump",
|
|
24
|
+
label: "Dumps",
|
|
25
|
+
icon: icon,
|
|
26
|
+
priority: priority,
|
|
27
|
+
enabled: true,
|
|
28
|
+
default_active: false
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def collect
|
|
33
|
+
dumps = Thread.current[:profiler_dumps] || []
|
|
34
|
+
|
|
35
|
+
formatted_dumps = dumps.map do |dump|
|
|
36
|
+
{
|
|
37
|
+
value: dump[:value],
|
|
38
|
+
formatted: format_value(dump[:value]),
|
|
39
|
+
file: dump[:file],
|
|
40
|
+
line: dump[:line],
|
|
41
|
+
label: dump[:label],
|
|
42
|
+
timestamp: dump[:timestamp]
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
store_data({
|
|
47
|
+
count: formatted_dumps.size,
|
|
48
|
+
dumps: formatted_dumps
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
# Clear dumps for next request
|
|
52
|
+
Thread.current[:profiler_dumps] = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def toolbar_summary
|
|
56
|
+
count = @data[:count] || 0
|
|
57
|
+
color = count > 0 ? "blue" : "gray"
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
text: "#{count} dump#{count != 1 ? 's' : ''}",
|
|
61
|
+
color: color
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def format_value(value)
|
|
68
|
+
case value
|
|
69
|
+
when String
|
|
70
|
+
value.inspect
|
|
71
|
+
when Array, Hash
|
|
72
|
+
PP.pp(value, +"", 120).strip
|
|
73
|
+
when NilClass
|
|
74
|
+
"nil"
|
|
75
|
+
when TrueClass, FalseClass
|
|
76
|
+
value.to_s
|
|
77
|
+
when Numeric
|
|
78
|
+
value.to_s
|
|
79
|
+
else
|
|
80
|
+
# For objects, show class and instance variables
|
|
81
|
+
formatted = "#<#{value.class}:0x#{value.object_id.to_s(16)}"
|
|
82
|
+
ivars = value.instance_variables
|
|
83
|
+
if ivars.any?
|
|
84
|
+
formatted += " "
|
|
85
|
+
formatted += ivars.map do |ivar|
|
|
86
|
+
ivar_value = value.instance_variable_get(ivar)
|
|
87
|
+
"#{ivar}=#{ivar_value.inspect}"
|
|
88
|
+
end.join(", ")
|
|
89
|
+
end
|
|
90
|
+
formatted += ">"
|
|
91
|
+
formatted
|
|
92
|
+
end
|
|
93
|
+
rescue => e
|
|
94
|
+
"[Error formatting value: #{e.message}]"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
require_relative "../models/timeline_event"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Collectors
|
|
8
|
+
class FlameGraphCollector < BaseCollector
|
|
9
|
+
def initialize(profile)
|
|
10
|
+
super
|
|
11
|
+
@events = []
|
|
12
|
+
@subscriptions = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def icon
|
|
16
|
+
"🔥"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def priority
|
|
20
|
+
30
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tab_config
|
|
24
|
+
{
|
|
25
|
+
key: "flamegraph",
|
|
26
|
+
label: "Flame Graph",
|
|
27
|
+
icon: icon,
|
|
28
|
+
priority: priority,
|
|
29
|
+
enabled: true,
|
|
30
|
+
default_active: false
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def subscribe
|
|
35
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
36
|
+
|
|
37
|
+
Thread.current[:profiler_flamegraph_collector] = self
|
|
38
|
+
|
|
39
|
+
# Controller action
|
|
40
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
|
|
41
|
+
@events << Models::TimelineEvent.new(
|
|
42
|
+
name: "#{payload[:controller]}##{payload[:action]}",
|
|
43
|
+
started_at: started,
|
|
44
|
+
finished_at: finished,
|
|
45
|
+
category: "controller",
|
|
46
|
+
payload: {
|
|
47
|
+
controller: payload[:controller],
|
|
48
|
+
action: payload[:action],
|
|
49
|
+
format: payload[:format],
|
|
50
|
+
method: payload[:method],
|
|
51
|
+
path: payload[:path],
|
|
52
|
+
status: payload[:status]
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Template rendering
|
|
58
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |_name, started, finished, _unique_id, payload|
|
|
59
|
+
identifier = short_identifier(payload[:identifier])
|
|
60
|
+
@events << Models::TimelineEvent.new(
|
|
61
|
+
name: "Render: #{identifier}",
|
|
62
|
+
started_at: started,
|
|
63
|
+
finished_at: finished,
|
|
64
|
+
category: "view",
|
|
65
|
+
payload: { identifier: identifier, layout: payload[:layout] }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Partial rendering
|
|
70
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
|
|
71
|
+
identifier = short_identifier(payload[:identifier])
|
|
72
|
+
@events << Models::TimelineEvent.new(
|
|
73
|
+
name: "Partial: #{identifier}",
|
|
74
|
+
started_at: started,
|
|
75
|
+
finished_at: finished,
|
|
76
|
+
category: "partial",
|
|
77
|
+
payload: { identifier: identifier }
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# SQL queries
|
|
82
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_name, started, finished, _unique_id, payload|
|
|
83
|
+
next if payload[:name] == "SCHEMA"
|
|
84
|
+
next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
|
|
85
|
+
|
|
86
|
+
sql = payload[:sql].to_s
|
|
87
|
+
@events << Models::TimelineEvent.new(
|
|
88
|
+
name: sql.length > 80 ? "#{sql[0, 80]}..." : sql,
|
|
89
|
+
started_at: started,
|
|
90
|
+
finished_at: finished,
|
|
91
|
+
category: "sql",
|
|
92
|
+
payload: { sql: sql, name: payload[:name] }
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Cache operations
|
|
97
|
+
%w[cache_read.active_support cache_write.active_support cache_delete.active_support].each do |event_name|
|
|
98
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe(event_name) do |name, started, finished, _unique_id, payload|
|
|
99
|
+
op = name.split(".").first.sub("cache_", "")
|
|
100
|
+
key = payload[:key].to_s
|
|
101
|
+
@events << Models::TimelineEvent.new(
|
|
102
|
+
name: "cache_#{op}: #{key.length > 60 ? "#{key[0, 60]}..." : key}",
|
|
103
|
+
started_at: started,
|
|
104
|
+
finished_at: finished,
|
|
105
|
+
category: "cache",
|
|
106
|
+
payload: { operation: op, key: key, hit: payload[:hit] }
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Called by NetHttpInstrumentation to record outbound HTTP events
|
|
113
|
+
def record_http_event(started_at:, finished_at:, url:, method:, status:)
|
|
114
|
+
@events << Models::TimelineEvent.new(
|
|
115
|
+
name: "HTTP #{method} #{url}",
|
|
116
|
+
started_at: started_at,
|
|
117
|
+
finished_at: finished_at,
|
|
118
|
+
category: "http",
|
|
119
|
+
payload: { url: url, method: method, status: status }
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def collect
|
|
124
|
+
@subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
125
|
+
Thread.current[:profiler_flamegraph_collector] = nil
|
|
126
|
+
|
|
127
|
+
root_events = build_hierarchy(@events)
|
|
128
|
+
|
|
129
|
+
store_data({
|
|
130
|
+
total_events: @events.size,
|
|
131
|
+
total_duration: @events.empty? ? 0 : @events.map(&:duration).sum.round(2),
|
|
132
|
+
root_events: root_events.map(&:to_h)
|
|
133
|
+
})
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def toolbar_summary
|
|
137
|
+
{
|
|
138
|
+
text: "#{@events.size} events",
|
|
139
|
+
color: "blue"
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def build_hierarchy(events)
|
|
146
|
+
return [] if events.empty?
|
|
147
|
+
|
|
148
|
+
# Sort by started_at ASC, then by duration DESC (longest first = parents first)
|
|
149
|
+
sorted = events.sort_by { |e| [e.started_at, -e.duration] }
|
|
150
|
+
|
|
151
|
+
# Stack-based nesting: each stack entry is a potential parent
|
|
152
|
+
roots = []
|
|
153
|
+
stack = []
|
|
154
|
+
|
|
155
|
+
sorted.each do |event|
|
|
156
|
+
# Pop stack entries that have finished before this event starts
|
|
157
|
+
stack.pop while stack.any? && stack.last.finished_at <= event.started_at
|
|
158
|
+
|
|
159
|
+
# Pop stack entries where this event doesn't fit inside
|
|
160
|
+
stack.pop while stack.any? && event.finished_at > stack.last.finished_at
|
|
161
|
+
|
|
162
|
+
if stack.any?
|
|
163
|
+
stack.last.add_child(event)
|
|
164
|
+
else
|
|
165
|
+
roots << event
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
stack.push(event)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
roots
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def short_identifier(identifier)
|
|
175
|
+
return identifier.to_s unless identifier.to_s.include?("/")
|
|
176
|
+
|
|
177
|
+
parts = identifier.to_s.split("/")
|
|
178
|
+
parts.last(2).join("/")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
require_relative "../instrumentation/net_http_instrumentation"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Collectors
|
|
8
|
+
class HttpCollector < BaseCollector
|
|
9
|
+
def initialize(profile)
|
|
10
|
+
super
|
|
11
|
+
@requests = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def icon
|
|
15
|
+
"🔗"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def priority
|
|
19
|
+
35
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tab_config
|
|
23
|
+
{
|
|
24
|
+
key: "http",
|
|
25
|
+
label: "Outbound HTTP",
|
|
26
|
+
icon: icon,
|
|
27
|
+
priority: priority,
|
|
28
|
+
enabled: true,
|
|
29
|
+
default_active: false
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def subscribe
|
|
34
|
+
return unless Profiler.configuration.track_http
|
|
35
|
+
|
|
36
|
+
Profiler::Instrumentation::NetHttpInstrumentation.install!
|
|
37
|
+
Thread.current[:profiler_http_collector] = self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def collect
|
|
41
|
+
Thread.current[:profiler_http_collector] = nil
|
|
42
|
+
|
|
43
|
+
threshold = Profiler.configuration.slow_http_threshold
|
|
44
|
+
|
|
45
|
+
store_data(
|
|
46
|
+
total_requests: @requests.size,
|
|
47
|
+
total_duration: @requests.sum { |r| r[:duration] }.round(2),
|
|
48
|
+
slow_requests: @requests.count { |r| r[:duration] >= threshold },
|
|
49
|
+
error_requests: @requests.count { |r| r[:status] >= 400 || r[:status] == 0 },
|
|
50
|
+
by_host: group_by_host,
|
|
51
|
+
by_status: group_by_status,
|
|
52
|
+
requests: @requests.map { |r| r.transform_keys(&:to_s) }
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_request(payload)
|
|
57
|
+
@requests << payload
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def toolbar_summary
|
|
61
|
+
total = @requests.size
|
|
62
|
+
return { text: "0 HTTP", color: "green" } if total == 0
|
|
63
|
+
|
|
64
|
+
threshold = Profiler.configuration.slow_http_threshold
|
|
65
|
+
errors = @requests.count { |r| r[:status] >= 400 || r[:status] == 0 }
|
|
66
|
+
slow = @requests.count { |r| r[:duration] >= threshold }
|
|
67
|
+
duration = @requests.sum { |r| r[:duration] }.round(2)
|
|
68
|
+
|
|
69
|
+
color = if errors > 0 || slow > 0
|
|
70
|
+
"red"
|
|
71
|
+
elsif total > 10
|
|
72
|
+
"orange"
|
|
73
|
+
else
|
|
74
|
+
"green"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ text: "#{total} HTTP (#{duration}ms)", color: color }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def group_by_host
|
|
83
|
+
@requests.each_with_object(Hash.new(0)) do |req, h|
|
|
84
|
+
host = begin
|
|
85
|
+
URI.parse(req[:url]).host || "unknown"
|
|
86
|
+
rescue URI::InvalidURIError
|
|
87
|
+
"unknown"
|
|
88
|
+
end
|
|
89
|
+
h[host] += 1
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def group_by_status
|
|
94
|
+
@requests.each_with_object(Hash.new(0)) do |req, h|
|
|
95
|
+
status = req[:status]
|
|
96
|
+
key = if status == 0
|
|
97
|
+
"error"
|
|
98
|
+
elsif status < 300
|
|
99
|
+
"2xx"
|
|
100
|
+
elsif status < 400
|
|
101
|
+
"3xx"
|
|
102
|
+
elsif status < 500
|
|
103
|
+
"4xx"
|
|
104
|
+
else
|
|
105
|
+
"5xx"
|
|
106
|
+
end
|
|
107
|
+
h[key] += 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
class JobCollector < BaseCollector
|
|
8
|
+
def initialize(profile, job_data = {})
|
|
9
|
+
super(profile)
|
|
10
|
+
@job_data = job_data.merge(status: "running")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def icon
|
|
14
|
+
"⚙️"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def priority
|
|
18
|
+
5
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tab_config
|
|
22
|
+
{
|
|
23
|
+
key: "job",
|
|
24
|
+
label: "Job",
|
|
25
|
+
icon: icon,
|
|
26
|
+
priority: priority,
|
|
27
|
+
enabled: true,
|
|
28
|
+
default_active: true
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update_status(status, error_message = nil)
|
|
33
|
+
@job_data[:status] = status
|
|
34
|
+
@job_data[:error] = error_message if error_message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def collect
|
|
38
|
+
store_data(@job_data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_data?
|
|
42
|
+
@job_data.any?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def toolbar_summary
|
|
46
|
+
{ text: @job_data[:job_class].to_s, color: @job_data[:status] == "failed" ? "red" : "green" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|