solid_observer 0.1.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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +58 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +347 -0
  5. data/app/jobs/solid_observer/cleanup_job.rb +12 -0
  6. data/app/models/solid_observer/queue_event.rb +23 -0
  7. data/app/models/solid_observer/queue_metric.rb +14 -0
  8. data/app/models/solid_observer/storage_info.rb +36 -0
  9. data/bin/console +11 -0
  10. data/bin/setup +8 -0
  11. data/db/migrate/20260115000001_create_solid_observer_queue_events.rb +21 -0
  12. data/db/migrate/20260115000002_create_solid_observer_metrics.rb +16 -0
  13. data/db/migrate/20260115000003_create_solid_observer_storage_info.rb +13 -0
  14. data/lib/generators/solid_observer/install_generator.rb +72 -0
  15. data/lib/generators/solid_observer/templates/initializer.rb.tt +57 -0
  16. data/lib/solid_observer/base_event.rb +10 -0
  17. data/lib/solid_observer/base_metric.rb +59 -0
  18. data/lib/solid_observer/cli/base.rb +98 -0
  19. data/lib/solid_observer/cli/jobs.rb +195 -0
  20. data/lib/solid_observer/cli/status.rb +59 -0
  21. data/lib/solid_observer/cli/storage.rb +114 -0
  22. data/lib/solid_observer/configuration.rb +125 -0
  23. data/lib/solid_observer/correlation_id_resolver.rb +62 -0
  24. data/lib/solid_observer/engine.rb +60 -0
  25. data/lib/solid_observer/queue_event_buffer.rb +80 -0
  26. data/lib/solid_observer/queue_stats.rb +89 -0
  27. data/lib/solid_observer/services/cleanup_storage.rb +94 -0
  28. data/lib/solid_observer/services/flush_event_buffer.rb +65 -0
  29. data/lib/solid_observer/services/record_event.rb +96 -0
  30. data/lib/solid_observer/subscriber.rb +96 -0
  31. data/lib/solid_observer/version.rb +7 -0
  32. data/lib/solid_observer.rb +40 -0
  33. data/lib/tasks/solid_observer.rake +155 -0
  34. metadata +93 -0
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+ require "active_support/core_ext/numeric/bytes"
5
+
6
+ module SolidObserver
7
+ # Configuration options for SolidObserver.
8
+ #
9
+ # @example Basic configuration
10
+ # SolidObserver.configure do |config|
11
+ # config.event_retention = 14.days
12
+ # config.sampling_rate = 0.5
13
+ # end
14
+ class Configuration
15
+ # UI Settings
16
+ attr_accessor :ui_enabled,
17
+ :ui_base_controller,
18
+ :http_basic_auth_enabled,
19
+ :http_basic_auth_user,
20
+ :http_basic_auth_password
21
+
22
+ # Observer Settings
23
+ attr_accessor :observe_queue
24
+
25
+ # Observer Settings (planned for v0.2.0)
26
+ # @note Cache and Cable observers are not yet implemented
27
+ attr_accessor :observe_cache,
28
+ :observe_cable,
29
+ :cache_sampling_rate
30
+
31
+ # Retention Settings
32
+ attr_accessor :event_retention
33
+
34
+ # Retention Settings (planned for v0.2.0)
35
+ # @note Metrics cleanup is not yet implemented
36
+ attr_accessor :metrics_retention
37
+
38
+ # Storage Settings
39
+ attr_accessor :max_db_size
40
+
41
+ # Performance Settings (with validation)
42
+ attr_reader :sampling_rate,
43
+ :warning_threshold,
44
+ :buffer_size,
45
+ :flush_interval
46
+
47
+ # Correlation Settings
48
+ attr_accessor :correlation_id_generator
49
+
50
+ def initialize
51
+ # UI defaults
52
+ @ui_enabled = !production?
53
+ @ui_base_controller = "::ApplicationController"
54
+ @http_basic_auth_enabled = false
55
+ @http_basic_auth_user = nil
56
+ @http_basic_auth_password = nil
57
+
58
+ # Observer defaults
59
+ @observe_queue = true
60
+ @observe_cache = false
61
+ @observe_cable = false
62
+
63
+ # Retention defaults
64
+ @event_retention = 30.days
65
+ @metrics_retention = 90.days
66
+
67
+ # Storage defaults
68
+ @max_db_size = 1.gigabyte
69
+ @warning_threshold = 0.8
70
+
71
+ # Performance defaults
72
+ @sampling_rate = 1.0
73
+ @cache_sampling_rate = 0.1
74
+ @buffer_size = 1000
75
+ @flush_interval = 10.seconds
76
+
77
+ # Correlation defaults
78
+ @correlation_id_generator = nil
79
+ end
80
+
81
+ def sampling_rate=(value)
82
+ validate_rate!(:sampling_rate, value)
83
+ @sampling_rate = value
84
+ end
85
+
86
+ def warning_threshold=(value)
87
+ validate_rate!(:warning_threshold, value)
88
+ @warning_threshold = value
89
+ end
90
+
91
+ def buffer_size=(value)
92
+ validate_positive_integer!(:buffer_size, value)
93
+ @buffer_size = value
94
+ end
95
+
96
+ def flush_interval=(value)
97
+ validate_positive_numeric!(:flush_interval, value)
98
+ @flush_interval = value
99
+ end
100
+
101
+ private
102
+
103
+ def validate_rate!(name, value)
104
+ unless value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
105
+ raise ArgumentError, "#{name} must be a number between 0.0 and 1.0"
106
+ end
107
+ end
108
+
109
+ def validate_positive_integer!(name, value)
110
+ unless value.is_a?(Integer) && value > 0
111
+ raise ArgumentError, "#{name} must be a positive integer"
112
+ end
113
+ end
114
+
115
+ def validate_positive_numeric!(name, value)
116
+ unless value.is_a?(Numeric) && value > 0
117
+ raise ArgumentError, "#{name} must be a positive number"
118
+ end
119
+ end
120
+
121
+ def production?
122
+ defined?(Rails) && Rails.env.production?
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SolidObserver
6
+ class CorrelationIdResolver
7
+ def self.resolve(event)
8
+ new(event).resolve
9
+ end
10
+
11
+ def initialize(event)
12
+ @event = event
13
+ end
14
+
15
+ def resolve
16
+ custom_generator_result.presence ||
17
+ job_id_result.presence ||
18
+ thread_correlation_id.presence ||
19
+ SecureRandom.uuid
20
+ end
21
+
22
+ private
23
+
24
+ def custom_generator_result
25
+ call_custom_generator if custom_generator_configured?
26
+ end
27
+
28
+ def job_id_result
29
+ extract_job_id if job_event?
30
+ end
31
+
32
+ def thread_correlation_id
33
+ Thread.current[:solid_observer_correlation_id]
34
+ end
35
+
36
+ def custom_generator_configured?
37
+ SolidObserver.config.correlation_id_generator.present?
38
+ end
39
+
40
+ def call_custom_generator
41
+ result = SolidObserver.config.correlation_id_generator.call
42
+ return nil if result.blank?
43
+ result
44
+ rescue => e
45
+ log_generator_error(e)
46
+ nil
47
+ end
48
+
49
+ def log_generator_error(exception)
50
+ return unless defined?(Rails) && Rails.logger
51
+ Rails.logger.warn "[SolidObserver] Custom correlation_id_generator failed: #{exception.message}"
52
+ end
53
+
54
+ def job_event?
55
+ @event.payload[:job]&.job_id.present?
56
+ end
57
+
58
+ def extract_job_id
59
+ @event.payload[:job].job_id
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SolidObserver
6
+
7
+ class << self
8
+ def check_solid_queue_availability
9
+ return if defined?(SolidQueue)
10
+
11
+ Rails.logger.warn "[SolidObserver] SolidQueue not detected. Queue observability features will be limited."
12
+ end
13
+
14
+ def configure_database_connection
15
+ db_config = ActiveRecord::Base.configurations.configs_for(
16
+ env_name: Rails.env,
17
+ name: "solid_observer_queue"
18
+ )
19
+
20
+ return unless db_config
21
+
22
+ connection_config = {
23
+ database: {writing: :solid_observer_queue, reading: :solid_observer_queue}
24
+ }
25
+
26
+ SolidObserver::BaseEvent.connects_to(**connection_config)
27
+ SolidObserver::BaseMetric.connects_to(**connection_config)
28
+ end
29
+
30
+ def activate_subscribers
31
+ logger = Rails.logger
32
+
33
+ unless table_exists?("solid_observer_queue_events")
34
+ logger.info "[SolidObserver] Tables not found. Run: rails solid_observer:install:migrations && rails db:migrate"
35
+ return
36
+ end
37
+
38
+ logger.info "[SolidObserver] Activating event subscribers"
39
+ Subscriber.subscribe!
40
+ rescue ActiveRecord::NoDatabaseError
41
+ logger.info "[SolidObserver] Database not ready yet. Skipping subscriber activation."
42
+ end
43
+
44
+ private
45
+
46
+ def table_exists?(table_name)
47
+ ActiveRecord::Base.connection.table_exists?(table_name)
48
+ end
49
+ end
50
+
51
+ config.before_initialize do
52
+ Engine.check_solid_queue_availability
53
+ end
54
+
55
+ config.after_initialize do
56
+ Engine.configure_database_connection
57
+ Engine.activate_subscribers
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module SolidObserver
6
+ # Thread-safe buffer for collecting queue events before batch insertion.
7
+ #
8
+ # Events are buffered in memory and flushed either when:
9
+ # - Buffer size reaches the configured threshold
10
+ # - Flush interval timer expires
11
+ #
12
+ # @example Push an event to the buffer
13
+ # QueueEventBuffer.instance.push(event_data)
14
+ class QueueEventBuffer
15
+ include Singleton
16
+
17
+ def initialize
18
+ @mutex = Mutex.new
19
+ @buffer = []
20
+ @flush_scheduled = false
21
+ end
22
+
23
+ # Adds an event to the buffer and triggers flush if threshold reached.
24
+ #
25
+ # @param event_data [Hash] Event data to buffer
26
+ # @return [void]
27
+ def push(event_data)
28
+ should_flush = false
29
+
30
+ @mutex.synchronize do
31
+ @buffer << event_data
32
+ schedule_flush unless @flush_scheduled
33
+ should_flush = @buffer.size >= SolidObserver.config.buffer_size
34
+ end
35
+
36
+ flush! if should_flush
37
+ end
38
+
39
+ # Flushes all buffered events to the database.
40
+ #
41
+ # @return [void]
42
+ def flush!
43
+ events_to_flush = nil
44
+
45
+ @mutex.synchronize do
46
+ return if @buffer.empty?
47
+ events_to_flush = @buffer.dup
48
+ @buffer.clear
49
+ end
50
+
51
+ Services::FlushEventBuffer.call(events_to_flush)
52
+ rescue => e
53
+ @mutex.synchronize { @buffer.unshift(*events_to_flush) }
54
+ Rails.logger&.error "[SolidObserver] Buffer flush failed: #{e.message}" if defined?(Rails)
55
+ end
56
+
57
+ def size
58
+ @mutex.synchronize { @buffer.size }
59
+ end
60
+
61
+ def clear
62
+ @mutex.synchronize { @buffer.clear }
63
+ end
64
+
65
+ private
66
+
67
+ def schedule_flush
68
+ @flush_scheduled = true
69
+ thread = Thread.new do
70
+ sleep SolidObserver.config.flush_interval
71
+ @mutex.synchronize { @flush_scheduled = false }
72
+ flush!
73
+ rescue => e
74
+ Rails.logger&.error "[SolidObserver] Scheduled flush failed: #{e.message}" if defined?(Rails)
75
+ end
76
+ thread.name = "SolidObserver::QueueEventBuffer#flush"
77
+ thread.report_on_exception = false
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class QueueStats
5
+ class << self
6
+ def snapshot
7
+ new.snapshot
8
+ end
9
+
10
+ def solid_queue_available?
11
+ !!(defined?(SolidQueue) && defined?(SolidQueue::Job))
12
+ end
13
+ end
14
+
15
+ def snapshot
16
+ return unavailable_response unless self.class.solid_queue_available?
17
+
18
+ {
19
+ ready: ready_count,
20
+ scheduled: scheduled_count,
21
+ claimed: claimed_count,
22
+ failed: failed_count,
23
+ workers: active_workers_count,
24
+ queues: queue_depths,
25
+ available: true
26
+ }
27
+ rescue => e
28
+ error_response(e)
29
+ end
30
+
31
+ private
32
+
33
+ def unavailable_response
34
+ {
35
+ ready: 0,
36
+ scheduled: 0,
37
+ claimed: 0,
38
+ failed: 0,
39
+ workers: 0,
40
+ queues: {},
41
+ available: false,
42
+ error: "SolidQueue not available"
43
+ }
44
+ end
45
+
46
+ def error_response(exception)
47
+ {
48
+ ready: 0,
49
+ scheduled: 0,
50
+ claimed: 0,
51
+ failed: 0,
52
+ workers: 0,
53
+ queues: {},
54
+ available: false,
55
+ error: exception.message
56
+ }
57
+ end
58
+
59
+ def ready_count
60
+ SolidQueue::ReadyExecution.count
61
+ end
62
+
63
+ def scheduled_count
64
+ SolidQueue::ScheduledExecution.count
65
+ end
66
+
67
+ def claimed_count
68
+ SolidQueue::ClaimedExecution.count
69
+ end
70
+
71
+ def failed_count
72
+ SolidQueue::FailedExecution.count
73
+ end
74
+
75
+ def active_workers_count
76
+ return 0 unless defined?(SolidQueue::Process)
77
+
78
+ SolidQueue::Process.where(kind: "Worker").count
79
+ end
80
+
81
+ def queue_depths
82
+ return {} unless defined?(SolidQueue::ReadyExecution)
83
+
84
+ SolidQueue::ReadyExecution
85
+ .group(:queue_name)
86
+ .count
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class CleanupStorage
6
+ def self.call
7
+ new.call
8
+ end
9
+
10
+ def call
11
+ deleted_count = 0
12
+
13
+ QueueEvent.transaction do
14
+ deleted_count = delete_old_events
15
+ record_snapshot_after_cleanup
16
+ end
17
+
18
+ vacuum_database
19
+
20
+ check_storage_warnings
21
+ log_results(deleted_count)
22
+
23
+ deleted_count
24
+ rescue => e
25
+ Rails.logger.error "[SolidObserver] Cleanup failed: #{e.message}"
26
+ raise
27
+ end
28
+
29
+ private
30
+
31
+ def delete_old_events
32
+ cutoff = SolidObserver.config.event_retention.ago
33
+ QueueEvent.where("recorded_at < ?", cutoff).delete_all
34
+ end
35
+
36
+ def record_snapshot_after_cleanup
37
+ db_size = calculate_database_size
38
+ event_count = QueueEvent.count
39
+
40
+ StorageInfo.record_snapshot(
41
+ db_size: db_size,
42
+ event_count: event_count
43
+ )
44
+ end
45
+
46
+ def vacuum_database
47
+ adapter = QueueEvent.connection.adapter_name.downcase
48
+ case adapter
49
+ when "sqlite"
50
+ QueueEvent.connection.execute("VACUUM")
51
+ when "postgresql"
52
+ QueueEvent.connection.execute("VACUUM ANALYZE solid_observer_queue_events")
53
+ when "mysql2", "trilogy"
54
+ QueueEvent.connection.execute("OPTIMIZE TABLE solid_observer_queue_events")
55
+ end
56
+ rescue => e
57
+ Rails.logger.warn "[SolidObserver] Database maintenance failed: #{e.message}"
58
+ end
59
+
60
+ def calculate_database_size
61
+ db_path = QueueEvent.connection_db_config.database
62
+ File.size(db_path) if File.exist?(db_path)
63
+ rescue => e
64
+ Rails.logger.warn "[SolidObserver] Could not calculate DB size: #{e.message}"
65
+ 0
66
+ end
67
+
68
+ def check_storage_warnings
69
+ max_size = SolidObserver.config.max_db_size
70
+ threshold = SolidObserver.config.warning_threshold
71
+ current_size = calculate_database_size
72
+
73
+ return unless current_size > (max_size * threshold)
74
+
75
+ percentage = ((current_size.to_f / max_size) * 100).round(1)
76
+ Rails.logger.warn "[SolidObserver] Queue DB approaching limit: #{format_bytes(current_size)} / #{format_bytes(max_size)} (#{percentage}%)"
77
+ end
78
+
79
+ def log_results(deleted_count)
80
+ Rails.logger.info "[SolidObserver] Cleaned #{deleted_count} queue events"
81
+ end
82
+
83
+ def format_bytes(bytes)
84
+ return "0 B" if bytes.zero?
85
+
86
+ units = ["B", "KB", "MB", "GB"]
87
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
88
+ exp = [exp, units.length - 1].min
89
+
90
+ "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ # Flushes buffered events to the database using bulk insert.
6
+ #
7
+ # Attempts bulk insert with automatic fallback to smaller batches
8
+ # if the initial insert fails.
9
+ #
10
+ # @example Flush events
11
+ # FlushEventBuffer.call(events)
12
+ class FlushEventBuffer
13
+ BATCH_SIZE = 100
14
+
15
+ # @param events [Array<Hash>] Array of event data to insert
16
+ # @return [Integer] Number of events successfully inserted
17
+ def self.call(events)
18
+ new(events).call
19
+ end
20
+
21
+ def initialize(events)
22
+ @events = events
23
+ @failed_count = 0
24
+ end
25
+
26
+ def call
27
+ return 0 if @events.empty?
28
+
29
+ QueueEvent.transaction do
30
+ QueueEvent.insert_all!(@events)
31
+ end
32
+
33
+ @events.size
34
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => e
35
+ log_error("Bulk insert failed, retrying in batches: #{e.message}")
36
+ retry_with_smaller_batches
37
+ end
38
+
39
+ private
40
+
41
+ def retry_with_smaller_batches
42
+ inserted = 0
43
+
44
+ @events.each_slice(BATCH_SIZE) do |batch|
45
+ QueueEvent.insert_all(batch, returning: false)
46
+ inserted += batch.size
47
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => e
48
+ @failed_count += batch.size
49
+ log_warning("Failed to insert batch of #{batch.size} events: #{e.message}")
50
+ end
51
+
52
+ log_warning("#{@failed_count} events could not be saved") if @failed_count.positive?
53
+ inserted
54
+ end
55
+
56
+ def log_error(message)
57
+ Rails.logger.error("[SolidObserver] #{message}") if defined?(Rails)
58
+ end
59
+
60
+ def log_warning(message)
61
+ Rails.logger.warn("[SolidObserver] #{message}") if defined?(Rails)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ # Records ActiveJob events to the buffer with sampling support.
6
+ #
7
+ # Extracts job metadata, applies sampling rate, and pushes events
8
+ # to the buffer for batch insertion.
9
+ #
10
+ # @example Record a job completion
11
+ # RecordEvent.call(
12
+ # event: event,
13
+ # event_type: "job_completed",
14
+ # buffer: QueueEventBuffer.instance,
15
+ # metric_name: "jobs_completed"
16
+ # )
17
+ class RecordEvent
18
+ # @param event [ActiveSupport::Notifications::Event] The notification event
19
+ # @param event_type [String] Type of event (e.g., "job_enqueued")
20
+ # @param buffer [QueueEventBuffer] Buffer to push event data to
21
+ # @param metric_name [String] Metric name for incrementing
22
+ # @return [void]
23
+ def self.call(event:, event_type:, buffer:, metric_name:)
24
+ new(event, event_type, buffer, metric_name).call
25
+ end
26
+
27
+ def initialize(event, event_type, buffer, metric_name)
28
+ @event = event
29
+ @event_type = event_type
30
+ @buffer = buffer
31
+ @metric_name = metric_name
32
+ end
33
+
34
+ def call
35
+ return unless should_record?
36
+
37
+ @buffer.push(build_event_data)
38
+ increment_metric
39
+ rescue => e
40
+ handle_error(e)
41
+ end
42
+
43
+ private
44
+
45
+ def should_record?
46
+ rand <= SolidObserver.config.sampling_rate
47
+ end
48
+
49
+ def build_event_data
50
+ metadata = extract_metadata
51
+
52
+ {
53
+ event_type: @event_type,
54
+ job_class: metadata[:job_class],
55
+ queue_name: metadata[:queue_name],
56
+ correlation_id: CorrelationIdResolver.resolve(@event),
57
+ duration: @event.duration,
58
+ metadata: metadata.except(:job_class, :queue_name).to_json,
59
+ recorded_at: Time.current
60
+ }
61
+ end
62
+
63
+ def extract_metadata
64
+ payload = @event.payload || {}
65
+ exception_obj = payload[:exception_object]
66
+
67
+ {
68
+ job_id: payload.dig(:job, :job_id),
69
+ job_class: payload.dig(:job, :class_name) || payload.dig(:job, :job_class),
70
+ queue_name: payload.dig(:job, :queue_name),
71
+ arguments: payload.dig(:job, :arguments),
72
+ executions: payload.dig(:job, :executions),
73
+ exception_class: exception_obj&.class&.name || payload[:exception]&.first,
74
+ exception_message: exception_obj&.message || payload[:exception]&.last,
75
+ enqueued_at: payload.dig(:job, :enqueued_at),
76
+ priority: payload.dig(:job, :priority)
77
+ }.compact
78
+ rescue => e
79
+ Rails.logger.warn "[SolidObserver] Failed to extract metadata: #{e.message}" if defined?(Rails)
80
+ {}
81
+ end
82
+
83
+ def increment_metric
84
+ period = Time.current.beginning_of_hour
85
+ QueueMetric.increment(metric: @metric_name, period: period)
86
+ rescue => e
87
+ Rails.logger.warn "[SolidObserver] Metric increment failed: #{e.message}" if defined?(Rails)
88
+ end
89
+
90
+ def handle_error(exception)
91
+ return unless defined?(Rails) && Rails.logger
92
+ Rails.logger.warn "[SolidObserver] Event recording failed: #{exception.message}"
93
+ end
94
+ end
95
+ end
96
+ end