rails-profiler 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler-toolbar.js +60 -1
  3. data/app/assets/builds/profiler.js +120 -31
  4. data/app/controllers/profiler/api/cluster_controller.rb +35 -0
  5. data/app/controllers/profiler/api/events_controller.rb +35 -0
  6. data/app/controllers/profiler/api/profiles_controller.rb +8 -2
  7. data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
  8. data/app/views/layouts/profiler/application.html.erb +3 -0
  9. data/config/routes.rb +11 -0
  10. data/lib/profiler/cluster/master_client.rb +79 -0
  11. data/lib/profiler/cluster/slave_proxy.rb +106 -0
  12. data/lib/profiler/cluster/slave_registry.rb +50 -0
  13. data/lib/profiler/configuration.rb +20 -1
  14. data/lib/profiler/mcp/server.rb +49 -20
  15. data/lib/profiler/mcp/slave_support.rb +23 -0
  16. data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
  17. data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
  18. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  19. data/lib/profiler/mcp/tools/explain_query.rb +8 -0
  20. data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
  21. data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
  22. data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
  23. data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
  24. data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
  25. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
  26. data/lib/profiler/mcp/tools/list_env_vars.rb +38 -0
  27. data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
  28. data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
  29. data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
  30. data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
  31. data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
  32. data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
  33. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
  35. data/lib/profiler/mcp/tools/run_tests.rb +41 -0
  36. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  37. data/lib/profiler/railtie.rb +11 -0
  38. data/lib/profiler/sse/bus.rb +13 -0
  39. data/lib/profiler/sse/event_bus.rb +47 -0
  40. data/lib/profiler/sse/redis_event_bus.rb +79 -0
  41. data/lib/profiler/storage/base_store.rb +18 -1
  42. data/lib/profiler/storage/file_store.rb +1 -1
  43. data/lib/profiler/storage/memory_store.rb +1 -1
  44. data/lib/profiler/storage/redis_store.rb +3 -1
  45. data/lib/profiler/storage/sqlite_store.rb +1 -1
  46. data/lib/profiler/version.rb +1 -1
  47. data/lib/profiler.rb +10 -0
  48. metadata +13 -2
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -10,10 +12,11 @@ module Profiler
10
12
  return [{ type: "text", text: "Error: token parameter is required" }]
11
13
  end
12
14
 
15
+ storage = MCP::SlaveSupport.resolve_storage(params)
13
16
  profile = if token == "latest"
14
- Profiler.storage.list(limit: 1).first
17
+ storage.list(limit: 1).first
15
18
  else
16
- Profiler.storage.load(token)
19
+ storage.load(token)
17
20
  end
18
21
  unless profile
19
22
  return [{ type: "text", text: "Profile not found: #{token}" }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  require "shellwords"
4
6
  require "uri"
5
7
 
@@ -13,10 +15,11 @@ module Profiler
13
15
  return [{ type: "text", text: "Error: token parameter is required" }]
14
16
  end
15
17
 
18
+ storage = MCP::SlaveSupport.resolve_storage(params)
16
19
  profile = if token == "latest"
17
- Profiler.storage.list(limit: 1).first
20
+ storage.list(limit: 1).first
18
21
  else
19
- Profiler.storage.load(token)
22
+ storage.load(token)
20
23
  end
21
24
  unless profile
22
25
  return [{ type: "text", text: "Profile not found: #{token}" }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -12,10 +14,11 @@ module Profiler
12
14
  return [{ type: "text", text: "Error: token parameter is required" }]
13
15
  end
14
16
 
17
+ storage = MCP::SlaveSupport.resolve_storage(params)
15
18
  profile = if token == "latest"
16
- Profiler.storage.list(limit: 1).first
19
+ storage.list(limit: 1).first
17
20
  else
18
- Profiler.storage.load(token)
21
+ storage.load(token)
19
22
  end
20
23
  unless profile
21
24
  return [{ type: "text", text: "Profile not found: #{token}" }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -10,11 +12,12 @@ module Profiler
10
12
  return [{ type: "text", text: "Error: token parameter is required" }]
11
13
  end
12
14
 
15
+ storage = MCP::SlaveSupport.resolve_storage(params)
13
16
  profile = if token == "latest"
14
- profiles = Profiler.storage.list(limit: 200)
17
+ profiles = storage.list(limit: 200)
15
18
  profiles.find { |p| p.profile_type == "test" }
16
19
  else
17
- Profiler.storage.load(token)
20
+ storage.load(token)
18
21
  end
19
22
 
20
23
  unless profile && profile.profile_type == "test"
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
6
8
  class ListEnvVars
7
9
  def self.call(params)
10
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
11
+ data = proxy.get_json("/_profiler/api/env_vars",
12
+ include_all: params["include_all"], filter: params["filter"])
13
+ text = format_slave_env_vars(data, params)
14
+ return [{ type: "text", text: text }]
15
+ end
16
+
8
17
  include_all = params["include_all"]
9
18
  filter = params["filter"]&.downcase
10
19
 
@@ -51,6 +60,35 @@ module Profiler
51
60
 
52
61
  [{ type: "text", text: text }]
53
62
  end
63
+
64
+ private
65
+
66
+ def self.format_slave_env_vars(data, params)
67
+ overrides = data["overrides"] || {}
68
+ filter = params["filter"]&.downcase
69
+
70
+ if params["include_all"]
71
+ vars = (data["variables"] || {}).sort.to_h
72
+ vars = vars.select { |k, _| k.downcase.include?(filter) } if filter
73
+ return "No environment variables match filter '#{params["filter"]}'." if vars.empty?
74
+
75
+ rows = vars.map do |key, value|
76
+ override = overrides[key]
77
+ overridden = override ? "✓ (was: #{override["original"] || "(unset)"})" : ""
78
+ "| #{key} | #{value} | #{overridden} |"
79
+ end
80
+ "**ENV variables (#{vars.size})**\n\n| Key | Current Value | Overridden |\n|-----|--------------|------------|\n" + rows.join("\n")
81
+ else
82
+ overrides = overrides.select { |k, _| k.downcase.include?(filter) } if filter
83
+ return "No overrides active." if overrides.empty?
84
+
85
+ rows = overrides.map do |key, entry|
86
+ current = entry["value"] == "__PROFILER_DELETED__" ? "(deleted)" : entry["value"]
87
+ "| #{key} | #{current} | #{entry["original"] || "(unset)"} |"
88
+ end
89
+ "**Active ENV overrides (#{overrides.size})**\n\n| Key | Current Value | Original Value |\n|-----|--------------|----------------|\n" + rows.join("\n")
90
+ end
91
+ end
54
92
  end
55
93
  end
56
94
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class ListSlaves
7
+ def self.call(_params)
8
+ slaves = Profiler.slave_registry.all
9
+
10
+ if slaves.empty?
11
+ return [{ type: "text", text: "No slave profilers connected." }]
12
+ end
13
+
14
+ lines = ["# Connected Slave Profilers\n"]
15
+ lines << "| Name | URL | Status | Registered At | Last Heartbeat |"
16
+ lines << "|------|-----|--------|--------------|----------------|"
17
+
18
+ slaves.each do |s|
19
+ lines << "| #{s[:name]} | #{s[:url]} | #{s[:status]} | #{s[:registered_at]} | #{s[:last_heartbeat_at]} |"
20
+ end
21
+
22
+ [{ type: "text", text: lines.join("\n") }]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -9,7 +11,8 @@ module Profiler
9
11
  def self.call(params)
10
12
  limit = params["limit"]&.to_i || 20
11
13
  fetch_size = [limit * 5, 500].min
12
- profiles = Profiler.storage.list(limit: fetch_size)
14
+ storage = MCP::SlaveSupport.resolve_storage(params)
15
+ profiles = storage.list(limit: fetch_size)
13
16
 
14
17
  consoles = profiles.select { |p| p.profile_type == "console" }
15
18
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -9,7 +11,8 @@ module Profiler
9
11
  def self.call(params)
10
12
  limit = params["limit"]&.to_i || 20
11
13
  fetch_size = [limit * 5, 500].min
12
- profiles = Profiler.storage.list(limit: fetch_size)
14
+ storage = MCP::SlaveSupport.resolve_storage(params)
15
+ profiles = storage.list(limit: fetch_size)
13
16
 
14
17
  jobs = profiles.select { |p| p.profile_type == "job" }
15
18
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -10,7 +12,8 @@ module Profiler
10
12
  def call(params)
11
13
  limit = params["limit"]&.to_i || 20
12
14
  fetch_size = [limit * 10, 1000].min
13
- profiles = Profiler.storage.list(limit: fetch_size)
15
+ storage = MCP::SlaveSupport.resolve_storage(params)
16
+ profiles = storage.list(limit: fetch_size)
14
17
 
15
18
  emails = []
16
19
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -7,9 +9,10 @@ module Profiler
7
9
  ALL_FIELDS = %w[time type method path duration queries status token].freeze
8
10
 
9
11
  def self.call(params)
12
+ storage = MCP::SlaveSupport.resolve_storage(params)
10
13
  limit = params["limit"]&.to_i || 20
11
14
  fetch_size = [limit * 5, 500].min
12
- profiles = Profiler.storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size)
13
16
 
14
17
  profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
15
18
  profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -9,7 +11,8 @@ module Profiler
9
11
  def self.call(params)
10
12
  limit = params["limit"]&.to_i || 20
11
13
  fetch_size = [limit * 5, 500].min
12
- profiles = Profiler.storage.list(limit: fetch_size)
14
+ storage = MCP::SlaveSupport.resolve_storage(params)
15
+ profiles = storage.list(limit: fetch_size)
13
16
 
14
17
  tests = profiles.select { |p| p.profile_type == "test" }
15
18
 
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
6
8
  class ResetAllEnvVars
7
- def self.call(_params)
9
+ def self.call(params)
10
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
11
+ proxy.delete_json("/_profiler/api/env_vars/reset_all")
12
+ return [{ type: "text", text: "Reset all ENV overrides on slave '#{params["slave"]}'." }]
13
+ end
14
+
8
15
  overrides = Profiler.env_override_store.all_overrides
9
16
  count = overrides.size
10
17
 
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
5
+ require "uri"
6
+
3
7
  module Profiler
4
8
  module MCP
5
9
  module Tools
@@ -9,6 +13,11 @@ module Profiler
9
13
 
10
14
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
11
15
 
16
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
17
+ proxy.delete_json("/_profiler/api/env_vars/reset?key=#{URI.encode_www_form_component(key)}")
18
+ return [{ type: "text", text: "Reset #{key} on slave '#{params["slave"]}'." }]
19
+ end
20
+
12
21
  overrides = Profiler.env_override_store.all_overrides
13
22
  unless overrides.key?(key)
14
23
  return [{ type: "text", text: "No active override for #{key}." }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  require_relative "../../test_runner/discovery"
4
6
  require_relative "../../test_runner/run_store"
5
7
  require_relative "../../test_runner/runner"
@@ -13,6 +15,10 @@ module Profiler
13
15
  POLL_TIMEOUT = 10 # seconds per wait_for_output call
14
16
 
15
17
  def self.call(params)
18
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
19
+ return run_on_slave(proxy, params)
20
+ end
21
+
16
22
  files = Array(params["files"])
17
23
  framework = params["framework"]&.to_s
18
24
  timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
@@ -65,6 +71,41 @@ module Profiler
65
71
 
66
72
  private
67
73
 
74
+ def self.run_on_slave(proxy, params)
75
+ files = Array(params["files"])
76
+ framework = params["framework"].to_s
77
+ timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
78
+ max_output = (params["max_output"] || DEFAULT_MAX_OUTPUT).to_i
79
+
80
+ run_data = proxy.post_json("/_profiler/api/test_runner/runs", { files: files, framework: framework.presence || "rspec" })
81
+
82
+ if run_data["error"]
83
+ return [{ type: "text", text: "Error starting tests on slave: #{run_data["error"]}" }]
84
+ end
85
+
86
+ run_id = run_data["id"]
87
+ deadline = Time.now + timeout_secs
88
+ timed_out = false
89
+
90
+ loop do
91
+ break if Time.now > deadline && (timed_out = true)
92
+ run_data = proxy.get_json("/_profiler/api/test_runner/runs/#{run_id}")
93
+ break if %w[completed failed cancelled].include?(run_data["status"])
94
+ sleep 2
95
+ end
96
+
97
+ output = run_data["output_lines"]&.join.to_s
98
+ output = "…(truncated)\n" + output[-(max_output)..] if output.length > max_output
99
+
100
+ text = "# Test Run on slave '#{params["slave"]}' #{timed_out ? "(timed out)" : ""}\n\n"
101
+ text += "| Field | Value |\n|-------|-------|\n"
102
+ text += "| Run ID | `#{run_id}` |\n"
103
+ text += "| Status | **#{run_data["status"]}** |\n"
104
+ text += "| Exit code | #{run_data["exit_code"].inspect} |\n\n"
105
+ text += "## Output\n```\n#{output.strip}\n```"
106
+ [{ type: "text", text: text }]
107
+ end
108
+
68
109
  def self.collect_run_profiles(since)
69
110
  Profiler.storage.list(limit: 500).select do |p|
70
111
  p.profile_type == "test" && p.started_at && p.started_at >= since
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -10,6 +12,11 @@ module Profiler
10
12
 
11
13
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
12
14
 
15
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
16
+ proxy.patch_json("/_profiler/api/env_vars", { key: key, value: value })
17
+ return [{ type: "text", text: "Set #{key}=#{value} on slave '#{params["slave"]}'" }]
18
+ end
19
+
13
20
  Profiler.env_override_store.set(key, value)
14
21
  ENV[key] = value
15
22
 
@@ -93,6 +93,17 @@ module Profiler
93
93
  end
94
94
  end
95
95
 
96
+ initializer "profiler.start_cluster_client" do
97
+ # Check slave? inside on_load — the app's own initializers (config/initializers/profiler.rb)
98
+ # set master_url AFTER railtie initializers run, so the check must happen after_initialize.
99
+ ActiveSupport.on_load(:after_initialize) do
100
+ next unless Profiler.configuration.enabled && Profiler.configuration.slave?
101
+
102
+ require_relative "cluster/master_client"
103
+ Profiler::Cluster::MasterClient.new.start
104
+ end
105
+ end
106
+
96
107
  console do
97
108
  next unless Profiler.configuration.enabled && Profiler.configuration.track_console
98
109
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module SSE
5
+ def self.current
6
+ if defined?(Profiler::Storage::RedisStore) && Profiler.storage.is_a?(Profiler::Storage::RedisStore)
7
+ RedisEventBus.instance
8
+ else
9
+ EventBus.instance
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "timeout"
6
+ require "set"
7
+ require "concurrent"
8
+
9
+ module Profiler
10
+ module SSE
11
+ class EventBus
12
+ include Singleton
13
+
14
+ def initialize
15
+ @subscriptions = Concurrent::Hash.new
16
+ end
17
+
18
+ def subscribe(token, collectors)
19
+ id = SecureRandom.uuid
20
+ @subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
21
+ id
22
+ end
23
+
24
+ def unsubscribe(id)
25
+ @subscriptions.delete(id)
26
+ end
27
+
28
+ def broadcast(token, collectors)
29
+ changed = Set.new(collectors.map(&:to_s))
30
+ @subscriptions.each_value do |sub|
31
+ next unless sub[:token] == token
32
+ # Empty collector set means "match all"; non-empty set filters by intersection.
33
+ next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
34
+ sub[:queue] << { token: token, collectors: changed.to_a, timestamp: Time.now.to_f }
35
+ end
36
+ end
37
+
38
+ def wait_for_event(id, timeout: 30)
39
+ sub = @subscriptions[id]
40
+ return nil unless sub
41
+ Timeout.timeout(timeout) { sub[:queue].pop }
42
+ rescue Timeout::Error
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "timeout"
6
+ require "set"
7
+ require "json"
8
+ require "concurrent"
9
+
10
+ module Profiler
11
+ module SSE
12
+ class RedisEventBus
13
+ include Singleton
14
+
15
+ CHANNEL_PREFIX = "profiler:events"
16
+
17
+ def initialize
18
+ @subscriptions = Concurrent::Hash.new
19
+ @listener_thread = nil
20
+ @listener_mutex = Mutex.new
21
+ end
22
+
23
+ def subscribe(token, collectors)
24
+ id = SecureRandom.uuid
25
+ @subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
26
+ ensure_listener_running
27
+ id
28
+ end
29
+
30
+ def unsubscribe(id)
31
+ @subscriptions.delete(id)
32
+ end
33
+
34
+ def broadcast(token, collectors)
35
+ payload = { token: token, collectors: collectors.map(&:to_s), timestamp: Time.now.to_f }.to_json
36
+ publish_redis_client.publish("#{CHANNEL_PREFIX}:#{token}", payload)
37
+ end
38
+
39
+ def wait_for_event(id, timeout: 30)
40
+ sub = @subscriptions[id]
41
+ return nil unless sub
42
+ Timeout.timeout(timeout) { sub[:queue].pop }
43
+ rescue Timeout::Error
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def publish_redis_client
50
+ Profiler.storage.redis
51
+ end
52
+
53
+ def ensure_listener_running
54
+ @listener_mutex.synchronize do
55
+ return if @listener_thread&.alive?
56
+
57
+ @listener_thread = Thread.new do
58
+ # Use a dedicated connection for blocking psubscribe
59
+ Profiler.storage.redis.dup.psubscribe("#{CHANNEL_PREFIX}:*") do |on|
60
+ on.pmessage do |_pattern, _channel, message|
61
+ payload = JSON.parse(message, symbolize_names: true)
62
+ token = payload[:token]
63
+ changed = Set.new(payload[:collectors].map(&:to_s))
64
+
65
+ @subscriptions.each_value do |sub|
66
+ next unless sub[:token] == token
67
+ next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
68
+ sub[:queue] << { token: token, collectors: changed.to_a, timestamp: payload[:timestamp] }
69
+ end
70
+ end
71
+ end
72
+ rescue StandardError
73
+ # Thread restarts on the next subscribe call
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -3,8 +3,16 @@
3
3
  module Profiler
4
4
  module Storage
5
5
  class BaseStore
6
+ # Public interface: persists the profile then fires an SSE broadcast.
7
+ # Subclasses implement #do_save, not #save.
6
8
  def save(token, profile)
7
- raise NotImplementedError, "#{self.class} must implement #save"
9
+ result = do_save(token, profile)
10
+ broadcast_event(token, profile)
11
+ result
12
+ end
13
+
14
+ def do_save(token, profile)
15
+ raise NotImplementedError, "#{self.class} must implement #do_save"
8
16
  end
9
17
 
10
18
  def load(token)
@@ -36,6 +44,15 @@ module Profiler
36
44
  def clear(type: nil)
37
45
  raise NotImplementedError, "#{self.class} must implement #clear"
38
46
  end
47
+
48
+ private
49
+
50
+ def broadcast_event(token, profile)
51
+ collectors = profile.collectors_data.keys
52
+ Profiler::SSE.current.broadcast(token, collectors)
53
+ rescue StandardError
54
+ # Never let a broadcast failure prevent profile persistence
55
+ end
39
56
  end
40
57
  end
41
58
  end
@@ -17,7 +17,7 @@ module Profiler
17
17
  ensure_directory_exists
18
18
  end
19
19
 
20
- def save(token, profile)
20
+ def do_save(token, profile)
21
21
  file_path = profile_file_path(token)
22
22
  File.write(file_path, profile.to_json)
23
23
  cleanup_if_needed
@@ -12,7 +12,7 @@ module Profiler
12
12
  @max_profiles = options[:max_profiles] || Profiler.configuration.max_profiles || 100
13
13
  end
14
14
 
15
- def save(token, profile)
15
+ def do_save(token, profile)
16
16
  cleanup_if_needed
17
17
  @profiles[token] = serialize_profile(profile)
18
18
  token
@@ -10,13 +10,15 @@ module Profiler
10
10
  class RedisStore < BaseStore
11
11
  DEFAULT_TTL = 24 * 60 * 60 # 24 hours
12
12
 
13
+ attr_reader :redis
14
+
13
15
  def initialize(options = {})
14
16
  @redis = options[:redis] || build_redis_client(options)
15
17
  @ttl = options[:ttl] || DEFAULT_TTL
16
18
  @key_prefix = options[:key_prefix] || "profiler"
17
19
  end
18
20
 
19
- def save(token, profile)
21
+ def do_save(token, profile)
20
22
  key = profile_key(token)
21
23
  @redis.setex(key, @ttl, profile.to_json)
22
24
 
@@ -28,7 +28,7 @@ module Profiler
28
28
  migrate!
29
29
  end
30
30
 
31
- def save(token, profile)
31
+ def do_save(token, profile)
32
32
  data = profile.to_h
33
33
 
34
34
  collectors_meta = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.28.0"
4
+ VERSION = "0.30.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -29,6 +29,13 @@ module Profiler
29
29
  @env_override_store ||= EnvOverrideStore.new
30
30
  end
31
31
 
32
+ def slave_registry
33
+ @slave_registry ||= begin
34
+ require_relative "profiler/cluster/slave_registry"
35
+ Cluster::SlaveRegistry.new
36
+ end
37
+ end
38
+
32
39
  def enabled?
33
40
  configuration.enabled
34
41
  end
@@ -107,5 +114,8 @@ require_relative "profiler/collectors/mailer_collector"
107
114
 
108
115
  require_relative "profiler/env_override_store"
109
116
  require_relative "profiler/instrumentation/thread_context_propagation"
117
+ require_relative "profiler/sse/event_bus"
118
+ require_relative "profiler/sse/redis_event_bus"
119
+ require_relative "profiler/sse/bus"
110
120
  require_relative "profiler/railtie" if defined?(Rails::Railtie)
111
121
  require_relative "profiler/engine" if defined?(Rails::Engine)