solid_observer 0.4.0 → 0.6.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +100 -25
  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 +21 -13
  10. data/app/controllers/solid_observer/traces_controller.rb +17 -0
  11. data/app/helpers/solid_observer/application_helper.rb +145 -3
  12. data/app/models/solid_observer/cable_event.rb +13 -0
  13. data/app/models/solid_observer/cable_metric.rb +12 -0
  14. data/app/models/solid_observer/cache_metric.rb +1 -2
  15. data/app/models/solid_observer/storage_info.rb +1 -1
  16. data/app/views/layouts/solid_observer/application.html.erb +35 -9
  17. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  18. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +39 -0
  19. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  20. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  21. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
  22. data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
  23. data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
  24. data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
  25. data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
  26. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  27. data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
  28. data/app/views/solid_observer/dashboard/index.html.erb +3 -139
  29. data/app/views/solid_observer/events/index.html.erb +7 -1
  30. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  31. data/app/views/solid_observer/storages/show.html.erb +29 -3
  32. data/app/views/solid_observer/traces/show.html.erb +80 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  35. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  36. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  37. data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
  38. data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
  39. data/lib/generators/solid_observer/install_generator.rb +8 -1
  40. data/lib/generators/solid_observer/templates/initializer.rb.tt +24 -5
  41. data/lib/solid_observer/base_event.rb +1 -1
  42. data/lib/solid_observer/base_metric.rb +1 -1
  43. data/lib/solid_observer/base_record.rb +8 -0
  44. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  45. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  46. data/lib/solid_observer/cable_subscriber.rb +57 -0
  47. data/lib/solid_observer/cache_event_buffer.rb +11 -36
  48. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  49. data/lib/solid_observer/chart_buffer.rb +84 -27
  50. data/lib/solid_observer/cli/health.rb +49 -0
  51. data/lib/solid_observer/cli/trace.rb +106 -0
  52. data/lib/solid_observer/configuration.rb +47 -7
  53. data/lib/solid_observer/correlated.rb +23 -0
  54. data/lib/solid_observer/engine.rb +46 -28
  55. data/lib/solid_observer/event_buffer_core.rb +218 -0
  56. data/lib/solid_observer/queries/trace_query.rb +151 -0
  57. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  58. data/lib/solid_observer/services/cable_operations.rb +74 -0
  59. data/lib/solid_observer/services/cable_stats.rb +385 -0
  60. data/lib/solid_observer/services/cache_stats.rb +35 -18
  61. data/lib/solid_observer/services/cleanup_storage.rb +82 -47
  62. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  63. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  64. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  65. data/lib/solid_observer/services/health_score.rb +85 -0
  66. data/lib/solid_observer/services/record_cable_event.rb +115 -0
  67. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  68. data/lib/solid_observer/services/record_cache_event.rb +24 -0
  69. data/lib/solid_observer/services/record_cache_metric.rb +13 -21
  70. data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
  71. data/lib/solid_observer/services/unified_feed.rb +101 -0
  72. data/lib/solid_observer/version.rb +1 -1
  73. data/lib/solid_observer.rb +42 -11
  74. data/lib/tasks/solid_observer.rake +97 -21
  75. metadata +41 -6
  76. data/app/assets/stylesheets/solid_observer/application.css +0 -18
  77. data/bin/console +0 -11
  78. data/bin/quality_gate +0 -95
  79. data/bin/setup +0 -8
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage_info_snapshot"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ # :reek:TooManyConstants
8
+ class CableStats
9
+ RANGES = {
10
+ "15m" => 15.minutes,
11
+ "30m" => 30.minutes,
12
+ "1h" => 1.hour,
13
+ "7h" => 7.hours,
14
+ "1d" => 1.day,
15
+ "7d" => 7.days,
16
+ "14d" => 14.days
17
+ }.freeze
18
+ DEFAULT_RANGE = "15m"
19
+ ACTIVITY_TREND_EMPTY = {
20
+ available: false,
21
+ broadcasts: [],
22
+ rejections: []
23
+ }.freeze
24
+ STABILITY_EMPTY = {
25
+ available: false,
26
+ state: :stable,
27
+ rejection_count: 0,
28
+ error_count: 0,
29
+ rejection_rate: 0.0,
30
+ backlog_ratio: nil,
31
+ backlog_available: false,
32
+ latest_recorded_at: nil
33
+ }.freeze
34
+ STABILITY_DEGRADED = {
35
+ available: true,
36
+ state: :degraded,
37
+ rejection_count: 0,
38
+ error_count: 0,
39
+ rejection_rate: 0.0,
40
+ backlog_ratio: nil,
41
+ backlog_available: false,
42
+ latest_recorded_at: nil
43
+ }.freeze
44
+ BUCKET_RULES = [
45
+ [2.hours.to_i, 1.minute.to_i],
46
+ [1.day.to_i, 15.minutes.to_i],
47
+ [7.days.to_i, 2.hours.to_i]
48
+ ].freeze
49
+
50
+ class TrendData
51
+ class BucketSnapshot
52
+ attr_reader :broadcasts_count, :rejections_count
53
+
54
+ def initialize
55
+ @broadcasts_count = 0
56
+ @rejections_count = 0
57
+ end
58
+
59
+ def add(row)
60
+ @broadcasts_count += row[1].to_i
61
+ @rejections_count += row[4].to_i
62
+ end
63
+
64
+ def value_for(key)
65
+ public_send(key)
66
+ end
67
+ end
68
+
69
+ def initialize(metric_rows:, window:, current_time:)
70
+ @metric_rows = metric_rows
71
+ @window = window
72
+ @current_time = current_time
73
+ end
74
+
75
+ def to_h
76
+ return CableStats::ACTIVITY_TREND_EMPTY.dup if metric_rows.empty?
77
+
78
+ buckets = blank_buckets
79
+ metric_rows.each do |row|
80
+ buckets[align_bucket(row[0].to_i)]&.add(row)
81
+ end
82
+
83
+ {
84
+ available: true,
85
+ broadcasts: count_series(buckets, :broadcasts_count),
86
+ rejections: count_series(buckets, :rejections_count)
87
+ }
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :metric_rows, :window, :current_time
93
+
94
+ def blank_buckets
95
+ start_bucket = align_bucket((current_time - window).to_i)
96
+ end_bucket = align_bucket(current_time.to_i)
97
+
98
+ start_bucket.step(end_bucket, bucket_seconds).each_with_object({}) do |timestamp, buckets|
99
+ buckets[timestamp] = BucketSnapshot.new
100
+ end
101
+ end
102
+
103
+ def count_series(buckets, key)
104
+ buckets.map do |timestamp, totals|
105
+ {t: timestamp, v: totals.value_for(key)}
106
+ end
107
+ end
108
+
109
+ def bucket_seconds
110
+ seconds = window.to_i
111
+ CableStats::BUCKET_RULES.find { |limit, _bucket| seconds <= limit }&.last || 4.hours.to_i
112
+ end
113
+
114
+ def align_bucket(value)
115
+ (value / bucket_seconds) * bucket_seconds
116
+ end
117
+ end
118
+
119
+ class BacklogSnapshot
120
+ def self.call
121
+ new.call
122
+ end
123
+
124
+ # :reek:TooManyStatements
125
+ def call
126
+ snapshot = fetch_snapshot
127
+ return unavailable unless snapshot && snapshot[:available]
128
+
129
+ count = snapshot[:trimmable_count].to_i
130
+ total = snapshot[:event_count].to_i
131
+
132
+ {
133
+ available: true,
134
+ count: count,
135
+ ratio: total.positive? ? count.to_f / total.to_f : 0.0
136
+ }
137
+ rescue *StorageInfoSnapshot::CONNECTION_ERRORS, TypeError, NoMethodError
138
+ unavailable
139
+ end
140
+
141
+ private
142
+
143
+ def fetch_snapshot
144
+ StorageInfoSnapshot.call.find { |component| component[:component] == "solid_cable" }
145
+ end
146
+
147
+ def unavailable
148
+ {available: false, count: nil, ratio: nil}
149
+ end
150
+ end
151
+
152
+ class StabilityData
153
+ def initialize(window:, current_time:, backlog_snapshot:)
154
+ @window = window
155
+ @current_time = current_time
156
+ @backlog_snapshot = backlog_snapshot
157
+ end
158
+
159
+ def to_h(metric_broadcasts_count:, metric_rejections_count:)
160
+ compute(
161
+ metric_broadcasts_count: metric_broadcasts_count,
162
+ metric_rejections_count: metric_rejections_count
163
+ ).to_h
164
+ rescue ActiveRecord::StatementInvalid
165
+ CableStats::STABILITY_DEGRADED.dup
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :window, :current_time, :backlog_snapshot
171
+
172
+ # :reek:TooManyStatements
173
+ def compute(metric_broadcasts_count:, metric_rejections_count:)
174
+ event_counts = query_event_counts
175
+ rejection_count = event_counts[:rejection_count]
176
+ error_count = event_counts[:error_count]
177
+ rejection_rate = CableStats.ratio(metric_rejections_count, metric_broadcasts_count)
178
+ rejection_present = rejection_count.to_i.positive? || metric_rejections_count.to_i.positive?
179
+ backlog_ratio = backlog_snapshot[:ratio]
180
+ backlog_available = backlog_snapshot[:available]
181
+
182
+ {
183
+ available: true,
184
+ state: stability_state(
185
+ error_count: error_count,
186
+ rejection_rate: rejection_rate,
187
+ rejection_present: rejection_present,
188
+ backlog_ratio: backlog_ratio,
189
+ backlog_available: backlog_available
190
+ ),
191
+ rejection_count: rejection_count,
192
+ error_count: error_count,
193
+ rejection_rate: rejection_rate,
194
+ backlog_ratio: backlog_ratio,
195
+ backlog_available: backlog_available,
196
+ latest_recorded_at: event_counts[:latest_recorded_at]
197
+ }
198
+ end
199
+
200
+ # :reek:ControlParameter
201
+ # :reek:LongParameterList
202
+ # :reek:TooManyStatements
203
+ def stability_state(error_count:, rejection_rate:, rejection_present:, backlog_ratio:, backlog_available:)
204
+ config = SolidObserver.config
205
+
206
+ return :critical if error_count.to_i > config.cable_error_threshold.to_i
207
+ return :critical if rejection_rate >= config.cable_rejection_threshold.to_f
208
+ return :critical if backlog_ratio && backlog_ratio >= 0.5
209
+ return :degraded if backlog_ratio && backlog_ratio >= config.cable_backlog_threshold.to_f
210
+ return :degraded if rejection_present
211
+ return :degraded unless backlog_available
212
+
213
+ :stable
214
+ end
215
+
216
+ def query_event_counts
217
+ rejection_count, error_count, latest_recorded_at = SolidObserver::CableEvent.where(recorded_at: window_range).pick(
218
+ Arel.sql("COUNT(CASE WHEN event_type = 'transmit_subscription_rejection' THEN 1 END)"),
219
+ Arel.sql("COUNT(CASE WHEN error_class IS NOT NULL AND TRIM(error_class) != '' THEN 1 END)"),
220
+ Arel.sql("MAX(CASE WHEN event_type = 'transmit_subscription_rejection' OR (error_class IS NOT NULL AND TRIM(error_class) != '') THEN recorded_at END)")
221
+ )
222
+
223
+ {
224
+ rejection_count: rejection_count.to_i,
225
+ error_count: error_count.to_i,
226
+ latest_recorded_at: parse_latest_recorded_at(latest_recorded_at)
227
+ }
228
+ end
229
+
230
+ def parse_latest_recorded_at(value)
231
+ return nil unless value.present?
232
+
233
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
234
+ rescue ArgumentError
235
+ nil
236
+ end
237
+
238
+ def window_range
239
+ (current_time - window)..current_time
240
+ end
241
+ end
242
+
243
+ class << self
244
+ def parse_range(value, fallback: DEFAULT_RANGE)
245
+ range_key = value.to_s
246
+ RANGES.key?(range_key) ? range_key : fallback
247
+ end
248
+
249
+ def range_duration(value, fallback: DEFAULT_RANGE)
250
+ RANGES.fetch(parse_range(value, fallback: fallback))
251
+ end
252
+
253
+ def ratio(numerator, denominator)
254
+ return 0.0 if denominator.to_i.zero?
255
+
256
+ numerator.to_f / denominator.to_f
257
+ end
258
+ end
259
+
260
+ def self.call(window:)
261
+ new.call(window: window)
262
+ end
263
+
264
+ def call(window:)
265
+ current_time = Time.current
266
+ dashboard_response(window: window, current_time: current_time)
267
+ rescue => error
268
+ Rails.logger&.error("[SolidObserver] CableStats call failed: #{error.class} #{error.message}") if defined?(Rails)
269
+ error_response
270
+ end
271
+
272
+ private
273
+
274
+ def dashboard_response(window:, current_time:)
275
+ time_window = (current_time - window)..current_time
276
+ metric_rows = metric_rows(time_window: time_window)
277
+ totals = metric_totals(time_window: time_window)
278
+ backlog_snapshot = BacklogSnapshot.call
279
+
280
+ build_response(
281
+ totals: totals,
282
+ backlog_snapshot: backlog_snapshot,
283
+ dashboard_data: dashboard_data(
284
+ window: window,
285
+ current_time: current_time,
286
+ metric_rows: metric_rows,
287
+ totals: totals,
288
+ backlog_snapshot: backlog_snapshot
289
+ )
290
+ )
291
+ end
292
+
293
+ # :reek:FeatureEnvy
294
+ # :reek:LongParameterList
295
+ def build_response(totals:, backlog_snapshot:, dashboard_data:)
296
+ broadcasts_count = totals[:broadcasts_count]
297
+ rejections_count = totals[:rejections_count]
298
+
299
+ {
300
+ broadcasts_count: broadcasts_count,
301
+ transmissions_count: totals[:transmissions_count],
302
+ confirmations_count: totals[:confirmations_count],
303
+ rejections_count: rejections_count,
304
+ perform_actions_count: totals[:perform_actions_count],
305
+ errors_count: totals[:errors_count],
306
+ rejection_rate: self.class.ratio(rejections_count, broadcasts_count),
307
+ activity_trends: dashboard_data[:activity_trends],
308
+ stability: dashboard_data[:stability],
309
+ backlog_count: backlog_snapshot[:count],
310
+ backlog_available: backlog_snapshot[:available]
311
+ }
312
+ end
313
+
314
+ # :reek:LongParameterList
315
+ def dashboard_data(window:, current_time:, metric_rows:, totals:, backlog_snapshot:)
316
+ {
317
+ activity_trends: TrendData.new(
318
+ metric_rows: metric_rows,
319
+ window: window,
320
+ current_time: current_time
321
+ ).to_h,
322
+ stability: StabilityData.new(
323
+ window: window,
324
+ current_time: current_time,
325
+ backlog_snapshot: backlog_snapshot
326
+ ).to_h(
327
+ metric_broadcasts_count: totals[:broadcasts_count],
328
+ metric_rejections_count: totals[:rejections_count]
329
+ )
330
+ }
331
+ end
332
+
333
+ def metric_rows(time_window:)
334
+ SolidObserver::CableMetric.where(period_start: time_window).pluck(
335
+ :period_start,
336
+ :broadcasts_count,
337
+ :transmissions_count,
338
+ :confirmations_count,
339
+ :rejections_count,
340
+ :perform_actions_count,
341
+ :errors_count
342
+ )
343
+ end
344
+
345
+ def metric_totals(time_window:)
346
+ broadcasts_count, transmissions_count, confirmations_count, rejections_count, perform_actions_count, errors_count = SolidObserver::CableMetric.where(
347
+ period_start: time_window
348
+ ).pick(
349
+ Arel.sql("COALESCE(SUM(broadcasts_count), 0)"),
350
+ Arel.sql("COALESCE(SUM(transmissions_count), 0)"),
351
+ Arel.sql("COALESCE(SUM(confirmations_count), 0)"),
352
+ Arel.sql("COALESCE(SUM(rejections_count), 0)"),
353
+ Arel.sql("COALESCE(SUM(perform_actions_count), 0)"),
354
+ Arel.sql("COALESCE(SUM(errors_count), 0)")
355
+ )
356
+
357
+ {
358
+ broadcasts_count: broadcasts_count,
359
+ transmissions_count: transmissions_count,
360
+ confirmations_count: confirmations_count,
361
+ rejections_count: rejections_count,
362
+ perform_actions_count: perform_actions_count,
363
+ errors_count: errors_count
364
+ }
365
+ end
366
+
367
+ def error_response
368
+ {
369
+ broadcasts_count: 0,
370
+ transmissions_count: 0,
371
+ confirmations_count: 0,
372
+ rejections_count: 0,
373
+ perform_actions_count: 0,
374
+ errors_count: 0,
375
+ rejection_rate: 0.0,
376
+ activity_trends: ACTIVITY_TREND_EMPTY.dup,
377
+ stability: STABILITY_EMPTY.dup,
378
+ backlog_count: nil,
379
+ backlog_available: false,
380
+ error: "Service temporarily unavailable"
381
+ }
382
+ end
383
+ end
384
+ end
385
+ end
@@ -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