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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Models
5
+ class SqlQuery
6
+ attr_reader :sql, :duration, :binds, :name, :connection, :backtrace
7
+
8
+ def initialize(sql:, duration:, binds: [], name: nil, connection: nil, backtrace: [])
9
+ @sql = sql
10
+ @duration = duration
11
+ @binds = binds
12
+ @name = name
13
+ @connection = connection
14
+ @backtrace = backtrace
15
+ end
16
+
17
+ def slow?(threshold = 100)
18
+ @duration > threshold
19
+ end
20
+
21
+ def cached?
22
+ @name == "CACHE"
23
+ end
24
+
25
+ def transaction?
26
+ @sql =~ /^(BEGIN|COMMIT|ROLLBACK)/i
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ sql: @sql,
32
+ duration: @duration,
33
+ binds: @binds,
34
+ name: @name,
35
+ connection: @connection&.class&.name,
36
+ backtrace: @backtrace.map(&:to_s),
37
+ slow: slow?,
38
+ cached: cached?,
39
+ transaction: transaction?
40
+ }
41
+ end
42
+
43
+ def to_json(*args)
44
+ to_h.to_json(*args)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Models
5
+ class TimelineEvent
6
+ attr_reader :name, :started_at, :finished_at, :duration, :payload, :children, :category
7
+
8
+ def initialize(name:, started_at:, finished_at:, payload: {}, category: nil)
9
+ @name = name
10
+ @started_at = started_at
11
+ @finished_at = finished_at
12
+ @duration = ((finished_at - started_at) * 1000).round(2) # milliseconds
13
+ @payload = payload
14
+ @category = category
15
+ @children = []
16
+ end
17
+
18
+ def add_child(event)
19
+ @children << event
20
+ end
21
+
22
+ def to_h
23
+ h = {
24
+ name: @name,
25
+ started_at: @started_at,
26
+ finished_at: @finished_at,
27
+ duration: @duration,
28
+ payload: @payload,
29
+ children: @children.map(&:to_h)
30
+ }
31
+ h[:category] = @category if @category
32
+ h
33
+ end
34
+
35
+ def to_json(*args)
36
+ to_h.to_json(*args)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Profiler
6
+ class Railtie < Rails::Railtie
7
+ config.profiler = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "profiler.set_configs" do |app|
10
+ # Set default configuration for Rails environment
11
+ Profiler.configure do |config|
12
+ config.enabled = Rails.env.development? || Rails.env.test?
13
+ config.storage = Rails.env.development? ? :file : :memory
14
+ config.storage_options = {
15
+ path: Rails.root.join("tmp", "profiler")
16
+ }
17
+ end
18
+ end
19
+
20
+ initializer "profiler.insert_middleware", before: :build_middleware_stack do |app|
21
+ if Profiler.configuration.enabled
22
+ require_relative "middleware/profiler_middleware"
23
+
24
+ # Insert CORS middleware first if enabled
25
+ if Profiler.configuration.extension_cors_enabled
26
+ require_relative "middleware/cors_middleware"
27
+ app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
28
+ end
29
+
30
+ app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
31
+ end
32
+ end
33
+
34
+ initializer "profiler.load_collectors" do
35
+ if Profiler.configuration.collectors.empty?
36
+ Profiler.configure do |config|
37
+ config.collectors = [
38
+ Profiler::Collectors::RequestCollector,
39
+ Profiler::Collectors::DumpCollector,
40
+ Profiler::Collectors::DatabaseCollector,
41
+ Profiler::Collectors::PerformanceCollector,
42
+ Profiler::Collectors::ViewCollector,
43
+ Profiler::Collectors::CacheCollector,
44
+ Profiler::Collectors::HttpCollector,
45
+ Profiler::Collectors::FlameGraphCollector
46
+ ]
47
+ end
48
+ end
49
+ end
50
+
51
+ initializer "profiler.setup_job_instrumentation" do
52
+ next unless Profiler.configuration.enabled && Profiler.configuration.track_jobs
53
+
54
+ require_relative "job_profiler"
55
+
56
+ if defined?(Sidekiq)
57
+ require_relative "instrumentation/sidekiq_middleware"
58
+ Sidekiq.configure_server do |config|
59
+ config.server_middleware do |chain|
60
+ chain.add Profiler::Instrumentation::SidekiqMiddleware
61
+ end
62
+ end
63
+ end
64
+
65
+ if defined?(ActiveJob::Base)
66
+ require_relative "instrumentation/active_job_instrumentation"
67
+ ActiveJob::Base.include Profiler::Instrumentation::ActiveJobInstrumentation
68
+ end
69
+ end
70
+
71
+ rake_tasks do
72
+ load "profiler/tasks/profiler.rake"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Storage
5
+ class BaseStore
6
+ def save(token, profile)
7
+ raise NotImplementedError, "#{self.class} must implement #save"
8
+ end
9
+
10
+ def load(token)
11
+ raise NotImplementedError, "#{self.class} must implement #load"
12
+ end
13
+
14
+ def list(limit: 50, offset: 0)
15
+ raise NotImplementedError, "#{self.class} must implement #list"
16
+ end
17
+
18
+ def cleanup(older_than: 24 * 60 * 60)
19
+ raise NotImplementedError, "#{self.class} must implement #cleanup"
20
+ end
21
+
22
+ def exists?(token)
23
+ !load(token).nil?
24
+ rescue
25
+ false
26
+ end
27
+
28
+ def find_by_parent(parent_token)
29
+ raise NotImplementedError, "#{self.class} must implement #find_by_parent"
30
+ end
31
+
32
+ def delete(token)
33
+ raise NotImplementedError, "#{self.class} must implement #delete"
34
+ end
35
+
36
+ def clear(type: nil)
37
+ raise NotImplementedError, "#{self.class} must implement #clear"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Profiler
7
+ module Storage
8
+ class BlobStore
9
+ def initialize(path)
10
+ @path = path
11
+ FileUtils.mkdir_p(@path)
12
+ end
13
+
14
+ def write(token, collector_name, data)
15
+ dir = token_dir(token)
16
+ FileUtils.mkdir_p(dir)
17
+ File.write(File.join(dir, "#{collector_name}.json"), JSON.generate(data))
18
+ end
19
+
20
+ def read(token, collector_name)
21
+ file = File.join(token_dir(token), "#{collector_name}.json")
22
+ return nil unless File.exist?(file)
23
+
24
+ JSON.parse(File.read(file))
25
+ rescue => e
26
+ warn "BlobStore: failed to read #{token}/#{collector_name}: #{e.message}"
27
+ nil
28
+ end
29
+
30
+ def delete(token)
31
+ dir = token_dir(token)
32
+ FileUtils.rm_rf(dir) if File.directory?(dir)
33
+ end
34
+
35
+ def exists?(token, collector_name)
36
+ File.exist?(File.join(token_dir(token), "#{collector_name}.json"))
37
+ end
38
+
39
+ private
40
+
41
+ def token_dir(token)
42
+ File.join(@path, token)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require_relative "base_store"
6
+ require_relative "../models/profile"
7
+
8
+ module Profiler
9
+ module Storage
10
+ class FileStore < BaseStore
11
+ def initialize(options = {})
12
+ @path = options[:path] || default_path
13
+ @max_size = options[:max_size] || 100 * 1024 * 1024 # 100 MB
14
+ ensure_directory_exists
15
+ end
16
+
17
+ def save(token, profile)
18
+ file_path = profile_file_path(token)
19
+ File.write(file_path, profile.to_json)
20
+ cleanup_if_needed
21
+ token
22
+ end
23
+
24
+ def load(token)
25
+ file_path = profile_file_path(token)
26
+ return nil unless File.exist?(file_path)
27
+
28
+ json_data = File.read(file_path)
29
+ Models::Profile.from_json(json_data)
30
+ rescue => e
31
+ warn "Failed to load profile #{token}: #{e.message}"
32
+ nil
33
+ end
34
+
35
+ def list(limit: 50, offset: 0)
36
+ profile_files
37
+ .sort_by { |f| File.mtime(f) }
38
+ .reverse
39
+ .drop(offset)
40
+ .take(limit)
41
+ .map { |f| load(File.basename(f, ".json")) }
42
+ .compact
43
+ end
44
+
45
+ def cleanup(older_than: 24 * 60 * 60)
46
+ cutoff_time = Time.now - older_than
47
+ profile_files.each do |file|
48
+ File.delete(file) if File.mtime(file) < cutoff_time
49
+ rescue => e
50
+ warn "Failed to delete profile file #{file}: #{e.message}"
51
+ end
52
+ end
53
+
54
+ def find_by_parent(parent_token)
55
+ profile_files
56
+ .map { |f| load(File.basename(f, ".json")) }
57
+ .compact
58
+ .select { |profile| profile.parent_token == parent_token }
59
+ .sort_by { |profile| profile.started_at }
60
+ end
61
+
62
+ def delete(token)
63
+ file_path = profile_file_path(token)
64
+ File.delete(file_path) if File.exist?(file_path)
65
+ end
66
+
67
+ def clear(type: nil)
68
+ if type.nil?
69
+ profile_files.each { |f| File.delete(f) rescue nil }
70
+ else
71
+ profile_files.each do |f|
72
+ profile = load(File.basename(f, ".json"))
73
+ File.delete(f) if profile&.profile_type == type.to_s
74
+ rescue
75
+ nil
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def default_path
83
+ if defined?(Rails)
84
+ Rails.root.join("tmp", "profiler")
85
+ else
86
+ File.expand_path("tmp/profiler", Dir.pwd)
87
+ end
88
+ end
89
+
90
+ def ensure_directory_exists
91
+ FileUtils.mkdir_p(@path) unless File.directory?(@path)
92
+ end
93
+
94
+ def profile_file_path(token)
95
+ File.join(@path, "#{token}.json")
96
+ end
97
+
98
+ def profile_files
99
+ Dir.glob(File.join(@path, "*.json"))
100
+ end
101
+
102
+ def cleanup_if_needed
103
+ total_size = profile_files.sum { |f| File.size(f) }
104
+ return if total_size < @max_size
105
+
106
+ # Delete oldest files until we're under the limit
107
+ profile_files
108
+ .sort_by { |f| File.mtime(f) }
109
+ .each do |file|
110
+ File.delete(file)
111
+ total_size -= File.size(file)
112
+ break if total_size < @max_size * 0.8
113
+ rescue => e
114
+ warn "Failed to delete profile file #{file}: #{e.message}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require_relative "base_store"
5
+ require_relative "../models/profile"
6
+
7
+ module Profiler
8
+ module Storage
9
+ class MemoryStore < BaseStore
10
+ def initialize(options = {})
11
+ @profiles = Concurrent::Hash.new
12
+ @max_profiles = options[:max_profiles] || Profiler.configuration.max_profiles || 100
13
+ end
14
+
15
+ def save(token, profile)
16
+ cleanup_if_needed
17
+ @profiles[token] = serialize_profile(profile)
18
+ token
19
+ end
20
+
21
+ def load(token)
22
+ data = @profiles[token]
23
+ return nil unless data
24
+
25
+ deserialize_profile(data)
26
+ end
27
+
28
+ def list(limit: 50, offset: 0)
29
+ @profiles.values
30
+ .map { |data| deserialize_profile(data) }
31
+ .sort_by { |p| p.started_at }
32
+ .reverse
33
+ .drop(offset)
34
+ .take(limit)
35
+ end
36
+
37
+ def cleanup(older_than: 24 * 60 * 60)
38
+ cutoff_time = Time.now - older_than
39
+ @profiles.delete_if do |_token, data|
40
+ profile = deserialize_profile(data)
41
+ profile.started_at < cutoff_time
42
+ end
43
+ end
44
+
45
+ def find_by_parent(parent_token)
46
+ @profiles.values
47
+ .map { |data| deserialize_profile(data) }
48
+ .select { |profile| profile.parent_token == parent_token }
49
+ .sort_by { |profile| profile.started_at }
50
+ end
51
+
52
+ def delete(token)
53
+ @profiles.delete(token)
54
+ end
55
+
56
+ def clear(type: nil)
57
+ if type.nil?
58
+ @profiles.clear
59
+ else
60
+ @profiles.delete_if do |_token, data|
61
+ deserialize_profile(data).profile_type == type.to_s
62
+ end
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def cleanup_if_needed
69
+ return if @profiles.size < @max_profiles
70
+
71
+ # Remove oldest profiles until we're under the limit
72
+ profiles_to_remove = @profiles.size - (@max_profiles * 0.8).to_i
73
+ return if profiles_to_remove <= 0
74
+
75
+ sorted_tokens = @profiles.map do |token, data|
76
+ profile = deserialize_profile(data)
77
+ [token, profile.started_at]
78
+ end.sort_by { |_, time| time }.map(&:first)
79
+
80
+ sorted_tokens.take(profiles_to_remove).each do |token|
81
+ @profiles.delete(token)
82
+ end
83
+ end
84
+
85
+ def serialize_profile(profile)
86
+ profile.to_h
87
+ end
88
+
89
+ def deserialize_profile(data)
90
+ Models::Profile.from_hash(data)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "json"
5
+ require_relative "base_store"
6
+ require_relative "../models/profile"
7
+
8
+ module Profiler
9
+ module Storage
10
+ class RedisStore < BaseStore
11
+ DEFAULT_TTL = 24 * 60 * 60 # 24 hours
12
+
13
+ def initialize(options = {})
14
+ @redis = options[:redis] || build_redis_client(options)
15
+ @ttl = options[:ttl] || DEFAULT_TTL
16
+ @key_prefix = options[:key_prefix] || "profiler"
17
+ end
18
+
19
+ def save(token, profile)
20
+ key = profile_key(token)
21
+ @redis.setex(key, @ttl, profile.to_json)
22
+
23
+ # Add to sorted set for listing
24
+ @redis.zadd(list_key, profile.started_at.to_f, token)
25
+
26
+ token
27
+ end
28
+
29
+ def load(token)
30
+ key = profile_key(token)
31
+ json_data = @redis.get(key)
32
+ return nil unless json_data
33
+
34
+ Models::Profile.from_json(json_data)
35
+ rescue => e
36
+ warn "Failed to load profile #{token}: #{e.message}"
37
+ nil
38
+ end
39
+
40
+ def list(limit: 50, offset: 0)
41
+ tokens = @redis.zrevrange(list_key, offset, offset + limit - 1)
42
+ tokens.map { |token| load(token) }.compact
43
+ end
44
+
45
+ def cleanup(older_than: 24 * 60 * 60)
46
+ cutoff_time = Time.now.to_f - older_than
47
+ @redis.zremrangebyscore(list_key, "-inf", cutoff_time)
48
+ end
49
+
50
+ def find_by_parent(parent_token)
51
+ # Get all tokens from the sorted set
52
+ tokens = @redis.zrange(list_key, 0, -1)
53
+
54
+ # Load each profile and filter by parent_token
55
+ tokens.map { |token| load(token) }
56
+ .compact
57
+ .select { |profile| profile.parent_token == parent_token }
58
+ .sort_by { |profile| profile.started_at }
59
+ end
60
+
61
+ def delete(token)
62
+ @redis.del(profile_key(token))
63
+ @redis.zrem(list_key, token)
64
+ end
65
+
66
+ def clear(type: nil)
67
+ tokens = @redis.zrange(list_key, 0, -1)
68
+ tokens.each do |token|
69
+ if type.nil?
70
+ @redis.del(profile_key(token))
71
+ @redis.zrem(list_key, token)
72
+ else
73
+ profile = load(token)
74
+ if profile&.profile_type == type.to_s
75
+ @redis.del(profile_key(token))
76
+ @redis.zrem(list_key, token)
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def build_redis_client(options)
85
+ url = options[:url] || ENV["REDIS_URL"] || "redis://localhost:6379/0"
86
+ Redis.new(url: url)
87
+ end
88
+
89
+ def profile_key(token)
90
+ "#{@key_prefix}:#{token}"
91
+ end
92
+
93
+ def list_key
94
+ "#{@key_prefix}:list"
95
+ end
96
+ end
97
+ end
98
+ end