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,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
|