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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +347 -0
- data/app/jobs/solid_observer/cleanup_job.rb +12 -0
- data/app/models/solid_observer/queue_event.rb +23 -0
- data/app/models/solid_observer/queue_metric.rb +14 -0
- data/app/models/solid_observer/storage_info.rb +36 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/db/migrate/20260115000001_create_solid_observer_queue_events.rb +21 -0
- data/db/migrate/20260115000002_create_solid_observer_metrics.rb +16 -0
- data/db/migrate/20260115000003_create_solid_observer_storage_info.rb +13 -0
- data/lib/generators/solid_observer/install_generator.rb +72 -0
- data/lib/generators/solid_observer/templates/initializer.rb.tt +57 -0
- data/lib/solid_observer/base_event.rb +10 -0
- data/lib/solid_observer/base_metric.rb +59 -0
- data/lib/solid_observer/cli/base.rb +98 -0
- data/lib/solid_observer/cli/jobs.rb +195 -0
- data/lib/solid_observer/cli/status.rb +59 -0
- data/lib/solid_observer/cli/storage.rb +114 -0
- data/lib/solid_observer/configuration.rb +125 -0
- data/lib/solid_observer/correlation_id_resolver.rb +62 -0
- data/lib/solid_observer/engine.rb +60 -0
- data/lib/solid_observer/queue_event_buffer.rb +80 -0
- data/lib/solid_observer/queue_stats.rb +89 -0
- data/lib/solid_observer/services/cleanup_storage.rb +94 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +65 -0
- data/lib/solid_observer/services/record_event.rb +96 -0
- data/lib/solid_observer/subscriber.rb +96 -0
- data/lib/solid_observer/version.rb +7 -0
- data/lib/solid_observer.rb +40 -0
- data/lib/tasks/solid_observer.rake +155 -0
- 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
|