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
@@ -2,7 +2,40 @@
2
2
 
3
3
  module SolidObserver
4
4
  class ChartBuffer
5
+ CACHE_KEY = "solid_observer/chart_buffer/ready_samples"
5
6
  INSTANCE_MUTEX = Mutex.new
7
+ SAMPLE_CAP = 720
8
+ STORAGE_MUTEX = Mutex.new
9
+
10
+ class SampleWindow
11
+ def initialize(samples)
12
+ @samples = samples
13
+ end
14
+
15
+ def upsert(sample, cap:)
16
+ return replace_latest_sample(sample) if latest_sample_timestamp == sample[:t]
17
+
18
+ append_new_sample(sample, cap: cap)
19
+ end
20
+
21
+ private
22
+
23
+ def latest_sample_timestamp
24
+ @samples.last&.[](:t)
25
+ end
26
+
27
+ def replace_latest_sample(sample)
28
+ @samples[-1] = sample
29
+ @samples
30
+ end
31
+
32
+ def append_new_sample(sample, cap:)
33
+ @samples << sample
34
+ overflow = @samples.length - cap
35
+ @samples.shift(overflow) if overflow.positive?
36
+ @samples
37
+ end
38
+ end
6
39
 
7
40
  class << self
8
41
  def append(value, at: Time.now)
@@ -24,16 +57,12 @@ module SolidObserver
24
57
  end
25
58
  end
26
59
 
27
- def initialize
28
- @mutex = Mutex.new
29
- @samples = []
30
- @cap = nil
31
- end
60
+ @fallback_samples = []
32
61
 
33
62
  def append(value, at: Time.now)
34
63
  sample = {t: at.to_i, v: value.to_i}
35
64
 
36
- @mutex.synchronize { store_sample(sample) }
65
+ STORAGE_MUTEX.synchronize { persist_sample(sample) }
37
66
 
38
67
  sample
39
68
  end
@@ -41,43 +70,71 @@ module SolidObserver
41
70
  def recent(window_seconds)
42
71
  cutoff = Time.now.to_i - window_seconds.to_i
43
72
 
44
- @mutex.synchronize do
45
- @samples.select { |sample| sample[:t] >= cutoff }.map(&:dup)
73
+ STORAGE_MUTEX.synchronize do
74
+ load_samples.select { |sample| sample[:t] >= cutoff }.map(&:dup)
46
75
  end
47
76
  end
48
77
 
49
78
  def clear
50
- @mutex.synchronize do
51
- @samples.clear
52
- @cap = nil
79
+ STORAGE_MUTEX.synchronize do
80
+ empty_samples = []
81
+ replace_fallback_samples(empty_samples)
82
+ cache_store&.delete(CACHE_KEY)
83
+ rescue
84
+ nil
53
85
  end
54
86
  end
55
87
 
56
88
  private
57
89
 
58
- def store_sample(sample)
59
- @cap ||= compute_cap
60
- replace_or_append(sample)
61
- trim_to_cap
90
+ def persist_sample(sample)
91
+ samples = SampleWindow.new(load_samples).upsert(sample, cap: SAMPLE_CAP)
92
+ write_samples(samples)
62
93
  end
63
94
 
64
- def replace_or_append(sample)
65
- latest_sample = @samples.last
95
+ def load_samples
96
+ normalize_samples(cache_store&.read(CACHE_KEY) || stored_fallback_samples)
97
+ rescue
98
+ stored_fallback_samples
99
+ end
66
100
 
67
- if latest_sample && latest_sample[:t] == sample[:t]
68
- @samples[-1] = sample
69
- else
70
- @samples << sample
71
- end
101
+ def write_samples(samples)
102
+ normalized = normalize_samples(samples)
103
+
104
+ replace_fallback_samples(normalized.map(&:dup))
105
+ cache_store&.write(CACHE_KEY, normalized)
106
+ rescue
107
+ replace_fallback_samples(normalized)
72
108
  end
73
109
 
74
- def trim_to_cap
75
- overflow = @samples.length - @cap
76
- @samples.shift(overflow) if overflow.positive?
110
+ def cache_store
111
+ return unless defined?(Rails)
112
+
113
+ Rails.cache
77
114
  end
78
115
 
79
- def compute_cap
80
- (3600 / 5).to_i # 720 samples — 1h at the 5s cadence
116
+ def stored_fallback_samples
117
+ normalize_samples(self.class.instance_variable_get(:@fallback_samples))
118
+ end
119
+
120
+ def replace_fallback_samples(samples)
121
+ self.class.instance_variable_set(:@fallback_samples, samples)
122
+ end
123
+
124
+ def normalize_samples(samples)
125
+ Array(samples).filter_map do |sample|
126
+ normalize_sample(sample)
127
+ end.last(SAMPLE_CAP)
128
+ end
129
+
130
+ def normalize_sample(sample)
131
+ return unless sample.is_a?(Hash)
132
+
133
+ timestamp = sample[:t] || sample["t"]
134
+ value = sample[:v] || sample["v"]
135
+ return unless timestamp && value
136
+
137
+ {t: timestamp.to_i, v: value.to_i}
81
138
  end
82
139
  end
83
140
  end
@@ -28,47 +28,50 @@ module SolidObserver
28
28
  end
29
29
 
30
30
  def gather_storage_stats
31
- {
32
- db_size_bytes: SolidObserver::Services::DatabaseSize.call(connection: QueueEvent.connection),
33
- event_count: QueueEvent.count,
34
- max_size_bytes: SolidObserver.config.max_db_size
35
- }
31
+ {components: SolidObserver::Services::StorageInfoSnapshot.call, max_size_bytes: SolidObserver.config.max_db_size}
36
32
  rescue => e
37
33
  {error: "Failed to gather storage stats: #{e.message}"}
38
34
  end
39
35
 
40
36
  def print_storage_table(stats)
41
- event_count = stats[:event_count]
42
- db_size_bytes = stats[:db_size_bytes]
43
37
  max_size_bytes = stats[:max_size_bytes]
38
+ components = stats[:components]
44
39
 
45
40
  table(
46
41
  headers: ["Component", "Size", "Events", "Usage", "Status"],
47
- rows: [storage_row(event_count: event_count, db_size_bytes: db_size_bytes, max_size_bytes: max_size_bytes)]
42
+ rows: components.map { |component| storage_row(component: component, max_size_bytes: max_size_bytes) }
48
43
  )
49
44
 
50
45
  output("")
51
46
  end
52
47
 
53
- def storage_row(event_count:, db_size_bytes:, max_size_bytes:)
54
- size, usage, status = storage_displays(db_size_bytes, max_size_bytes)
48
+ def storage_row(component:, max_size_bytes:)
49
+ event_count = component[:event_count]
50
+ size, usage, status = storage_displays(component: component, max_size_bytes: max_size_bytes)
55
51
 
56
52
  [
57
- "Queue",
53
+ component[:label],
58
54
  size,
59
- format_number(event_count),
55
+ event_count ? format_number(event_count) : "—",
60
56
  usage,
61
57
  status
62
58
  ]
63
59
  end
64
60
 
65
- def storage_displays(db_size_bytes, max_size_bytes)
61
+ def storage_displays(component:, max_size_bytes:)
62
+ return unavailable_displays unless component[:available]
63
+
64
+ db_size_bytes = component[:db_size_bytes]
66
65
  return ["N/A", "N/A", "— Unknown"] unless db_size_bytes
67
66
 
68
67
  percentage = calculate_percentage(db_size_bytes, max_size_bytes)
69
68
  [format_size(bytes_to_mb(db_size_bytes)), "#{percentage}%", status_indicator(percentage)]
70
69
  end
71
70
 
71
+ def unavailable_displays
72
+ ["—", "—", "— Unavailable"]
73
+ end
74
+
72
75
  def print_configuration
73
76
  retention_days = (SolidObserver.config.event_retention / 1.day).to_i
74
77
  max_size_mb = bytes_to_mb(SolidObserver.config.max_db_size)
@@ -22,10 +22,16 @@ module SolidObserver
22
22
  attr_accessor :observe_queue
23
23
 
24
24
  # Observer Settings (planned for a future release)
25
- # @note Cache and Cable observers are not yet implemented
25
+ # @note Cable observer is not yet fully implemented
26
26
  attr_accessor :observe_cache,
27
27
  :observe_cable,
28
- :cache_sampling_rate
28
+ :cache_sampling_rate,
29
+ :cache_slow_threshold,
30
+ :cache_store_errors
31
+
32
+ attr_reader :cable_rejection_threshold,
33
+ :cable_backlog_threshold,
34
+ :cable_error_threshold
29
35
 
30
36
  # Retention Settings
31
37
  attr_accessor :event_retention
@@ -47,7 +53,8 @@ module SolidObserver
47
53
  :flush_interval,
48
54
  :max_buffer_size,
49
55
  :buffer_overflow_strategy,
50
- :filter_cache_ttl
56
+ :filter_cache_ttl,
57
+ :cable_sampling_rate
51
58
 
52
59
  # Correlation Settings
53
60
  attr_accessor :correlation_id_generator
@@ -56,12 +63,16 @@ module SolidObserver
56
63
  @ui_enabled, @ui_base_controller, @ui_username, @ui_password,
57
64
  @storage_mode, @observe_queue, @observe_cache, @observe_cable,
58
65
  @event_retention, @metrics_retention, @max_db_size, @warning_threshold,
59
- @sampling_rate, @cache_sampling_rate, @buffer_size, @flush_interval,
66
+ @sampling_rate, @cache_sampling_rate, @cable_sampling_rate, @cache_slow_threshold, @cache_store_errors,
67
+ @cable_rejection_threshold, @cable_backlog_threshold, @cable_error_threshold,
68
+ @buffer_size, @flush_interval,
60
69
  @max_buffer_size, @buffer_overflow_strategy, @filter_cache_ttl,
61
70
  @correlation_id_generator = !production?, "::ApplicationController", nil, nil,
62
71
  :persistence, true, false, false,
63
72
  30.days, 90.days, 1.gigabyte, 0.8,
64
- 1.0, 0.1, 1000, 10.seconds,
73
+ 1.0, 0.1, 0.1, 0.1, true,
74
+ 0.05, 0.10, 0.0,
75
+ 1000, 10.seconds,
65
76
  10_000, :drop_old, 1.minute,
66
77
  nil
67
78
  end
@@ -84,11 +95,55 @@ module SolidObserver
84
95
  @storage_mode == :realtime
85
96
  end
86
97
 
98
+ def solid_queue_available?
99
+ !!defined?(::SolidQueue)
100
+ end
101
+
102
+ def solid_cache_available?
103
+ !!defined?(::SolidCache)
104
+ end
105
+
106
+ def solid_cable_available?
107
+ !!defined?(::SolidCable)
108
+ end
109
+
110
+ def solid_queue_enabled?
111
+ observe_queue
112
+ end
113
+
114
+ def solid_cache_enabled?
115
+ observe_cache && solid_cache_available?
116
+ end
117
+
118
+ def solid_cable_enabled?
119
+ observe_cable && solid_cable_available?
120
+ end
121
+
87
122
  def sampling_rate=(value)
88
123
  validate_rate!(:sampling_rate, value)
89
124
  @sampling_rate = value
90
125
  end
91
126
 
127
+ def cable_sampling_rate=(value)
128
+ validate_rate!(:cable_sampling_rate, value)
129
+ @cable_sampling_rate = value
130
+ end
131
+
132
+ def cable_rejection_threshold=(value)
133
+ validate_rate!(:cable_rejection_threshold, value)
134
+ @cable_rejection_threshold = value
135
+ end
136
+
137
+ def cable_backlog_threshold=(value)
138
+ validate_rate!(:cable_backlog_threshold, value)
139
+ @cable_backlog_threshold = value
140
+ end
141
+
142
+ def cable_error_threshold=(value)
143
+ validate_non_negative_numeric!(:cable_error_threshold, value)
144
+ @cable_error_threshold = value
145
+ end
146
+
92
147
  def warning_threshold=(value)
93
148
  validate_rate!(:warning_threshold, value)
94
149
  @warning_threshold = value
@@ -151,6 +206,13 @@ module SolidObserver
151
206
  end
152
207
  end
153
208
 
209
+ # :reek:FeatureEnvy
210
+ def validate_non_negative_numeric!(name, value)
211
+ unless value.is_a?(Numeric) && value >= 0
212
+ raise ArgumentError, "#{name} must be a non-negative number"
213
+ end
214
+ end
215
+
154
216
  def production?
155
217
  defined?(Rails) && Rails.env.production?
156
218
  end
@@ -10,11 +10,25 @@ module SolidObserver
10
10
 
11
11
  class << self
12
12
  def check_solid_queue_availability
13
- return if defined?(SolidQueue)
13
+ return if defined?(::SolidQueue)
14
14
 
15
15
  Rails.logger.warn "[SolidObserver] SolidQueue not detected. Queue observability features will be limited."
16
16
  end
17
17
 
18
+ def check_solid_cache_availability
19
+ return if defined?(::SolidCache)
20
+ return unless SolidObserver.config.observe_cache
21
+
22
+ Rails.logger.warn "[SolidObserver] SolidCache not detected. Cache observability features will be disabled."
23
+ end
24
+
25
+ def check_solid_cable_availability
26
+ return if defined?(::SolidCable)
27
+ return unless SolidObserver.config.observe_cable
28
+
29
+ Rails.logger.warn "[SolidObserver] SolidCable not detected. Cable observability features will be disabled."
30
+ end
31
+
18
32
  def check_ui_authentication
19
33
  Services::UiAuthCheck.call(config: SolidObserver.config)
20
34
  end
@@ -28,15 +42,18 @@ module SolidObserver
28
42
 
29
43
  def activate_subscribers
30
44
  return activate_subscribers_in_realtime if SolidObserver.config.realtime_mode?
31
- return if activation_skipped_for_table_status?
32
45
 
33
46
  Rails.logger.info "[SolidObserver] Activating event subscribers"
34
- Subscriber.subscribe!
47
+ activate_queue_subscriber
48
+ activate_cache_subscriber
49
+ activate_cable_subscriber
35
50
  end
36
51
 
37
52
  private
38
53
 
39
54
  def queue_db_config
55
+ return unless active_record_available?
56
+
40
57
  ActiveRecord::Base.configurations.configs_for(
41
58
  env_name: Rails.env,
42
59
  name: "solid_observer_queue"
@@ -48,40 +65,73 @@ module SolidObserver
48
65
  database: {writing: :solid_observer_queue, reading: :solid_observer_queue}
49
66
  }
50
67
 
51
- SolidObserver::BaseEvent.connects_to(**connection_config)
52
- SolidObserver::BaseMetric.connects_to(**connection_config)
68
+ SolidObserver::BaseRecord.connects_to(**connection_config)
69
+ end
70
+
71
+ def active_record_available?
72
+ defined?(::ActiveRecord::Base)
53
73
  end
54
74
 
55
75
  def activate_subscribers_in_realtime
56
76
  Rails.logger.info "[SolidObserver] Starting in real-time mode (no persistence)"
57
- Subscriber.subscribe!
77
+ Subscriber.subscribe! if should_activate_queue_subscriber?
78
+ CacheSubscriber.subscribe! if should_activate_cache_subscriber?
79
+ CableSubscriber.subscribe! if should_activate_cable_subscriber?
80
+ end
81
+
82
+ def activate_queue_subscriber
83
+ activate_subscriber_for_table("solid_observer_queue_events", Subscriber) if should_activate_queue_subscriber?
84
+ end
85
+
86
+ def activate_cache_subscriber
87
+ activate_subscriber_for_table("solid_observer_cache_events", CacheSubscriber) if should_activate_cache_subscriber?
88
+ end
89
+
90
+ def activate_cable_subscriber
91
+ activate_subscriber_for_table("solid_observer_cable_events", CableSubscriber) if should_activate_cable_subscriber?
58
92
  end
59
93
 
60
- def activation_skipped_for_table_status?
61
- case table_status("solid_observer_queue_events")
94
+ def activate_subscriber_for_table(table_name, subscriber)
95
+ case table_status(table_name)
62
96
  when :absent
63
- log_activation_skip("Tables not found. Run: rails solid_observer:install:migrations && rails db:migrate")
64
- true
97
+ log_activation_skip("Tables not found (missing: #{table_name}). Run: rails solid_observer:install:migrations && rails db:migrate")
65
98
  when :unknown
66
99
  log_activation_skip("Database not reachable at boot. Skipping subscriber activation.")
67
- true
68
100
  else
69
- false
101
+ subscriber.subscribe!
70
102
  end
71
103
  end
72
104
 
105
+ def should_activate_queue_subscriber?
106
+ SolidObserver.config.solid_queue_enabled?
107
+ end
108
+
109
+ def should_activate_cache_subscriber?
110
+ SolidObserver.config.solid_cache_enabled?
111
+ end
112
+
113
+ def should_activate_cable_subscriber?
114
+ SolidObserver.config.solid_cable_enabled?
115
+ end
116
+
73
117
  def log_activation_skip(message)
74
118
  Rails.logger.info("[SolidObserver] #{message}")
75
119
  end
76
120
 
77
121
  def table_status(table_name)
78
- pool = SolidObserver::BaseEvent.connection_pool
122
+ return :unknown unless active_record_available?
123
+
124
+ data_source_status(table_name)
125
+ rescue *boot_connection_errors
126
+ :unknown
127
+ end
128
+
129
+ def data_source_status(table_name)
130
+ pool = SolidObserver::BaseRecord.connection_pool
79
131
 
80
132
  return :present if cached_data_source_exists?(pool, table_name)
81
133
 
82
134
  data_source_exists_in_db?(pool, table_name) ? :present : :absent
83
- rescue *boot_connection_errors
84
- :unknown
85
135
  end
86
136
 
87
137
  def cached_data_source_exists?(pool, table_name)
@@ -96,12 +146,15 @@ module SolidObserver
96
146
  end
97
147
 
98
148
  def boot_connection_errors
149
+ return [] unless active_record_available?
150
+
99
151
  [
100
152
  ActiveRecord::NoDatabaseError,
101
153
  ActiveRecord::ConnectionNotEstablished,
102
154
  ActiveRecord::StatementInvalid,
103
155
  *([PG::ConnectionBad] if defined?(PG::ConnectionBad)),
104
156
  *([Mysql2::Error::ConnectionError] if defined?(Mysql2::Error::ConnectionError)),
157
+ *([Trilogy::Error] if defined?(Trilogy::Error)),
105
158
  *([SQLite3::CantOpenException] if defined?(SQLite3::CantOpenException))
106
159
  ]
107
160
  end
@@ -109,6 +162,8 @@ module SolidObserver
109
162
 
110
163
  config.before_initialize do
111
164
  Engine.check_solid_queue_availability
165
+ Engine.check_solid_cache_availability
166
+ Engine.check_solid_cable_availability
112
167
  end
113
168
 
114
169
  config.after_initialize do
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/timer_task"
4
+
5
+ module SolidObserver
6
+ module EventBufferCore
7
+ INITIAL_METRICS = {
8
+ flush_failures_count: 0,
9
+ drops_count: 0,
10
+ last_flush_at: nil,
11
+ last_flush_duration_ms: nil,
12
+ last_flush_error: nil
13
+ }.freeze
14
+
15
+ def initialize_event_buffer
16
+ @mutex = Mutex.new
17
+ @metrics_mutex = Mutex.new
18
+ @buffer = []
19
+ @metrics = INITIAL_METRICS.dup
20
+ @timer_task = nil
21
+ end
22
+ private :initialize_event_buffer
23
+
24
+ def push(event_data)
25
+ return unless (config = SolidObserver.config).persistence_mode?
26
+
27
+ drops_count, should_flush = sync_push_and_check(event_data, config)
28
+ record_drop(drops_count) if drops_count.positive?
29
+ ensure_timer_running
30
+ flush! if should_flush
31
+ end
32
+
33
+ def flush!
34
+ events_to_flush = drain_buffer
35
+ return if events_to_flush.empty?
36
+
37
+ flush_events(events_to_flush, monotonic_ms)
38
+ end
39
+
40
+ def flush
41
+ flush!
42
+ end
43
+
44
+ def size
45
+ @mutex.synchronize { @buffer.size }
46
+ end
47
+
48
+ def clear
49
+ @mutex.synchronize { @buffer.clear }
50
+ end
51
+
52
+ def metrics
53
+ current_size = @mutex.synchronize { @buffer.size }
54
+ snapshot = @metrics_mutex.synchronize { @metrics.dup }
55
+ {
56
+ size: current_size,
57
+ max_buffer_size: SolidObserver.config.max_buffer_size
58
+ }.merge(snapshot)
59
+ end
60
+
61
+ def shutdown
62
+ stop_timer
63
+ flush!
64
+ end
65
+
66
+ private
67
+
68
+ def drain_buffer
69
+ @mutex.synchronize do
70
+ events_to_flush = @buffer.dup
71
+ @buffer.clear
72
+ events_to_flush
73
+ end
74
+ end
75
+
76
+ def flush_events(events_to_flush, started_at_ms)
77
+ flush_service.call(events_to_flush)
78
+ record_flush_success(monotonic_ms - started_at_ms)
79
+ rescue => error
80
+ handle_flush_error(error, events_to_flush)
81
+ end
82
+
83
+ def handle_flush_error(error, events_to_flush)
84
+ requeue_failed_events(events_to_flush)
85
+ record_flush_failure(error)
86
+ log_flush_failure(error)
87
+ nil
88
+ end
89
+
90
+ def log_flush_failure(error)
91
+ Rails.logger&.error("[SolidObserver] #{log_label} flush failed: #{error.message}") if defined?(Rails)
92
+ end
93
+
94
+ def apply_overflow_policy(event_data, config)
95
+ if @buffer.size < config.max_buffer_size
96
+ @buffer << event_data
97
+ return 0
98
+ end
99
+
100
+ handle_overflow(event_data, config.buffer_overflow_strategy)
101
+ end
102
+
103
+ def handle_overflow(event_data, overflow_strategy)
104
+ case overflow_strategy
105
+ when :drop_old
106
+ @buffer.shift
107
+ @buffer << event_data
108
+ when :drop_new
109
+ nil
110
+ else
111
+ raise ArgumentError, "Unsupported buffer_overflow_strategy: #{overflow_strategy.inspect}"
112
+ end
113
+
114
+ 1
115
+ end
116
+
117
+ def requeue_failed_events(events_to_flush)
118
+ dropped_count = sync_requeue_events(events_to_flush, SolidObserver.config.max_buffer_size)
119
+ record_drop(dropped_count) if dropped_count.positive?
120
+ end
121
+
122
+ def trim_events_for_capacity(events, max_buffer_size)
123
+ dropped_count = events.size - max_buffer_size
124
+ return [events, 0] if dropped_count <= 0
125
+
126
+ [events_to_keep(events, max_buffer_size), dropped_count]
127
+ end
128
+
129
+ def ensure_timer_running
130
+ timer_to_start, timer_to_stop = replace_timer_if_stopped
131
+ return unless timer_to_start
132
+
133
+ timer_to_stop&.shutdown
134
+ timer_to_start.execute
135
+ end
136
+
137
+ def replace_timer_if_stopped
138
+ @mutex.synchronize do
139
+ current_timer_task = @timer_task
140
+ return [nil, nil] if timer_running?(current_timer_task)
141
+
142
+ [build_timer_task, current_timer_task]
143
+ end
144
+ end
145
+
146
+ def stop_timer
147
+ timer_to_stop = @mutex.synchronize do
148
+ current_timer = @timer_task
149
+ @timer_task = nil
150
+ current_timer
151
+ end
152
+
153
+ timer_to_stop&.shutdown
154
+ end
155
+
156
+ def record_flush_success(duration_ms)
157
+ @metrics_mutex.synchronize do
158
+ @metrics.merge!(
159
+ last_flush_at: Time.current,
160
+ last_flush_duration_ms: duration_ms,
161
+ last_flush_error: nil
162
+ )
163
+ end
164
+ end
165
+
166
+ def record_flush_failure(error)
167
+ @metrics_mutex.synchronize do
168
+ @metrics[:flush_failures_count] += 1
169
+ @metrics[:last_flush_error] = error.message
170
+ end
171
+ end
172
+
173
+ def record_drop(count = 1)
174
+ @metrics_mutex.synchronize do
175
+ @metrics[:drops_count] += count
176
+ end
177
+ end
178
+
179
+ def sync_push_and_check(event_data, config)
180
+ @mutex.synchronize do
181
+ drops_count = apply_overflow_policy(event_data, config)
182
+ [drops_count, @buffer.size >= config.buffer_size]
183
+ end
184
+ end
185
+
186
+ def sync_requeue_events(events_to_flush, max_buffer_size)
187
+ @mutex.synchronize do
188
+ combined_events = events_to_flush + @buffer
189
+ kept_events, dropped_count = trim_events_for_capacity(combined_events, max_buffer_size)
190
+ @buffer.replace(kept_events)
191
+ dropped_count
192
+ end
193
+ end
194
+
195
+ def events_to_keep(events, max_buffer_size)
196
+ if SolidObserver.config.buffer_overflow_strategy == :drop_old
197
+ events.last(max_buffer_size)
198
+ else
199
+ events.first(max_buffer_size)
200
+ end
201
+ end
202
+
203
+ def timer_running?(timer_task)
204
+ timer_task && !timer_task.shuttingdown?
205
+ end
206
+
207
+ def build_timer_task
208
+ @timer_task = Concurrent::TimerTask.new(
209
+ execution_interval: SolidObserver.config.flush_interval,
210
+ run_now: false
211
+ ) { flush! }
212
+ end
213
+
214
+ def monotonic_ms
215
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
216
+ end
217
+ end
218
+ end