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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/builds/profiler-toolbar.js +1191 -0
  3. data/app/assets/builds/profiler.css +2668 -0
  4. data/app/assets/builds/profiler.js +2772 -0
  5. data/app/controllers/profiler/api/ajax_controller.rb +36 -0
  6. data/app/controllers/profiler/api/jobs_controller.rb +39 -0
  7. data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
  8. data/app/controllers/profiler/api/profiles_controller.rb +60 -0
  9. data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
  10. data/app/controllers/profiler/application_controller.rb +19 -0
  11. data/app/controllers/profiler/assets_controller.rb +29 -0
  12. data/app/controllers/profiler/profiles_controller.rb +107 -0
  13. data/app/views/layouts/profiler/application.html.erb +16 -0
  14. data/app/views/layouts/profiler/embedded.html.erb +34 -0
  15. data/app/views/profiler/profiles/index.html.erb +1 -0
  16. data/app/views/profiler/profiles/show.html.erb +4 -0
  17. data/config/routes.rb +36 -0
  18. data/exe/profiler-mcp +8 -0
  19. data/lib/profiler/collectors/ajax_collector.rb +109 -0
  20. data/lib/profiler/collectors/base_collector.rb +92 -0
  21. data/lib/profiler/collectors/cache_collector.rb +96 -0
  22. data/lib/profiler/collectors/database_collector.rb +113 -0
  23. data/lib/profiler/collectors/dump_collector.rb +98 -0
  24. data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
  25. data/lib/profiler/collectors/http_collector.rb +112 -0
  26. data/lib/profiler/collectors/job_collector.rb +50 -0
  27. data/lib/profiler/collectors/performance_collector.rb +103 -0
  28. data/lib/profiler/collectors/request_collector.rb +80 -0
  29. data/lib/profiler/collectors/view_collector.rb +79 -0
  30. data/lib/profiler/configuration.rb +81 -0
  31. data/lib/profiler/engine.rb +17 -0
  32. data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
  33. data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
  34. data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
  35. data/lib/profiler/job_profiler.rb +118 -0
  36. data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
  37. data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
  38. data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
  39. data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
  40. data/lib/profiler/mcp/server.rb +217 -0
  41. data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
  42. data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
  43. data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
  44. data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
  45. data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
  46. data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
  47. data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
  48. data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
  49. data/lib/profiler/middleware/cors_middleware.rb +55 -0
  50. data/lib/profiler/middleware/profiler_middleware.rb +151 -0
  51. data/lib/profiler/middleware/toolbar_injector.rb +378 -0
  52. data/lib/profiler/models/profile.rb +182 -0
  53. data/lib/profiler/models/sql_query.rb +48 -0
  54. data/lib/profiler/models/timeline_event.rb +40 -0
  55. data/lib/profiler/railtie.rb +75 -0
  56. data/lib/profiler/storage/base_store.rb +41 -0
  57. data/lib/profiler/storage/blob_store.rb +46 -0
  58. data/lib/profiler/storage/file_store.rb +119 -0
  59. data/lib/profiler/storage/memory_store.rb +94 -0
  60. data/lib/profiler/storage/redis_store.rb +98 -0
  61. data/lib/profiler/storage/sqlite_store.rb +272 -0
  62. data/lib/profiler/tasks/profiler.rake +79 -0
  63. data/lib/profiler/version.rb +5 -0
  64. data/lib/profiler.rb +68 -0
  65. metadata +194 -0
@@ -0,0 +1,103 @@
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 PerformanceCollector < 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: "performance",
26
+ label: "Performance",
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
+ # Subscribe to controller processing
38
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |name, started, finished, unique_id, payload|
39
+ @events << Models::TimelineEvent.new(
40
+ name: "Controller: #{payload[:controller]}##{payload[:action]}",
41
+ started_at: started,
42
+ finished_at: finished,
43
+ payload: {
44
+ controller: payload[:controller],
45
+ action: payload[:action],
46
+ format: payload[:format],
47
+ method: payload[:method],
48
+ path: payload[:path],
49
+ status: payload[:status]
50
+ }
51
+ )
52
+ end
53
+
54
+ # Subscribe to view rendering
55
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |name, started, finished, unique_id, payload|
56
+ @events << Models::TimelineEvent.new(
57
+ name: "Render: #{payload[:identifier]}",
58
+ started_at: started,
59
+ finished_at: finished,
60
+ payload: {
61
+ identifier: payload[:identifier],
62
+ layout: payload[:layout]
63
+ }
64
+ )
65
+ end
66
+
67
+ # Subscribe to partial rendering
68
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |name, started, finished, unique_id, payload|
69
+ @events << Models::TimelineEvent.new(
70
+ name: "Partial: #{payload[:identifier]}",
71
+ started_at: started,
72
+ finished_at: finished,
73
+ payload: {
74
+ identifier: payload[:identifier]
75
+ }
76
+ )
77
+ end
78
+ end
79
+
80
+ def collect
81
+ # Unsubscribe from all notifications
82
+ @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
83
+
84
+ data = {
85
+ total_events: @events.size,
86
+ total_duration: @events.sum(&:duration).round(2),
87
+ events: @events.map(&:to_h)
88
+ }
89
+
90
+ store_data(data)
91
+ end
92
+
93
+ def toolbar_summary
94
+ duration = @events.sum(&:duration).round(2)
95
+
96
+ {
97
+ text: "#{@events.size} events (#{duration}ms)",
98
+ color: "blue"
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class RequestCollector < BaseCollector
8
+ def icon
9
+ "🌐"
10
+ end
11
+
12
+ def priority
13
+ 10
14
+ end
15
+
16
+ def tab_config
17
+ {
18
+ key: "request",
19
+ label: "Request",
20
+ icon: icon,
21
+ priority: priority,
22
+ enabled: true,
23
+ default_active: false
24
+ }
25
+ end
26
+
27
+ def collect
28
+ data = {
29
+ path: @profile.path,
30
+ method: @profile.method,
31
+ status: @profile.status,
32
+ duration: @profile.duration,
33
+ memory: @profile.memory,
34
+ params: @profile.params,
35
+ headers: @profile.headers,
36
+ response_headers: @profile.response_headers,
37
+ request_body: @profile.request_body,
38
+ request_body_encoding: @profile.request_body_encoding,
39
+ response_body: @profile.response_body,
40
+ response_body_encoding: @profile.response_body_encoding,
41
+ started_at: @profile.started_at&.iso8601,
42
+ finished_at: @profile.finished_at&.iso8601
43
+ }
44
+
45
+ store_data(data)
46
+ end
47
+
48
+ def toolbar_summary
49
+ status_color = case @profile.status
50
+ when 200..299 then "green"
51
+ when 300..399 then "blue"
52
+ when 400..499 then "orange"
53
+ when 500..599 then "red"
54
+ else "gray"
55
+ end
56
+
57
+ {
58
+ text: "#{@profile.method} #{@profile.status}",
59
+ color: status_color,
60
+ duration: @profile.duration,
61
+ memory: format_memory(@profile.memory)
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def format_memory(bytes)
68
+ return "0 B" unless bytes
69
+
70
+ if bytes < 1024
71
+ "#{bytes} B"
72
+ elsif bytes < 1024 * 1024
73
+ "#{(bytes / 1024.0).round(2)} KB"
74
+ else
75
+ "#{(bytes / 1024.0 / 1024.0).round(2)} MB"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class ViewCollector < BaseCollector
8
+ def initialize(profile)
9
+ super
10
+ @views = []
11
+ @partials = []
12
+ @subscriptions = []
13
+ end
14
+
15
+ def icon
16
+ "👁️"
17
+ end
18
+
19
+ def priority
20
+ 40
21
+ end
22
+
23
+ def tab_config
24
+ {
25
+ key: "view",
26
+ label: "Views",
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
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |name, started, finished, unique_id, payload|
38
+ duration = ((finished - started) * 1000).round(2)
39
+ @views << {
40
+ identifier: payload[:identifier],
41
+ layout: payload[:layout],
42
+ duration: duration
43
+ }
44
+ end
45
+
46
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |name, started, finished, unique_id, payload|
47
+ duration = ((finished - started) * 1000).round(2)
48
+ @partials << {
49
+ identifier: payload[:identifier],
50
+ duration: duration
51
+ }
52
+ end
53
+ end
54
+
55
+ def collect
56
+ @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
57
+
58
+ data = {
59
+ views: @views,
60
+ partials: @partials,
61
+ total_views: @views.size,
62
+ total_partials: @partials.size,
63
+ total_duration: (@views + @partials).sum { |v| v[:duration] }.round(2)
64
+ }
65
+
66
+ store_data(data)
67
+ end
68
+
69
+ def toolbar_summary
70
+ total_duration = (@views + @partials).sum { |v| v[:duration] }.round(2)
71
+
72
+ {
73
+ text: "#{@views.size} views, #{@partials.size} partials (#{total_duration}ms)",
74
+ color: "purple"
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class Configuration
5
+ attr_accessor :enabled, :storage, :storage_options, :collectors,
6
+ :skip_paths, :slow_query_threshold, :max_queries_warning,
7
+ :track_memory, :memory_warning_threshold,
8
+ :mcp_enabled, :mcp_transport, :mcp_port,
9
+ :authorization_mode, :max_profiles, :extension_cors_enabled,
10
+ :track_ajax, :ajax_skip_paths,
11
+ :track_http, :slow_http_threshold, :http_skip_hosts,
12
+ :track_jobs
13
+
14
+ attr_reader :authorize_block
15
+
16
+ def initialize
17
+ @enabled = false
18
+ @storage = :memory
19
+ @storage_options = {}
20
+ @collectors = []
21
+ @skip_paths = [%r{^/_profiler}, /\.well-known/, /favicon\.ico/, /manifest\.json/]
22
+ @slow_query_threshold = 100 # milliseconds
23
+ @max_queries_warning = 50
24
+ @track_memory = true
25
+ @memory_warning_threshold = 100 * 1024 * 1024 # 100 MB
26
+ @mcp_enabled = false
27
+ @mcp_transport = :stdio
28
+ @mcp_port = 3001
29
+ @authorization_mode = :allow_all
30
+ @authorize_block = nil
31
+ @max_profiles = 100
32
+ @extension_cors_enabled = true
33
+ @track_ajax = true
34
+ @ajax_skip_paths = [/^\/_profiler/]
35
+ @track_http = true
36
+ @slow_http_threshold = 500 # milliseconds
37
+ @http_skip_hosts = []
38
+ @track_jobs = true
39
+ end
40
+
41
+ def authorize_with(&block)
42
+ @authorize_block = block
43
+ end
44
+
45
+ def authorized?(request)
46
+ case @authorization_mode
47
+ when :allow_all
48
+ true
49
+ when :allow_authorized
50
+ @authorize_block ? @authorize_block.call(request) : false
51
+ else
52
+ false
53
+ end
54
+ end
55
+
56
+ def storage_backend
57
+ @storage_backend ||= build_storage_backend
58
+ end
59
+
60
+ private
61
+
62
+ def build_storage_backend
63
+ case @storage
64
+ when :memory
65
+ require_relative "storage/memory_store"
66
+ Storage::MemoryStore.new(@storage_options)
67
+ when :file
68
+ require_relative "storage/file_store"
69
+ Storage::FileStore.new(@storage_options)
70
+ when :redis
71
+ require_relative "storage/redis_store"
72
+ Storage::RedisStore.new(@storage_options)
73
+ when :sqlite
74
+ require_relative "storage/sqlite_store"
75
+ Storage::SqliteStore.new(@storage_options)
76
+ else
77
+ raise Error, "Unknown storage backend: #{@storage}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Profiler
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Profiler
8
+
9
+ config.profiler = ActiveSupport::OrderedOptions.new
10
+
11
+ initializer "profiler.helpers" do
12
+ ActiveSupport.on_load(:action_controller) do
13
+ helper Profiler::Engine.helpers
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Instrumentation
5
+ module ActiveJobInstrumentation
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ around_perform do |job, block|
10
+ Profiler::JobProfiler.profile(
11
+ job_class: job.class.name,
12
+ job_id: job.job_id,
13
+ queue: job.queue_name,
14
+ arguments: job.arguments,
15
+ executions: job.executions,
16
+ &block
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "base64"
5
+ require "zlib"
6
+ require "stringio"
7
+
8
+ module Profiler
9
+ module Instrumentation
10
+ module NetHttpInstrumentation
11
+ module RequestPatch
12
+ def request(req, body = nil, &block)
13
+ collector = Thread.current[:profiler_http_collector]
14
+ return super unless collector
15
+ # Re-entrancy guard: Net::HTTP#request calls itself recursively when
16
+ # the connection isn't started yet. Only record the outermost call.
17
+ return super if Thread.current[:profiler_http_recording]
18
+
19
+ host = address.to_s
20
+ return super if NetHttpInstrumentation.skip_host?(host)
21
+
22
+ url = build_url(host, port, req.path, use_ssl?)
23
+ req_body = req.body.to_s
24
+ req_headers = req.to_hash.transform_values { |v| v.join(", ") }
25
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ Thread.current[:profiler_http_recording] = true
27
+
28
+ response = super
29
+
30
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
31
+ resp_body_raw = response.body.to_s
32
+ resp_content_encoding = response["content-encoding"].to_s.strip.downcase
33
+ resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding)
34
+ resp_content_type = response["content-type"].to_s
35
+ req_content_type = req["content-type"].to_s
36
+
37
+ processed_req = req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
38
+ processed_resp = NetHttpInstrumentation.process_body(resp_body, resp_content_type)
39
+
40
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+
42
+ collector.record_request(
43
+ url: url,
44
+ method: req.method,
45
+ status: response.code.to_i,
46
+ duration: duration,
47
+ request_headers: req_headers,
48
+ request_body: processed_req[:body],
49
+ request_body_encoding: processed_req[:encoding],
50
+ request_size: req_body.bytesize,
51
+ response_headers: response.to_hash.transform_values { |v| v.join(", ") },
52
+ response_body: processed_resp[:body],
53
+ response_body_encoding: processed_resp[:encoding],
54
+ response_size: resp_body_raw.bytesize,
55
+ backtrace: NetHttpInstrumentation.extract_backtrace
56
+ )
57
+
58
+ fg = Thread.current[:profiler_flamegraph_collector]
59
+ fg&.record_http_event(started_at: t0, finished_at: t1, url: url, method: req.method, status: response.code.to_i)
60
+
61
+ response
62
+ rescue => e
63
+ if defined?(t0) && t0
64
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
65
+ collector&.record_request(
66
+ url: url,
67
+ method: req.method,
68
+ status: 0,
69
+ duration: duration,
70
+ request_headers: defined?(req_headers) ? req_headers : {},
71
+ request_body: nil,
72
+ request_body_encoding: "text",
73
+ request_size: 0,
74
+ response_headers: {},
75
+ response_body: nil,
76
+ response_body_encoding: "text",
77
+ response_size: 0,
78
+ backtrace: NetHttpInstrumentation.extract_backtrace,
79
+ error: e.message
80
+ )
81
+ end
82
+ raise
83
+ ensure
84
+ Thread.current[:profiler_http_recording] = false
85
+ end
86
+
87
+ private
88
+
89
+ def build_url(host, port, path, ssl)
90
+ scheme = ssl ? "https" : "http"
91
+ standard_port = (ssl && port == 443) || (!ssl && port == 80)
92
+ standard_port ? "#{scheme}://#{host}#{path}" : "#{scheme}://#{host}:#{port}#{path}"
93
+ end
94
+ end
95
+
96
+ SKIP_HOSTS = %w[127.0.0.1 localhost ::1].freeze
97
+
98
+ TEXT_BODY_LIMIT = 512 * 1024 # 512 KB
99
+ BINARY_BODY_LIMIT = 256 * 1024 # 256 KB (before base64)
100
+
101
+ TEXT_CONTENT_TYPES = /\A(text\/|application\/(json|xml|xhtml|javascript|x-www-form-urlencoded)|image\/svg)/i
102
+ BINARY_CONTENT_TYPES = /\A(image\/|application\/pdf|application\/octet-stream|application\/zip|audio\/|video\/)/i
103
+
104
+ def self.install!
105
+ return if @installed
106
+ Net::HTTP.prepend(RequestPatch)
107
+ @installed = true
108
+ end
109
+
110
+ def self.skip_host?(host)
111
+ SKIP_HOSTS.include?(host) ||
112
+ Profiler.configuration.http_skip_hosts.any? { |p| host.match?(p) }
113
+ end
114
+
115
+ def self.decompress_body(body, content_encoding)
116
+ return body if content_encoding.empty? || body.nil? || body.empty?
117
+
118
+ case content_encoding
119
+ when "gzip", "x-gzip"
120
+ Zlib::GzipReader.new(StringIO.new(body)).read
121
+ when "deflate"
122
+ Zlib::Inflate.inflate(body)
123
+ else
124
+ body
125
+ end
126
+ rescue StandardError
127
+ body
128
+ end
129
+
130
+ def self.process_body(body, content_type)
131
+ return { body: nil, encoding: "text" } if body.nil? || body.empty?
132
+
133
+ mime = content_type.split(";").first.to_s.strip
134
+
135
+ if mime.match?(BINARY_CONTENT_TYPES)
136
+ truncated = body.byteslice(0, BINARY_BODY_LIMIT) || ""
137
+ { body: Base64.strict_encode64(truncated.b), encoding: "base64" }
138
+ else
139
+ # Text (including unknown content types)
140
+ text = body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
141
+ { body: text.byteslice(0, TEXT_BODY_LIMIT), encoding: "text" }
142
+ end
143
+ end
144
+
145
+ def self.extract_backtrace
146
+ caller_locations(5, 15)
147
+ .reject { |l| l.path.to_s.include?("net/http") || l.path.to_s.include?("profiler/instrumentation") }
148
+ .first(5)
149
+ .map { |l| "#{l.path}:#{l.lineno}:in `#{l.label}`" }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Instrumentation
5
+ class SidekiqMiddleware
6
+ def call(worker, job, queue, &block)
7
+ Profiler::JobProfiler.profile(
8
+ job_class: job["class"],
9
+ job_id: job["jid"],
10
+ queue: queue,
11
+ arguments: job["args"],
12
+ executions: job["retry_count"].to_i,
13
+ &block
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/profile"
4
+ require_relative "collectors/job_collector"
5
+ require_relative "collectors/database_collector"
6
+ require_relative "collectors/cache_collector"
7
+ require_relative "collectors/http_collector"
8
+
9
+ module Profiler
10
+ class JobProfiler
11
+ JOB_COLLECTOR_CLASSES = [
12
+ Collectors::DatabaseCollector,
13
+ Collectors::CacheCollector,
14
+ Collectors::HttpCollector
15
+ ].freeze
16
+
17
+ def self.profile(job_class:, job_id:, queue:, arguments:, executions:, &block)
18
+ return block.call unless Profiler.enabled? && Profiler.configuration.track_jobs
19
+
20
+ new(
21
+ job_class: job_class,
22
+ job_id: job_id,
23
+ queue: queue,
24
+ arguments: arguments,
25
+ executions: executions
26
+ ).run(&block)
27
+ end
28
+
29
+ def initialize(job_class:, job_id:, queue:, arguments:, executions:)
30
+ @job_class = job_class
31
+ @job_id = job_id
32
+ @queue = queue
33
+ @arguments = arguments
34
+ @executions = executions
35
+ end
36
+
37
+ def run(&block)
38
+ profile = Models::Profile.new
39
+ profile.profile_type = "job"
40
+ profile.path = @job_class
41
+ profile.method = "JOB"
42
+
43
+ job_collector = Collectors::JobCollector.new(profile, {
44
+ job_class: @job_class,
45
+ job_id: @job_id,
46
+ queue: @queue,
47
+ arguments: sanitize_arguments(@arguments),
48
+ executions: @executions
49
+ })
50
+
51
+ collectors = [job_collector] + JOB_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
52
+ collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
53
+
54
+ memory_before = current_memory if Profiler.configuration.track_memory
55
+
56
+ job_status = "completed"
57
+ error_message = nil
58
+
59
+ begin
60
+ result = block.call
61
+ result
62
+ rescue => e
63
+ job_status = "failed"
64
+ error_message = "#{e.class}: #{e.message}"
65
+ raise
66
+ ensure
67
+ if Profiler.configuration.track_memory
68
+ profile.memory = current_memory - memory_before
69
+ end
70
+
71
+ job_collector.update_status(job_status, error_message)
72
+ profile.finish(job_status == "completed" ? 200 : 500)
73
+
74
+ collectors.each do |collector|
75
+ begin
76
+ collector.collect if collector.respond_to?(:collect)
77
+ profile.add_collector_metadata(collector)
78
+ rescue => e
79
+ warn "Profiler JobProfiler: Collector #{collector.class} failed: #{e.message}"
80
+ end
81
+ end
82
+
83
+ Profiler.storage.save(profile.token, profile)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def sanitize_arguments(args)
90
+ return [] unless args
91
+
92
+ args.map do |arg|
93
+ case arg
94
+ when String then arg.length > 200 ? "#{arg[0, 200]}..." : arg
95
+ when Numeric, TrueClass, FalseClass, NilClass then arg
96
+ else
97
+ inspected = arg.inspect
98
+ inspected.length > 200 ? "#{inspected[0, 200]}..." : inspected
99
+ end
100
+ rescue
101
+ arg.to_s
102
+ end
103
+ end
104
+
105
+ def current_memory
106
+ return 0 unless defined?(GC.stat)
107
+
108
+ stats = GC.stat
109
+ if stats.key?(:total_allocated_size)
110
+ stats[:total_allocated_size]
111
+ elsif stats.key?(:total_allocated_objects)
112
+ stats[:total_allocated_objects] * 40
113
+ else
114
+ 0
115
+ end
116
+ end
117
+ end
118
+ end