solid_observer 0.4.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +80 -20
  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 +33 -40
  9. data/app/controllers/solid_observer/dashboard_controller.rb +1 -7
  10. data/app/helpers/solid_observer/application_helper.rb +114 -0
  11. data/app/models/solid_observer/cable_event.rb +13 -0
  12. data/app/models/solid_observer/cable_metric.rb +12 -0
  13. data/app/models/solid_observer/cache_metric.rb +1 -2
  14. data/app/models/solid_observer/storage_info.rb +1 -1
  15. data/app/views/layouts/solid_observer/application.html.erb +19 -8
  16. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  17. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +34 -0
  18. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  19. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  20. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  21. data/app/views/solid_observer/dashboard/index.html.erb +2 -5
  22. data/app/views/solid_observer/events/index.html.erb +1 -0
  23. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  24. data/app/views/solid_observer/storages/show.html.erb +29 -3
  25. data/config/routes.rb +2 -0
  26. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  27. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  28. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  29. data/lib/generators/solid_observer/install_generator.rb +8 -1
  30. data/lib/generators/solid_observer/templates/initializer.rb.tt +18 -3
  31. data/lib/solid_observer/base_event.rb +1 -1
  32. data/lib/solid_observer/base_metric.rb +1 -1
  33. data/lib/solid_observer/base_record.rb +8 -0
  34. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  35. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  36. data/lib/solid_observer/cable_subscriber.rb +57 -0
  37. data/lib/solid_observer/cache_event_buffer.rb +11 -36
  38. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  39. data/lib/solid_observer/chart_buffer.rb +84 -27
  40. data/lib/solid_observer/configuration.rb +47 -4
  41. data/lib/solid_observer/engine.rb +46 -28
  42. data/lib/solid_observer/event_buffer_core.rb +218 -0
  43. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  44. data/lib/solid_observer/services/cable_operations.rb +74 -0
  45. data/lib/solid_observer/services/cable_stats.rb +385 -0
  46. data/lib/solid_observer/services/cache_stats.rb +35 -18
  47. data/lib/solid_observer/services/cleanup_storage.rb +82 -47
  48. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  49. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  50. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  51. data/lib/solid_observer/services/record_cable_event.rb +114 -0
  52. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  53. data/lib/solid_observer/services/record_cache_event.rb +23 -0
  54. data/lib/solid_observer/services/record_cache_metric.rb +13 -21
  55. data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
  56. data/lib/solid_observer/version.rb +1 -1
  57. data/lib/solid_observer.rb +36 -11
  58. data/lib/tasks/solid_observer.rake +84 -23
  59. metadata +26 -6
  60. data/app/assets/stylesheets/solid_observer/application.css +0 -18
  61. data/bin/console +0 -11
  62. data/bin/quality_gate +0 -95
  63. data/bin/setup +0 -8
@@ -123,10 +123,10 @@ module SolidObserver
123
123
  class EventCounts
124
124
  attr_reader :error_count, :slow_count, :latest_recorded_at
125
125
 
126
- def initialize
127
- @error_count = 0
128
- @slow_count = 0
129
- @latest_recorded_at = nil
126
+ def initialize(error_count: 0, slow_count: 0, latest_recorded_at: nil)
127
+ @error_count = error_count.to_i
128
+ @slow_count = slow_count.to_i
129
+ @latest_recorded_at = latest_recorded_at
130
130
  end
131
131
 
132
132
  def record(recorded_at:, error_class:, duration:)
@@ -181,22 +181,38 @@ module SolidObserver
181
181
  attr_reader :window, :current_time
182
182
 
183
183
  def event_counts
184
- counts = EventCounts.new
185
-
186
- SolidObserver::CacheEvent.where(recorded_at: window_range).pluck(
187
- :recorded_at,
188
- :duration,
189
- :error_class
190
- ).each do |recorded_at, duration, error_class|
191
- counts.record(recorded_at: recorded_at, error_class: error_class, duration: duration)
192
- end
193
-
194
- counts
184
+ error_count, slow_count, latest_recorded_at = SolidObserver::CacheEvent.where(recorded_at: window_range).pick(
185
+ Arel.sql("COUNT(CASE WHEN #{error_condition_sql} THEN 1 END)"),
186
+ Arel.sql("COUNT(CASE WHEN #{slow_condition_sql} THEN 1 END)"),
187
+ Arel.sql("MAX(CASE WHEN #{tracked_condition_sql} THEN recorded_at END)")
188
+ )
189
+
190
+ EventCounts.new(
191
+ error_count: error_count,
192
+ slow_count: slow_count,
193
+ latest_recorded_at: latest_recorded_at
194
+ )
195
195
  end
196
196
 
197
197
  def window_range
198
198
  (current_time - window)..current_time
199
199
  end
200
+
201
+ def tracked_condition_sql
202
+ "(#{error_condition_sql}) OR (#{slow_condition_sql})"
203
+ end
204
+
205
+ def error_condition_sql
206
+ "error_class IS NOT NULL AND TRIM(error_class) != ''"
207
+ end
208
+
209
+ def slow_condition_sql
210
+ "(error_class IS NULL OR TRIM(error_class) = '') AND duration >= #{slow_threshold}"
211
+ end
212
+
213
+ def slow_threshold
214
+ SolidObserver.config.cache_slow_threshold.to_f
215
+ end
200
216
  end
201
217
 
202
218
  class << self
@@ -218,7 +234,8 @@ module SolidObserver
218
234
  current_time = Time.current
219
235
  dashboard_response(window: window, current_time: current_time)
220
236
  rescue => error
221
- error_response(error.message)
237
+ Rails.logger&.error("[SolidObserver] CacheStats call failed: #{error.class} #{error.message}") if defined?(Rails)
238
+ error_response
222
239
  end
223
240
 
224
241
  private
@@ -308,7 +325,7 @@ module SolidObserver
308
325
  numerator.to_f / denominator
309
326
  end
310
327
 
311
- def error_response(message)
328
+ def error_response
312
329
  {
313
330
  hit_rate: 0.0,
314
331
  throughput: 0.0,
@@ -321,7 +338,7 @@ module SolidObserver
321
338
  duration_total: 0.0,
322
339
  activity_trends: ACTIVITY_TREND_EMPTY.dup,
323
340
  stability: STABILITY_EMPTY.dup,
324
- error: message
341
+ error: "Service temporarily unavailable"
325
342
  }
326
343
  end
327
344
  end
@@ -6,6 +6,13 @@ require_relative "storage_info_snapshot"
6
6
  module SolidObserver
7
7
  module Services
8
8
  class CleanupStorage
9
+ MAINTENANCE_STATEMENT_BUILDERS = {
10
+ "sqlite" => ->(_tables) { ["VACUUM"] },
11
+ "postgresql" => ->(tables) { tables.map { |table_name| "VACUUM ANALYZE #{table_name}" } },
12
+ "mysql2" => ->(tables) { ["OPTIMIZE TABLE #{tables.join(", ")}"] },
13
+ "trilogy" => ->(tables) { ["OPTIMIZE TABLE #{tables.join(", ")}"] }
14
+ }.freeze
15
+
9
16
  def self.call
10
17
  new.call
11
18
  end
@@ -13,37 +20,54 @@ module SolidObserver
13
20
  def call
14
21
  return 0 if SolidObserver.config.realtime_mode?
15
22
 
16
- deleted_count = perform_cleanup_transaction
17
- post_cleanup(deleted_count)
18
- rescue => e
19
- handle_cleanup_failure(e)
23
+ post_cleanup(cleanup_counts)
24
+ rescue => error
25
+ handle_cleanup_failure(error)
20
26
  end
21
27
 
22
28
  private
23
29
 
30
+ def cleanup_counts
31
+ perform_cleanup.tap { record_snapshot_after_cleanup }
32
+ end
33
+
24
34
  def handle_cleanup_failure(error)
25
35
  Rails.logger.error "[SolidObserver] Cleanup failed: #{error.message}"
26
36
  raise
27
37
  end
28
38
 
29
- def perform_cleanup_transaction
30
- QueueEvent.transaction do
31
- deleted_count = delete_old_events
32
- record_snapshot_after_cleanup
33
- deleted_count
34
- end
35
- end
36
-
37
- def post_cleanup(deleted_count)
39
+ def perform_cleanup
40
+ config = SolidObserver.config
41
+ event_cutoff = config.event_retention.ago
42
+
43
+ {
44
+ queue_events: QueueEvent.transaction do
45
+ QueueEvent.where("recorded_at < ?", event_cutoff).delete_all
46
+ end,
47
+ cache_events: delete_telemetry_records(
48
+ SolidObserver::CacheEvent,
49
+ column: :recorded_at,
50
+ cutoff: event_cutoff
51
+ ),
52
+ cache_metrics: delete_telemetry_records(
53
+ SolidObserver::CacheMetric,
54
+ column: :period_start,
55
+ cutoff: config.metrics_retention.ago
56
+ )
57
+ }
58
+ end
59
+
60
+ def post_cleanup(cleanup_counts)
38
61
  vacuum_database
39
62
  check_storage_warnings
40
- log_results(deleted_count)
41
- deleted_count
63
+ log_results(cleanup_counts)
64
+ cleanup_counts.values.sum
42
65
  end
43
66
 
44
- def delete_old_events
45
- cutoff = SolidObserver.config.event_retention.ago
46
- QueueEvent.where("recorded_at < ?", cutoff).delete_all
67
+ def delete_telemetry_records(model, column:, cutoff:)
68
+ return 0 unless data_source_available?(model)
69
+
70
+ model.where("#{column} < ?", cutoff).delete_all
47
71
  end
48
72
 
49
73
  def record_snapshot_after_cleanup
@@ -70,58 +94,69 @@ module SolidObserver
70
94
  end
71
95
 
72
96
  def vacuum_database
73
- statement = maintenance_statement
74
- return unless statement
75
-
76
- QueueEvent.connection.execute(statement)
77
- rescue => e
78
- Rails.logger.warn "[SolidObserver] Database maintenance failed: #{e.message}"
97
+ maintenance_statements.each do |statement|
98
+ QueueEvent.connection.execute(statement)
99
+ end
100
+ rescue => error
101
+ Rails.logger.warn "[SolidObserver] Database maintenance failed: #{error.message}"
79
102
  end
80
103
 
81
104
  def check_storage_warnings
82
105
  current_size = current_database_size
83
- return unless warning_needed?(current_size)
106
+ return unless current_size
107
+ return unless current_size > (SolidObserver.config.max_db_size * SolidObserver.config.warning_threshold)
84
108
 
85
109
  Rails.logger.warn(storage_warning_message(current_size))
86
110
  end
87
111
 
88
- def warning_needed?(current_size)
89
- return false unless current_size
90
-
91
- config = SolidObserver.config
92
- max_size = config.max_db_size
93
- threshold = config.warning_threshold
94
- current_size > (max_size * threshold)
95
- end
96
-
97
112
  def storage_warning_message(current_size)
98
113
  max_size = SolidObserver.config.max_db_size
99
114
  percentage = ((current_size.to_f / max_size) * 100).round(1)
100
- current_size_human = human_size(current_size)
101
- max_size_human = human_size(max_size)
115
+ current_size_human = ActiveSupport::NumberHelper.number_to_human_size(
116
+ current_size,
117
+ precision: 1,
118
+ significant: false,
119
+ strip_insignificant_zeros: false
120
+ )
121
+ max_size_human = ActiveSupport::NumberHelper.number_to_human_size(
122
+ max_size,
123
+ precision: 1,
124
+ significant: false,
125
+ strip_insignificant_zeros: false
126
+ )
102
127
  "[SolidObserver] Queue DB approaching limit: #{current_size_human} / #{max_size_human} (#{percentage}%)"
103
128
  end
104
129
 
105
- def human_size(bytes)
106
- ActiveSupport::NumberHelper.number_to_human_size(bytes, precision: 1, significant: false, strip_insignificant_zeros: false)
107
- end
108
-
109
130
  def current_database_size
110
131
  return @current_database_size if defined?(@current_database_size)
111
132
 
112
133
  @current_database_size = DatabaseSize.call(connection: QueueEvent.connection)
113
134
  end
114
135
 
115
- def log_results(deleted_count)
116
- Rails.logger.info "[SolidObserver] Cleaned #{deleted_count} queue events"
136
+ def log_results(cleanup_counts)
137
+ Rails.logger.info(
138
+ "[SolidObserver] Cleaned #{cleanup_counts[:queue_events]} queue events, " \
139
+ "#{cleanup_counts[:cache_events]} cache events, " \
140
+ "#{cleanup_counts[:cache_metrics]} cache metrics"
141
+ )
117
142
  end
118
143
 
119
- def maintenance_statement
120
- case QueueEvent.connection.adapter_name.downcase
121
- when "sqlite" then "VACUUM"
122
- when "postgresql" then "VACUUM ANALYZE solid_observer_queue_events"
123
- when "mysql2", "trilogy" then "OPTIMIZE TABLE solid_observer_queue_events"
144
+ def maintenance_statements
145
+ tables = [QueueEvent, SolidObserver::CacheEvent, SolidObserver::CacheMetric].filter_map do |model|
146
+ model.table_name if data_source_available?(model)
124
147
  end
148
+ return [] if tables.empty?
149
+
150
+ MAINTENANCE_STATEMENT_BUILDERS.fetch(QueueEvent.connection.adapter_name.downcase, ->(_known_tables) { [] }).call(tables)
151
+ end
152
+
153
+ def data_source_available?(model)
154
+ table_name = model.table_name.to_s
155
+ return false if table_name.empty?
156
+
157
+ model.connection.data_source_exists?(table_name)
158
+ rescue *StorageInfoSnapshot::CONNECTION_ERRORS, TypeError
159
+ false
125
160
  end
126
161
  end
127
162
  end
@@ -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,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
@@ -5,6 +5,8 @@ require "digest"
5
5
  module SolidObserver
6
6
  module Services
7
7
  class RecordCacheEvent
8
+ INTERNAL_CACHE_KEY_PREFIX = "solid_observer/"
9
+
8
10
  def self.call(event:, buffer:)
9
11
  new(event, buffer).call
10
12
  end
@@ -15,6 +17,8 @@ module SolidObserver
15
17
  end
16
18
 
17
19
  def call
20
+ return if internal_cache_event?
21
+
18
22
  record_metric_and_event
19
23
  rescue => error
20
24
  raise error if error.is_a?(NameError)
@@ -137,6 +141,25 @@ module SolidObserver
137
141
  def payload
138
142
  @event.payload || {}
139
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
140
163
  end
141
164
  end
142
165
  end