solid_observer 0.3.0 → 0.5.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +195 -82
  4. data/app/assets/javascripts/solid_observer/live_poll.js +3 -1
  5. data/app/controllers/solid_observer/application_controller.rb +1 -0
  6. data/app/controllers/solid_observer/cable_dashboard_controller.rb +52 -0
  7. data/app/controllers/solid_observer/cable_operations_controller.rb +16 -0
  8. data/app/controllers/solid_observer/cache_dashboard_controller.rb +52 -0
  9. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  10. data/app/controllers/solid_observer/dashboard_controller.rb +38 -1
  11. data/app/controllers/solid_observer/storages_controller.rb +1 -1
  12. data/app/helpers/solid_observer/application_helper.rb +268 -5
  13. data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
  14. data/app/models/solid_observer/cable_event.rb +13 -0
  15. data/app/models/solid_observer/cable_metric.rb +12 -0
  16. data/app/models/solid_observer/cache_event.rb +15 -0
  17. data/app/models/solid_observer/cache_metric.rb +13 -0
  18. data/app/models/solid_observer/storage_info.rb +4 -1
  19. data/app/views/layouts/solid_observer/application.html.erb +157 -19
  20. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  21. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +34 -0
  22. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  23. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  24. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  25. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  26. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  27. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  28. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  29. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  30. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  31. data/app/views/solid_observer/dashboard/index.html.erb +32 -5
  32. data/app/views/solid_observer/events/index.html.erb +1 -0
  33. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  34. data/app/views/solid_observer/jobs/show.html.erb +3 -3
  35. data/app/views/solid_observer/storages/show.html.erb +90 -32
  36. data/config/routes.rb +7 -0
  37. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  38. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  39. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  40. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  41. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  42. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  43. data/lib/generators/solid_observer/install_generator.rb +8 -1
  44. data/lib/generators/solid_observer/templates/initializer.rb.tt +20 -4
  45. data/lib/solid_observer/base_event.rb +1 -1
  46. data/lib/solid_observer/base_metric.rb +1 -1
  47. data/lib/solid_observer/base_record.rb +8 -0
  48. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  49. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  50. data/lib/solid_observer/cable_subscriber.rb +57 -0
  51. data/lib/solid_observer/cache_event_buffer.rb +28 -0
  52. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  53. data/lib/solid_observer/cache_subscriber.rb +47 -0
  54. data/lib/solid_observer/chart_buffer.rb +84 -27
  55. data/lib/solid_observer/cli/storage.rb +16 -13
  56. data/lib/solid_observer/configuration.rb +67 -5
  57. data/lib/solid_observer/engine.rb +70 -15
  58. data/lib/solid_observer/event_buffer_core.rb +218 -0
  59. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  60. data/lib/solid_observer/services/cable_operations.rb +74 -0
  61. data/lib/solid_observer/services/cable_stats.rb +385 -0
  62. data/lib/solid_observer/services/cache_operations.rb +115 -0
  63. data/lib/solid_observer/services/cache_stats.rb +346 -0
  64. data/lib/solid_observer/services/cleanup_storage.rb +98 -47
  65. data/lib/solid_observer/services/database_size.rb +13 -8
  66. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  67. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  68. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  69. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  70. data/lib/solid_observer/services/record_cable_event.rb +114 -0
  71. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  72. data/lib/solid_observer/services/record_cache_event.rb +165 -0
  73. data/lib/solid_observer/services/record_cache_metric.rb +66 -0
  74. data/lib/solid_observer/services/storage_info_snapshot.rb +216 -0
  75. data/lib/solid_observer/version.rb +1 -1
  76. data/lib/solid_observer.rb +36 -11
  77. data/lib/tasks/solid_observer.rake +111 -21
  78. metadata +47 -5
  79. data/bin/console +0 -11
  80. data/bin/quality_gate +0 -95
  81. data/bin/setup +0 -8
@@ -7,14 +7,15 @@ module SolidObserver
7
7
  # SQLite uses whole-database page accounting; PostgreSQL and MySQL/Trilogy
8
8
  # use table + index size from adapter-native system functions.
9
9
  class DatabaseSize
10
- TABLE_NAME = "solid_observer_queue_events"
10
+ DEFAULT_TABLE_NAME = "solid_observer_queue_events"
11
11
 
12
- def self.call(connection:)
13
- new(connection).call
12
+ def self.call(connection:, table_name: DEFAULT_TABLE_NAME)
13
+ new(connection, table_name: table_name).call
14
14
  end
15
15
 
16
- def initialize(connection)
16
+ def initialize(connection, table_name:)
17
17
  @connection = connection
18
+ @table_name = table_name
18
19
  end
19
20
 
20
21
  def call
@@ -26,7 +27,7 @@ module SolidObserver
26
27
 
27
28
  private
28
29
 
29
- attr_reader :connection
30
+ attr_reader :connection, :table_name
30
31
 
31
32
  def adapter_key
32
33
  case connection.adapter_name.to_s.downcase
@@ -52,16 +53,20 @@ module SolidObserver
52
53
  end
53
54
 
54
55
  def sqlite_size
55
- connection.query_value("SELECT pragma_page_count() * pragma_page_size()")&.to_i
56
+ page_count = connection.query_value("PRAGMA page_count")
57
+ page_size = connection.query_value("PRAGMA page_size")
58
+ return unless page_count && page_size
59
+
60
+ page_count.to_i * page_size.to_i
56
61
  end
57
62
 
58
63
  def postgresql_size
59
- quoted_table = connection.quote(TABLE_NAME)
64
+ quoted_table = connection.quote(table_name)
60
65
  connection.query_value("SELECT pg_total_relation_size(#{quoted_table})")&.to_i
61
66
  end
62
67
 
63
68
  def mysql_size
64
- quoted_table = connection.quote(TABLE_NAME)
69
+ quoted_table = connection.quote(table_name)
65
70
 
66
71
  connection.query_value(<<~SQL)&.to_i
67
72
  SELECT COALESCE(data_length + index_length, 0)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCableEventBuffer
6
+ BATCH_SIZE = 100
7
+
8
+ def self.call(events)
9
+ new(events).call
10
+ end
11
+
12
+ def initialize(events)
13
+ @events = events
14
+ end
15
+
16
+ def call
17
+ return 0 if @events.empty?
18
+
19
+ insert_all_events
20
+ @events.size
21
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
22
+ fallback_insert_count(error)
23
+ end
24
+
25
+ private
26
+
27
+ def insert_all_events
28
+ CableEvent.transaction { CableEvent.insert_all!(@events) }
29
+ end
30
+
31
+ def fallback_insert_count(error)
32
+ Rails.logger&.error("[SolidObserver] Cable bulk insert failed, retrying in batches: #{error.message}") if defined?(Rails)
33
+ @events.each_slice(BATCH_SIZE).sum { |batch| insert_batch(batch) }
34
+ end
35
+
36
+ def insert_batch(batch)
37
+ size = batch.size
38
+ CableEvent.insert_all(batch, returning: false)
39
+ size
40
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
41
+ handle_batch_insert_error(size, error)
42
+ end
43
+
44
+ def log_batch_warning(size, error)
45
+ Rails.logger&.warn("[SolidObserver] Failed to insert cable batch of #{size} events: #{error.message}") if defined?(Rails)
46
+ end
47
+
48
+ def handle_batch_insert_error(size, error)
49
+ log_batch_warning(size, error)
50
+ 0
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCableMetrics
6
+ def self.call(metrics)
7
+ new(metrics).call
8
+ end
9
+
10
+ def initialize(metrics)
11
+ @metrics = metrics
12
+ end
13
+
14
+ def call
15
+ return 0 if @metrics.empty?
16
+
17
+ flush_metrics
18
+ @metrics.size
19
+ rescue ActiveRecord::RecordNotUnique
20
+ retry
21
+ end
22
+
23
+ private
24
+
25
+ def flush_metrics
26
+ SolidObserver::CableMetric.transaction do
27
+ @metrics.each { |metric_data| increment_metric(metric_data) }
28
+ end
29
+ end
30
+
31
+ def increment_metric(metric_data)
32
+ period_start = metric_data.fetch(:period_start)
33
+ metric = SolidObserver::CableMetric.find_or_create_by!(period_start: period_start)
34
+
35
+ SolidObserver::CableMetric.where(id: metric.id).update_counters(update_values(metric_data))
36
+ end
37
+
38
+ def update_values(metric_data)
39
+ {
40
+ broadcasts_count: increment_expression(:broadcasts_count, metric_data),
41
+ transmissions_count: increment_expression(:transmissions_count, metric_data),
42
+ confirmations_count: increment_expression(:confirmations_count, metric_data),
43
+ rejections_count: increment_expression(:rejections_count, metric_data),
44
+ perform_actions_count: increment_expression(:perform_actions_count, metric_data),
45
+ errors_count: increment_expression(:errors_count, metric_data)
46
+ }
47
+ end
48
+
49
+ def increment_expression(column, metric_data)
50
+ metric_data.fetch(column, 0)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCacheEventBuffer
6
+ BATCH_SIZE = 100
7
+
8
+ def self.call(events)
9
+ new(events).call
10
+ end
11
+
12
+ def initialize(events)
13
+ @events = events
14
+ end
15
+
16
+ def call
17
+ return 0 if @events.empty?
18
+
19
+ insert_all_events
20
+ @events.size
21
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
22
+ fallback_insert_count(error)
23
+ end
24
+
25
+ private
26
+
27
+ def insert_all_events
28
+ CacheEvent.transaction { CacheEvent.insert_all!(@events) }
29
+ end
30
+
31
+ def fallback_insert_count(error)
32
+ Rails.logger&.error("[SolidObserver] Cache bulk insert failed, retrying in batches: #{error.message}") if defined?(Rails)
33
+ @events.each_slice(BATCH_SIZE).sum { |batch| insert_batch(batch) }
34
+ end
35
+
36
+ def insert_batch(batch)
37
+ size = batch.size
38
+ CacheEvent.insert_all(batch, returning: false)
39
+ size
40
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
41
+ handle_batch_insert_error(size, error)
42
+ end
43
+
44
+ def log_batch_warning(size, error)
45
+ Rails.logger&.warn("[SolidObserver] Failed to insert cache batch of #{size} events: #{error.message}") if defined?(Rails)
46
+ end
47
+
48
+ def handle_batch_insert_error(size, error)
49
+ log_batch_warning(size, error)
50
+ 0
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCacheMetrics
6
+ def self.call(metrics)
7
+ new(metrics).call
8
+ end
9
+
10
+ def initialize(metrics)
11
+ @metrics = metrics
12
+ end
13
+
14
+ def call
15
+ return 0 if @metrics.empty?
16
+
17
+ flush_metrics
18
+ @metrics.size
19
+ rescue ActiveRecord::RecordNotUnique
20
+ retry
21
+ end
22
+
23
+ private
24
+
25
+ def flush_metrics
26
+ SolidObserver::CacheMetric.transaction do
27
+ @metrics.each { |metric_data| increment_metric(metric_data) }
28
+ end
29
+ end
30
+
31
+ def increment_metric(metric_data)
32
+ event_type, period_start = metric_data.values_at(:event_type, :period_start)
33
+ metric = SolidObserver::CacheMetric.find_or_create_by!(
34
+ event_type: event_type,
35
+ period_start: period_start
36
+ )
37
+
38
+ SolidObserver::CacheMetric.where(id: metric.id).update_counters(update_values(metric_data))
39
+ end
40
+
41
+ def update_values(metric_data)
42
+ {
43
+ operations_count: increment_expression(:operations_count, metric_data),
44
+ hits_count: increment_expression(:hits_count, metric_data),
45
+ misses_count: increment_expression(:misses_count, metric_data),
46
+ errors_count: increment_expression(:errors_count, metric_data),
47
+ duration_total: increment_expression(:duration_total, metric_data)
48
+ }
49
+ end
50
+
51
+ def increment_expression(column, metric_data)
52
+ metric_data.fetch(column, 0)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCableEvent
8
+ def self.call(event:, buffer:)
9
+ new(event, buffer).call
10
+ end
11
+
12
+ def initialize(event, buffer)
13
+ @event = event
14
+ @buffer = buffer
15
+ end
16
+
17
+ def call
18
+ record_metric_and_event
19
+ rescue => error
20
+ raise error if error.is_a?(NameError)
21
+ Rails.logger&.warn("[SolidObserver] Cable event recording failed: #{error.message}") if defined?(Rails)
22
+ end
23
+
24
+ private
25
+
26
+ def record_metric_and_event
27
+ SolidObserver::Services::RecordCableMetric.call(event: @event)
28
+ return unless should_store_event?
29
+
30
+ @buffer.push(build_event_data)
31
+ end
32
+
33
+ def should_store_event?
34
+ case @event.name
35
+ when "broadcast.action_cable"
36
+ sampled? || errored?
37
+ when "transmit_subscription_rejection.action_cable"
38
+ true
39
+ else
40
+ false
41
+ end
42
+ end
43
+
44
+ def sampled?
45
+ rand <= SolidObserver.config.cable_sampling_rate
46
+ end
47
+
48
+ def errored?
49
+ !exception_data.compact.empty?
50
+ end
51
+
52
+ def build_event_data
53
+ {
54
+ event_type: @event.name.delete_suffix(".action_cable"),
55
+ channel_class: channel_class,
56
+ broadcasting_digest: broadcasting_digest,
57
+ duration: duration_in_seconds,
58
+ error_class: exception_data[:error_class],
59
+ error_message: exception_data[:error_message],
60
+ metadata: metadata.to_json,
61
+ recorded_at: Time.current
62
+ }
63
+ end
64
+
65
+ def broadcasting_digest
66
+ return nil unless @event.name == "broadcast.action_cable"
67
+
68
+ Digest::SHA256.hexdigest(payload[:broadcasting].to_s)
69
+ end
70
+
71
+ def channel_class
72
+ class_name = payload[:channel_class]
73
+ return class_name if class_name
74
+
75
+ channel = payload[:channel]
76
+ return nil unless channel
77
+
78
+ channel.class.name || channel.to_s
79
+ end
80
+
81
+ def duration_in_seconds
82
+ @event.duration&./(1000.0)
83
+ end
84
+
85
+ def metadata
86
+ {
87
+ action: payload[:action]&.to_s,
88
+ via: payload[:via]&.to_s,
89
+ data_size: payload[:data]&.to_s&.bytesize,
90
+ message_size: payload[:message]&.to_s&.bytesize
91
+ }.compact
92
+ end
93
+
94
+ def exception_data
95
+ @exception_data ||= begin
96
+ exception_obj = payload[:exception_object]
97
+ exception = payload[:exception]
98
+
99
+ if exception_obj
100
+ {error_class: exception_obj.class.name, error_message: exception_obj.message}
101
+ elsif exception.is_a?(Array)
102
+ {error_class: exception.first, error_message: exception.last}
103
+ else
104
+ {}
105
+ end
106
+ end
107
+ end
108
+
109
+ def payload
110
+ @event.payload || {}
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cable_metric_buffer"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCableMetric
8
+ COUNTERS = %i[
9
+ broadcasts_count
10
+ transmissions_count
11
+ confirmations_count
12
+ rejections_count
13
+ perform_actions_count
14
+ errors_count
15
+ ].freeze
16
+
17
+ EVENT_COUNTER_MAP = {
18
+ "broadcast.action_cable" => :broadcasts_count,
19
+ "transmit.action_cable" => :transmissions_count,
20
+ "transmit_subscription_confirmation.action_cable" => :confirmations_count,
21
+ "transmit_subscription_rejection.action_cable" => :rejections_count,
22
+ "perform_action.action_cable" => :perform_actions_count
23
+ }.freeze
24
+
25
+ def self.call(event:, buffer: SolidObserver::CableMetricBuffer.instance)
26
+ new(event, buffer).call
27
+ end
28
+
29
+ def initialize(event, buffer)
30
+ @event = event
31
+ @buffer = buffer
32
+ end
33
+
34
+ def call
35
+ @buffer.increment(metric_data)
36
+ rescue => error
37
+ Rails.logger&.warn("[SolidObserver] Cable metric recording failed: #{error.message}") if defined?(Rails)
38
+ end
39
+
40
+ private
41
+
42
+ def metric_data
43
+ COUNTERS.each_with_object({period_start: period_start}) do |counter, data|
44
+ data[counter] = (counter == target_counter) ? 1 : 0
45
+ end.merge(errors_count: error_increment)
46
+ end
47
+
48
+ def target_counter
49
+ EVENT_COUNTER_MAP.fetch(event_name, :broadcasts_count)
50
+ end
51
+
52
+ def period_start
53
+ Time.current.beginning_of_minute
54
+ end
55
+
56
+ def event_name
57
+ @event.name
58
+ end
59
+
60
+ def error_increment
61
+ exception_present? ? 1 : 0
62
+ end
63
+
64
+ def exception_present?
65
+ payload[:exception_object].present? || payload[:exception].is_a?(Array)
66
+ end
67
+
68
+ def payload
69
+ @event.payload || {}
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCacheEvent
8
+ INTERNAL_CACHE_KEY_PREFIX = "solid_observer/"
9
+
10
+ def self.call(event:, buffer:)
11
+ new(event, buffer).call
12
+ end
13
+
14
+ def initialize(event, buffer)
15
+ @event = event
16
+ @buffer = buffer
17
+ end
18
+
19
+ def call
20
+ return if internal_cache_event?
21
+
22
+ record_metric_and_event
23
+ rescue => error
24
+ raise error if error.is_a?(NameError)
25
+ Rails.logger&.warn("[SolidObserver] Cache event recording failed: #{error.message}") if defined?(Rails)
26
+ end
27
+
28
+ private
29
+
30
+ def record_metric_and_event
31
+ SolidObserver::Services::RecordCacheMetric.call(event: @event)
32
+ return unless should_store_event?
33
+
34
+ @buffer.push(build_event_data)
35
+ end
36
+
37
+ def should_store_event?
38
+ sampled? || slow? || errored?
39
+ end
40
+
41
+ def sampled?
42
+ rand <= SolidObserver.config.cache_sampling_rate
43
+ end
44
+
45
+ def slow?
46
+ duration_in_seconds && duration_in_seconds >= SolidObserver.config.cache_slow_threshold
47
+ end
48
+
49
+ def errored?
50
+ return false unless SolidObserver.config.cache_store_errors
51
+
52
+ !exception_data.compact.empty?
53
+ end
54
+
55
+ def build_event_data
56
+ {
57
+ event_type: cache_operation,
58
+ key_digest: key_digest,
59
+ hit: hit_value,
60
+ duration: duration_in_seconds,
61
+ error_class: exception_data[:error_class],
62
+ error_message: exception_data[:error_message],
63
+ metadata: metadata.to_json,
64
+ recorded_at: Time.current
65
+ }
66
+ end
67
+
68
+ def cache_operation
69
+ @event.name.delete_suffix(".active_support")
70
+ end
71
+
72
+ def duration_in_seconds
73
+ @event.duration&./(1000.0)
74
+ end
75
+
76
+ def key_digest
77
+ Digest::SHA256.hexdigest(normalized_key_string)
78
+ end
79
+
80
+ def normalized_key_string
81
+ key = payload[:key]
82
+
83
+ case key
84
+ when Hash
85
+ key.keys.map(&:to_s).sort.join(",")
86
+ when Array
87
+ key.map(&:to_s).sort.join(",")
88
+ else
89
+ key.to_s
90
+ end
91
+ end
92
+
93
+ def hit_value
94
+ hit = payload[:hit]
95
+ hits = payload[:hits]
96
+
97
+ return hit unless hit.nil?
98
+ return nil unless hits.is_a?(Array)
99
+
100
+ hits.any?
101
+ end
102
+
103
+ def metadata
104
+ {
105
+ super_operation: payload[:super_operation]&.to_s,
106
+ key_size: key_size,
107
+ hits_count: hits_count
108
+ }.compact.merge(exception_data).compact
109
+ end
110
+
111
+ def key_size
112
+ key = payload[:key]
113
+ return key.keys.size if key.is_a?(Hash)
114
+ return key.size if key.is_a?(Array)
115
+
116
+ key.to_s.bytesize
117
+ end
118
+
119
+ def hits_count
120
+ hits = payload[:hits]
121
+ return nil unless hits.is_a?(Array)
122
+
123
+ hits.size
124
+ end
125
+
126
+ def exception_data
127
+ @exception_data ||= begin
128
+ exception_obj = payload[:exception_object]
129
+ exception = payload[:exception]
130
+
131
+ if exception_obj
132
+ {error_class: exception_obj.class.name, error_message: exception_obj.message}
133
+ elsif exception.is_a?(Array)
134
+ {error_class: exception.first, error_message: exception.last}
135
+ else
136
+ {}
137
+ end
138
+ end
139
+ end
140
+
141
+ def payload
142
+ @event.payload || {}
143
+ end
144
+
145
+ def internal_cache_event?
146
+ internal_cache_key?(payload[:key])
147
+ end
148
+
149
+ def internal_cache_key?(key)
150
+ case key
151
+ when Array
152
+ nested_internal_cache_key?(key)
153
+ when Hash
154
+ nested_internal_cache_key?(key.flatten(1))
155
+ else
156
+ key.to_s.start_with?(INTERNAL_CACHE_KEY_PREFIX)
157
+ end
158
+ end
159
+
160
+ def nested_internal_cache_key?(entries)
161
+ entries.any? { |entry| internal_cache_key?(entry) }
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache_metric_buffer"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCacheMetric
8
+ def self.call(event:, buffer: SolidObserver::CacheMetricBuffer.instance)
9
+ new(event, buffer).call
10
+ end
11
+
12
+ def initialize(event, buffer)
13
+ @event = event
14
+ @buffer = buffer
15
+ end
16
+
17
+ def call
18
+ @buffer.increment(
19
+ event_type: event_type,
20
+ period_start: period_start,
21
+ operations_count: 1,
22
+ hits_count: hit_increment,
23
+ misses_count: miss_increment,
24
+ errors_count: error_increment,
25
+ duration_total: duration_in_seconds
26
+ )
27
+ rescue => error
28
+ Rails.logger&.warn("[SolidObserver] Cache metric recording failed: #{error.message}") if defined?(Rails)
29
+ end
30
+
31
+ private
32
+
33
+ def period_start
34
+ Time.current.beginning_of_minute
35
+ end
36
+
37
+ def event_type
38
+ @event.name.delete_suffix(".active_support")
39
+ end
40
+
41
+ def hit_increment
42
+ (payload[:hit] == true) ? 1 : 0
43
+ end
44
+
45
+ def miss_increment
46
+ (payload[:hit] == false) ? 1 : 0
47
+ end
48
+
49
+ def error_increment
50
+ exception_present? ? 1 : 0
51
+ end
52
+
53
+ def exception_present?
54
+ payload[:exception_object].present? || payload[:exception].is_a?(Array)
55
+ end
56
+
57
+ def duration_in_seconds
58
+ (@event.duration || 0).to_f / 1000.0
59
+ end
60
+
61
+ def payload
62
+ @event.payload || {}
63
+ end
64
+ end
65
+ end
66
+ end