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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SolidObserver Configuration
4
+ # See https://github.com/bart-oz/solid_observer for more details
5
+
6
+ SolidObserver.configure do |config|
7
+ # UI Settings
8
+ # Enable the web UI for viewing events and metrics
9
+ # Recommended: false in production, true in development/staging
10
+ config.ui_enabled = !Rails.env.production?
11
+
12
+ # Authentication for UI (when ui_enabled = true)
13
+ # config.http_basic_auth_enabled = true
14
+ # config.http_basic_auth_user = "admin"
15
+ # config.http_basic_auth_password = "secret"
16
+
17
+ # Base controller for UI (customize authorization)
18
+ # config.ui_base_controller = "ApplicationController"
19
+
20
+ # === Queue Observability (v0.1.0) ===
21
+ config.observe_queue = true
22
+
23
+ # === Cache Observability (Coming in v0.2.0+) ===
24
+ # config.observe_cache = true
25
+ # config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
26
+
27
+ # === Cable Observability (Coming in v0.2.0+) ===
28
+ # config.observe_cable = true
29
+
30
+ # Data Retention
31
+ config.event_retention = 30.days # How long to keep event records
32
+ config.metrics_retention = 90.days # How long to keep aggregated metrics
33
+
34
+ # Database Limits (prevent unlimited growth)
35
+ config.max_db_size = 1.gigabyte
36
+ config.warning_threshold = 0.8 # Warn when DB reaches 80% of max_db_size
37
+
38
+ # Performance Settings
39
+ config.buffer_size = 1000 # Events to buffer before flushing
40
+ config.flush_interval = 10.seconds
41
+
42
+ # Sampling (reduce overhead in high-traffic apps)
43
+ config.sampling_rate = 1.0 # 1.0 = 100% (capture all events)
44
+
45
+ # Correlation ID (for distributed tracing)
46
+ # Integrate with your APM tool (Datadog, Sentry, OpenTelemetry, etc.)
47
+ # config.correlation_id_generator = -> {
48
+ # # Example for Datadog APM
49
+ # Datadog::Tracing.active_trace&.id
50
+ #
51
+ # # Example for Sentry
52
+ # Sentry.get_current_scope&.transaction&.trace_id
53
+ #
54
+ # # Example for OpenTelemetry
55
+ # OpenTelemetry::Trace.current_span&.context&.trace_id
56
+ # }
57
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class BaseEvent < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ # connects_to is configured by the engine after Rails initializes
8
+ # See lib/solid_observer/engine.rb
9
+ end
10
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ # BaseMetric provides the foundation for time-series metrics storage.
5
+ #
6
+ # NOTE: Metrics functionality is planned for v0.2.0. The database connection
7
+ # will be configured by the Engine (similar to BaseEvent) when metrics are
8
+ # fully implemented.
9
+ #
10
+ class BaseMetric < ActiveRecord::Base
11
+ self.abstract_class = true
12
+ self.table_name = "solid_observer_metrics"
13
+
14
+ PERIOD_TYPES = %w[minute hour day].freeze
15
+
16
+ validates :metric_name, presence: true
17
+ validates :value, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
18
+ validates :period_start, presence: true
19
+ validates :period_type, presence: true, inclusion: {in: PERIOD_TYPES}
20
+
21
+ scope :for_metric, ->(name) { where(metric_name: name) }
22
+ scope :hourly, -> { where(period_type: "hour") }
23
+ scope :daily, -> { where(period_type: "day") }
24
+ scope :minutely, -> { where(period_type: "minute") }
25
+ scope :since, ->(time) { where("period_start >= ?", time) }
26
+ scope :between, ->(start_time, end_time) { where(period_start: start_time..end_time) }
27
+
28
+ class << self
29
+ def increment(metric:, period: Time.current.beginning_of_hour, period_type: "hour", by: 1)
30
+ record = find_or_create_by!(
31
+ metric_name: metric,
32
+ period_start: period,
33
+ period_type: period_type
34
+ )
35
+ record.increment!(:value, by)
36
+ record
37
+ rescue ActiveRecord::RecordNotUnique
38
+ retry
39
+ end
40
+
41
+ def record(metric:, value:, period: Time.current.beginning_of_hour, period_type: "hour")
42
+ upsert(
43
+ {
44
+ metric_name: metric,
45
+ value: value,
46
+ period_start: period,
47
+ period_type: period_type
48
+ },
49
+ unique_by: [:metric_name, :period_start, :period_type]
50
+ )
51
+ find_by!(
52
+ metric_name: metric,
53
+ period_start: period,
54
+ period_type: period_type
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module CLI
5
+ class Base
6
+ def self.call(*args, **kwargs)
7
+ new.call(*args, **kwargs)
8
+ end
9
+
10
+ def call
11
+ raise NotImplementedError, "#{self.class} must implement #call"
12
+ end
13
+
14
+ private
15
+
16
+ def output(text, color: nil)
17
+ text = colorize(text, color) if color && color_enabled?
18
+ puts text
19
+ end
20
+
21
+ def error(text)
22
+ output(text, color: :red)
23
+ end
24
+
25
+ def success(text)
26
+ output(text, color: :green)
27
+ end
28
+
29
+ def warning(text)
30
+ output(text, color: :yellow)
31
+ end
32
+
33
+ def info(text)
34
+ output(text, color: :blue)
35
+ end
36
+
37
+ def table(headers:, rows:)
38
+ return if rows.empty?
39
+
40
+ widths = calculate_column_widths(headers, rows)
41
+
42
+ output(format_table_row(headers, widths), color: :cyan)
43
+ output(separator_line(widths), color: :cyan)
44
+
45
+ rows.each do |row|
46
+ output(format_table_row(row, widths))
47
+ end
48
+ end
49
+
50
+ def confirm(question, default: true)
51
+ prompt = default ? "(Y/n)" : "(y/N)"
52
+ output("#{question} #{prompt} ", color: :yellow)
53
+ print "> "
54
+
55
+ response = $stdin.gets&.strip&.downcase
56
+
57
+ return default if response.nil? || response.empty?
58
+
59
+ response.start_with?("y")
60
+ end
61
+
62
+ def color_enabled?
63
+ $stdout.tty?
64
+ end
65
+
66
+ def colorize(text, color)
67
+ return text unless color
68
+
69
+ color_code = COLORS[color]
70
+ return text unless color_code
71
+
72
+ "\e[#{color_code}m#{text}\e[0m"
73
+ end
74
+
75
+ def calculate_column_widths(headers, rows)
76
+ all_rows = [headers] + rows
77
+ all_rows.transpose.map { |column| column.map(&:to_s).map(&:length).max }
78
+ end
79
+
80
+ def format_table_row(row, widths)
81
+ row.map.with_index { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ")
82
+ end
83
+
84
+ def separator_line(widths)
85
+ widths.map { |width| "-" * width }.join(" ")
86
+ end
87
+
88
+ COLORS = {
89
+ red: 31,
90
+ green: 32,
91
+ yellow: 33,
92
+ blue: 34,
93
+ magenta: 35,
94
+ cyan: 36
95
+ }.freeze
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module CLI
5
+ class Jobs < Base
6
+ def list(status: nil, queue: nil, job_class: nil, limit: 20)
7
+ return error("SolidQueue is not available") unless solid_queue_available?
8
+
9
+ jobs = fetch_jobs(status: status, queue: queue, job_class: job_class, limit: limit)
10
+
11
+ return warning("No jobs found matching the criteria") if jobs.empty?
12
+
13
+ print_jobs_table(jobs)
14
+ end
15
+
16
+ def show(job_id)
17
+ return error("SolidQueue is not available") unless solid_queue_available?
18
+
19
+ job = find_job(job_id)
20
+ return unless job
21
+
22
+ print_job_details(job)
23
+ end
24
+
25
+ def retry_job(job_id)
26
+ return error("SolidQueue is not available") unless solid_queue_available?
27
+
28
+ job = find_failed_job(job_id)
29
+ return unless job
30
+
31
+ return info("Retry cancelled") unless confirm("Retry job #{job_id}?")
32
+
33
+ job.retry
34
+ success("✓ Job #{job_id} has been queued for retry")
35
+ end
36
+
37
+ def discard(job_id)
38
+ return error("SolidQueue is not available") unless solid_queue_available?
39
+
40
+ job = find_failed_job(job_id)
41
+ return unless job
42
+
43
+ return info("Discard cancelled") unless confirm("Discard job #{job_id}? This cannot be undone.", default: false)
44
+
45
+ job.discard
46
+ success("✓ Job #{job_id} has been discarded")
47
+ end
48
+
49
+ private
50
+
51
+ def solid_queue_available?
52
+ SolidObserver::QueueStats.solid_queue_available?
53
+ end
54
+
55
+ def fetch_jobs(status:, queue:, job_class:, limit:)
56
+ scope = scope_for_status(status)
57
+ scope = scope.joins(:job) if job_class || needs_job_join?(status, queue)
58
+ scope = apply_queue_filter(scope, status, queue)
59
+ scope = scope.where("solid_queue_jobs.class_name = ?", job_class) if job_class
60
+ scope.order(created_at: :desc).limit(limit.to_i).includes(:job).to_a
61
+ end
62
+
63
+ def needs_job_join?(status, queue)
64
+ queue && %w[failed claimed].include?(status&.to_s&.downcase)
65
+ end
66
+
67
+ def apply_queue_filter(scope, status, queue)
68
+ return scope unless queue
69
+
70
+ case status&.to_s&.downcase
71
+ when "failed", "claimed"
72
+ scope.where("solid_queue_jobs.queue_name = ?", queue)
73
+ else
74
+ scope.where(queue_name: queue)
75
+ end
76
+ end
77
+
78
+ def scope_for_status(status)
79
+ case status&.to_s&.downcase
80
+ when "ready" then SolidQueue::ReadyExecution.all
81
+ when "scheduled" then SolidQueue::ScheduledExecution.all
82
+ when "claimed" then SolidQueue::ClaimedExecution.all
83
+ when "failed" then SolidQueue::FailedExecution.all
84
+ else SolidQueue::ReadyExecution.all
85
+ end
86
+ end
87
+
88
+ def find_job(job_id)
89
+ job = find_in_execution_tables(job_id)
90
+ return job if job
91
+
92
+ error("Job #{job_id} not found")
93
+ nil
94
+ end
95
+
96
+ def find_failed_job(job_id)
97
+ job = SolidQueue::FailedExecution.find_by(id: job_id)
98
+ return job if job
99
+
100
+ error("Failed job #{job_id} not found")
101
+ nil
102
+ end
103
+
104
+ def find_in_execution_tables(job_id)
105
+ SolidQueue::ReadyExecution.find_by(id: job_id) ||
106
+ SolidQueue::ScheduledExecution.find_by(id: job_id) ||
107
+ SolidQueue::ClaimedExecution.find_by(id: job_id) ||
108
+ SolidQueue::FailedExecution.find_by(id: job_id)
109
+ end
110
+
111
+ def print_jobs_table(jobs)
112
+ print_section_header("📋 Jobs")
113
+ table(
114
+ headers: ["ID", "Queue", "Class", "Status", "Created At"],
115
+ rows: jobs.map { |job| format_job_row(job) }
116
+ )
117
+ output("")
118
+ end
119
+
120
+ def format_job_row(execution)
121
+ job = execution.job
122
+ [
123
+ execution.id.to_s,
124
+ job&.queue_name || execution.try(:queue_name) || "N/A",
125
+ job&.class_name || "N/A",
126
+ job_status(execution),
127
+ format_time(execution.created_at)
128
+ ]
129
+ end
130
+
131
+ def job_status(execution)
132
+ return "Ready" if execution.is_a?(SolidQueue::ReadyExecution)
133
+ return "Scheduled" if execution.is_a?(SolidQueue::ScheduledExecution)
134
+ return "Claimed" if execution.is_a?(SolidQueue::ClaimedExecution)
135
+ return "Failed" if execution.is_a?(SolidQueue::FailedExecution)
136
+
137
+ "Unknown"
138
+ end
139
+
140
+ def format_time(time)
141
+ time.strftime("%Y-%m-%d %H:%M:%S")
142
+ end
143
+
144
+ def print_job_details(execution)
145
+ print_section_header("📄 Job Details")
146
+
147
+ details = build_job_details(execution)
148
+
149
+ table(
150
+ headers: ["Attribute", "Value"],
151
+ rows: details
152
+ )
153
+ output("")
154
+ end
155
+
156
+ def build_job_details(execution)
157
+ job = execution.job
158
+ details = [
159
+ ["ID", execution.id],
160
+ ["Job ID", job&.id || "N/A"],
161
+ ["Queue", job&.queue_name || execution.try(:queue_name) || "N/A"],
162
+ ["Class", job&.class_name || "N/A"],
163
+ ["Status", job_status(execution)],
164
+ ["Created At", format_time(execution.created_at)],
165
+ ["Priority", execution.try(:priority) || job&.priority || "N/A"]
166
+ ]
167
+
168
+ add_scheduled_details(details, execution)
169
+ add_error_details(details, execution)
170
+
171
+ details
172
+ end
173
+
174
+ def add_scheduled_details(details, execution)
175
+ return unless execution.is_a?(SolidQueue::ScheduledExecution)
176
+
177
+ details << ["Scheduled At", format_time(execution.scheduled_at)]
178
+ end
179
+
180
+ def add_error_details(details, execution)
181
+ return unless execution.is_a?(SolidQueue::FailedExecution)
182
+ return unless execution.error
183
+
184
+ details << ["Error", execution.error.exception_class]
185
+ details << ["Error Message", execution.error.message]
186
+ end
187
+
188
+ def print_section_header(title)
189
+ output("\n#{title}", color: :cyan)
190
+ output("=" * 80, color: :cyan)
191
+ output("")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module CLI
5
+ class Status < Base
6
+ def call
7
+ print_header
8
+ print_queue_stats
9
+ end
10
+
11
+ private
12
+
13
+ def print_header
14
+ output("\n📊 SolidObserver Status", color: :cyan)
15
+ output("=" * 50, color: :cyan)
16
+ output("")
17
+ end
18
+
19
+ def print_queue_stats
20
+ stats = SolidObserver::QueueStats.snapshot
21
+
22
+ if stats[:available]
23
+ print_queue_table(stats)
24
+ print_queue_depths(stats[:queues]) if stats[:queues].any?
25
+ else
26
+ warning("⚠️ SolidQueue not available: #{stats[:error]}")
27
+ end
28
+ end
29
+
30
+ def print_queue_table(stats)
31
+ output("🚀 Solid Queue", color: :green)
32
+ output("")
33
+
34
+ table(
35
+ headers: ["Metric", "Value"],
36
+ rows: [
37
+ ["Ready", stats[:ready]],
38
+ ["Scheduled", stats[:scheduled]],
39
+ ["Claimed", stats[:claimed]],
40
+ ["Failed", stats[:failed]],
41
+ ["Workers", stats[:workers]]
42
+ ]
43
+ )
44
+ output("")
45
+ end
46
+
47
+ def print_queue_depths(queues)
48
+ output("📋 Queue Depths", color: :green)
49
+ output("")
50
+
51
+ table(
52
+ headers: ["Queue", "Jobs"],
53
+ rows: queues.sort_by { |name, _count| name }.map { |name, count| [name, count] }
54
+ )
55
+ output("")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module CLI
5
+ class Storage < Base
6
+ def call
7
+ print_section_header("💾 Storage Status")
8
+
9
+ current_stats = gather_storage_stats
10
+
11
+ if current_stats[:error]
12
+ error(current_stats[:error])
13
+ return
14
+ end
15
+
16
+ print_storage_table(current_stats)
17
+ print_configuration
18
+ end
19
+
20
+ private
21
+
22
+ def gather_storage_stats
23
+ {
24
+ db_size_bytes: calculate_database_size,
25
+ event_count: QueueEvent.count,
26
+ max_size_bytes: SolidObserver.config.max_db_size
27
+ }
28
+ rescue => e
29
+ {error: "Failed to gather storage stats: #{e.message}"}
30
+ end
31
+
32
+ def calculate_database_size
33
+ db_config = QueueEvent.connection_db_config
34
+ db_path = db_config.database
35
+
36
+ return 0 unless File.exist?(db_path)
37
+
38
+ File.size(db_path)
39
+ rescue => e
40
+ warning("Could not calculate database size: #{e.message}")
41
+ 0
42
+ end
43
+
44
+ def print_storage_table(stats)
45
+ size_mb = bytes_to_mb(stats[:db_size_bytes])
46
+ percentage = calculate_percentage(stats[:db_size_bytes], stats[:max_size_bytes])
47
+ status = status_indicator(percentage)
48
+
49
+ table(
50
+ headers: ["Component", "Size", "Events", "Usage", "Status"],
51
+ rows: [[
52
+ "Queue",
53
+ format_size(size_mb),
54
+ format_number(stats[:event_count]),
55
+ "#{percentage}%",
56
+ status
57
+ ]]
58
+ )
59
+
60
+ output("")
61
+ end
62
+
63
+ def print_configuration
64
+ retention_days = (SolidObserver.config.event_retention / 1.day).to_i
65
+ max_size_mb = bytes_to_mb(SolidObserver.config.max_db_size)
66
+
67
+ info("Configuration:")
68
+ output(" Retention: #{retention_days} days")
69
+ output(" Max size: #{format_size(max_size_mb)} per database")
70
+ output(" Warning: #{(SolidObserver.config.warning_threshold * 100).to_i}% threshold")
71
+ output("")
72
+ end
73
+
74
+ def bytes_to_mb(bytes)
75
+ (bytes / 1_048_576.0).round(2)
76
+ end
77
+
78
+ def calculate_percentage(current, max)
79
+ return 0.0 if max.zero?
80
+
81
+ ((current.to_f / max) * 100).round(2)
82
+ end
83
+
84
+ def status_indicator(percentage)
85
+ threshold = SolidObserver.config.warning_threshold * 100
86
+
87
+ if percentage >= threshold
88
+ "⚠️ Warning"
89
+ else
90
+ "✓ OK"
91
+ end
92
+ end
93
+
94
+ def format_size(size_mb)
95
+ if size_mb >= 1024
96
+ "#{(size_mb / 1024.0).round(2)} GB"
97
+ else
98
+ "#{size_mb} MB"
99
+ end
100
+ end
101
+
102
+ def format_number(number)
103
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
104
+ end
105
+
106
+ def print_section_header(title)
107
+ output("")
108
+ output(title, color: :cyan)
109
+ output("=" * 50, color: :cyan)
110
+ output("")
111
+ end
112
+ end
113
+ end
114
+ end