solid_observer 0.1.1 → 0.3.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +157 -28
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  6. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  7. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  8. data/app/controllers/solid_observer/application_controller.rb +69 -0
  9. data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
  10. data/app/controllers/solid_observer/events_controller.rb +50 -0
  11. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  12. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  13. data/app/helpers/solid_observer/application_helper.rb +95 -0
  14. data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
  15. data/app/models/solid_observer/queue_event.rb +134 -0
  16. data/app/models/solid_observer/queue_metric.rb +1 -1
  17. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  18. data/app/views/layouts/solid_observer/application.html.erb +470 -0
  19. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  20. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  21. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  22. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  23. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  24. data/app/views/solid_observer/dashboard/index.html.erb +113 -0
  25. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  26. data/app/views/solid_observer/events/index.html.erb +53 -0
  27. data/app/views/solid_observer/events/show.html.erb +47 -0
  28. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  29. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  30. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  31. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  32. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  33. data/app/views/solid_observer/storages/show.html.erb +39 -0
  34. data/bin/quality_gate +95 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  37. data/lib/generators/solid_observer/install_generator.rb +12 -25
  38. data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
  39. data/lib/solid_observer/base_metric.rb +1 -1
  40. data/lib/solid_observer/chart_buffer.rb +83 -0
  41. data/lib/solid_observer/cli/base.rb +2 -2
  42. data/lib/solid_observer/cli/jobs.rb +2 -2
  43. data/lib/solid_observer/cli/status.rb +20 -2
  44. data/lib/solid_observer/cli/storage.rb +41 -40
  45. data/lib/solid_observer/configuration.rb +47 -37
  46. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  47. data/lib/solid_observer/engine.rb +72 -17
  48. data/lib/solid_observer/params/events_filter.rb +37 -0
  49. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  50. data/lib/solid_observer/queries/events_query.rb +27 -0
  51. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  52. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  53. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  54. data/lib/solid_observer/queue_stats.rb +165 -19
  55. data/lib/solid_observer/services/cleanup_storage.rb +58 -42
  56. data/lib/solid_observer/services/database_size.rb +86 -0
  57. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  58. data/lib/solid_observer/services/install_migrations.rb +49 -0
  59. data/lib/solid_observer/services/record_event.rb +51 -14
  60. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  61. data/lib/solid_observer/subscriber.rb +15 -8
  62. data/lib/solid_observer/version.rb +1 -1
  63. data/lib/solid_observer.rb +7 -0
  64. data/lib/tasks/solid_observer.rake +10 -2
  65. metadata +55 -1
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class JobsController < ApplicationController
5
+ include Paginatable
6
+ include RequireSolidQueue
7
+
8
+ PER_PAGE = 25
9
+ CACHE_KEY_QUEUES = "solid_observer/jobs/available_queues"
10
+ CACHE_KEY_JOB_CLASSES = "solid_observer/jobs/available_job_classes"
11
+
12
+ def index
13
+ filter = Params::JobsFilter.from_params(params)
14
+ @status = filter.status
15
+ @queue_name = filter.queue_name
16
+ @job_class = filter.job_class
17
+ @page = filter.page
18
+ result = Queries::JobExecutionsQuery.new(filter).call
19
+ offset = paginate_scope(result, per_page: PER_PAGE)
20
+ @jobs = if result.is_a?(Array)
21
+ result.drop(offset).first(PER_PAGE)
22
+ else
23
+ result.limit(PER_PAGE).offset(offset).includes(:job).to_a
24
+ end
25
+ @available_queues = fetch_available_queues
26
+ @available_job_classes = fetch_available_job_classes
27
+ end
28
+
29
+ def show
30
+ @execution = find_execution_for_show
31
+ return redirect_to jobs_path, alert: "Job not found" unless @execution
32
+
33
+ assign_show_presenter
34
+ end
35
+
36
+ def retry
37
+ id = params[:id]
38
+ execution = Queries::ExecutionFinder.find_failed(id)
39
+ return redirect_to(jobs_path, alert: "Failed job not found") unless execution
40
+
41
+ execution.retry
42
+ redirect_to jobs_path(status: "failed"), notice: "Job #{id} queued for retry"
43
+ end
44
+
45
+ def discard
46
+ id = params[:id]
47
+ execution = Queries::ExecutionFinder.find_failed(id)
48
+ return redirect_to(jobs_path, alert: "Failed job not found") unless execution
49
+
50
+ execution.discard
51
+ redirect_to jobs_path(status: "failed"), notice: "Job #{id} discarded"
52
+ end
53
+
54
+ private
55
+
56
+ def find_execution_for_show
57
+ id = params[:id]
58
+ Queries::ExecutionFinder.find_by_status(id, params[:status]) ||
59
+ Queries::ExecutionFinder.find_any(id)
60
+ end
61
+
62
+ def assign_show_presenter
63
+ @presenter = ExecutionPresenter.new(@execution)
64
+ @job = @presenter.job
65
+ @status = @presenter.status
66
+ end
67
+
68
+ def fetch_available_queues
69
+ Rails.cache.fetch(CACHE_KEY_QUEUES, expires_in: SolidObserver.config.filter_cache_ttl) do
70
+ next [] unless defined?(SolidQueue::Queue)
71
+ SolidQueue::Queue.all.map(&:name)
72
+ end
73
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
74
+ []
75
+ end
76
+
77
+ def fetch_available_job_classes
78
+ Rails.cache.fetch(CACHE_KEY_JOB_CLASSES, expires_in: SolidObserver.config.filter_cache_ttl) do
79
+ SolidQueue::Job.distinct.pluck(:class_name).compact.sort
80
+ end
81
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
82
+ []
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class StoragesController < ApplicationController
5
+ include RequirePersistenceMode
6
+
7
+ def show
8
+ @current_storage = SolidObserver::StorageInfo.order(recorded_at: :desc).first
9
+ @storage_history = SolidObserver::StorageInfo.recent(20)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module ApplicationHelper
5
+ STATUS_COLORS = {
6
+ "completed" => "success",
7
+ "ready" => "success",
8
+ "failed" => "danger",
9
+ "retry_stopped" => "danger",
10
+ "scheduled" => "warning",
11
+ "claimed" => "warning",
12
+ "enqueued" => "info",
13
+ "discarded" => "info"
14
+ }.freeze
15
+ DURATION_SEMANTICS = {
16
+ "job_enqueued" => "Time spent in the ActiveJob enqueue call (Rails internal; typically sub-millisecond to single-digit ms)",
17
+ "job_completed" => "Time spent performing the job",
18
+ "job_failed" => "Time spent performing the job before the exception was raised",
19
+ "job_discarded" => "Time before discard decision was made"
20
+ }.freeze
21
+ STABILITY_STATES = {
22
+ stable: {label: "Stable", tone: "success"},
23
+ degraded: {label: "Degraded", tone: "warning"},
24
+ critical: {label: "Critical", tone: "danger"}
25
+ }.freeze
26
+
27
+ def execution_status(execution)
28
+ ExecutionPresenter.new(execution).status
29
+ end
30
+
31
+ def format_duration(seconds)
32
+ return "0ms" if seconds.to_f.zero?
33
+
34
+ if seconds < 1
35
+ "#{(seconds * 1000).round}ms"
36
+ else
37
+ "#{"%.1f" % seconds}s"
38
+ end
39
+ end
40
+
41
+ def status_badge(status)
42
+ status_str = status.to_s
43
+ color = STATUS_COLORS.fetch(status_str, "default")
44
+ content_tag(:span, status_str.humanize, class: "so-badge so-badge--#{color}")
45
+ end
46
+
47
+ def duration_with_semantic(value, event_type)
48
+ return content_tag(:span, "—", class: "so-text-muted") unless value
49
+
50
+ content_tag(:abbr, format_duration(value), title: DURATION_SEMANTICS.fetch(event_type.to_s))
51
+ end
52
+
53
+ def mode_badge
54
+ config = SolidObserver.config
55
+ color = config.persistence_mode? ? "info" : "warning"
56
+ content_tag(:span, config.storage_mode.to_s.capitalize, class: "so-badge so-badge--#{color}")
57
+ end
58
+
59
+ def turbo_frame_tag(id, **options, &block)
60
+ return super if defined?(super)
61
+
62
+ content = options.delete(:content)
63
+ body = block_given? ? capture(&block) : content
64
+
65
+ content_tag(:"turbo-frame", body, **options.merge(id: id).compact)
66
+ end
67
+
68
+ def stability_state(stats)
69
+ return :critical if stats[:failed_last_hour].to_i.positive?
70
+ return :degraded if stats[:failed_last_24h].to_i.positive?
71
+
72
+ :stable
73
+ end
74
+
75
+ def stability_badge(stats)
76
+ meta = STABILITY_STATES.fetch(stability_state(stats))
77
+ dot = tag.svg(tag.circle(r: 3, cx: 3, cy: 3),
78
+ class: "so-badge__dot", viewBox: "0 0 6 6", "aria-hidden": "true")
79
+ tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
80
+ safe_join([dot, meta[:label]], " ")
81
+ end
82
+ end
83
+
84
+ def stability_detail(stats)
85
+ failures_24h = stats[:failed_last_24h].to_i
86
+ return "No failures in the last 24h" if failures_24h.zero?
87
+
88
+ "#{pluralize(failures_24h, "failure")} in the last 24h, latest #{latest_failure_phrase(stats[:latest_failure_at])}"
89
+ end
90
+
91
+ def latest_failure_phrase(timestamp)
92
+ timestamp ? "#{time_ago_in_words(timestamp)} ago" : "unknown"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mirrors live_poll.js Sparkline.render — keep in sync.
4
+ module SolidObserver
5
+ module DashboardHelper
6
+ SVG_W = 120
7
+ SVG_H = 32
8
+
9
+ def spark_points(series, width: SVG_W, height: SVG_H)
10
+ return "" if series.blank?
11
+
12
+ t_min = series.first[:t]
13
+ t_max = series.last[:t]
14
+ v_max = [series.max_by { |point| point[:v] }[:v], 1].max
15
+ time_span = t_max - t_min
16
+
17
+ series.map { |point|
18
+ val = point[:v]
19
+ point_x = time_span.zero? ? width / 2.0 : ((point[:t] - t_min).to_f / time_span) * (width - 2) + 1
20
+ point_y = height - 1 - (val.to_f / v_max) * (height - 2)
21
+ format("%.1f,%.1f", point_x, point_y)
22
+ }.join(" ")
23
+ end
24
+
25
+ RANGE_LABELS = {
26
+ "15m" => "in last 15m",
27
+ "30m" => "in last 30m",
28
+ "1h" => "in last hour",
29
+ "7h" => "in last 7h",
30
+ "1d" => "in last day",
31
+ "7d" => "in last 7d",
32
+ "14d" => "in last 14d"
33
+ }.freeze
34
+
35
+ def range_label(range_key)
36
+ RANGE_LABELS.fetch(range_key, "in selected range")
37
+ end
38
+ end
39
+ end
@@ -10,6 +10,7 @@ module SolidObserver
10
10
  job_failed
11
11
  job_discarded
12
12
  ].freeze
13
+ DISTINCT_FILTER_LIMIT = 500
13
14
 
14
15
  validates :event_type, presence: true, inclusion: {in: EVENT_TYPES}
15
16
  validates :recorded_at, presence: true
@@ -19,5 +20,138 @@ module SolidObserver
19
20
  scope :by_event_type, ->(event_type) { where(event_type: event_type) }
20
21
  scope :since, ->(time) { where("recorded_at >= ?", time) }
21
22
  scope :before, ->(time) { where("recorded_at < ?", time) }
23
+ scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
24
+ scope :recent_failures, ->(limit = 5) { by_event_type("job_failed").order(recorded_at: :desc).limit(limit) }
25
+ scope :distinct_job_classes, -> {
26
+ where("recorded_at >= ?", SolidObserver.config.event_retention.ago)
27
+ .where.not(job_class: nil)
28
+ .distinct
29
+ .limit(DISTINCT_FILTER_LIMIT)
30
+ .pluck(:job_class)
31
+ .sort
32
+ }
33
+ scope :distinct_queue_names, -> {
34
+ where("recorded_at >= ?", SolidObserver.config.event_retention.ago)
35
+ .where.not(queue_name: nil)
36
+ .distinct
37
+ .limit(DISTINCT_FILTER_LIMIT)
38
+ .pluck(:queue_name)
39
+ .sort
40
+ }
41
+
42
+ def self.performed_count_last(duration)
43
+ by_event_type("job_completed").since(duration.ago).count
44
+ end
45
+
46
+ def self.failed_count_last(duration)
47
+ by_event_type("job_failed").since(duration.ago).count
48
+ end
49
+
50
+ def self.enqueue_rate_per_minute(window: 5.minutes)
51
+ count = by_event_type("job_enqueued").since(window.ago).count
52
+ return 0.0 if count.zero?
53
+
54
+ (count.to_f / (window.to_f / 60.0)).round(1)
55
+ end
56
+
57
+ def self.enqueued_count_last(duration)
58
+ by_event_type("job_enqueued").since(duration.ago).count
59
+ end
60
+
61
+ def self.avg_duration_last(duration)
62
+ by_event_type("job_completed").since(duration.ago).average(:duration).to_f
63
+ end
64
+
65
+ def self.count_by_queue_and_event_type(window:, event_type:)
66
+ since(window.ago)
67
+ .where(event_type: event_type)
68
+ .where.not(queue_name: nil)
69
+ .group(:queue_name)
70
+ .count
71
+ end
72
+
73
+ def self.count_by_time_bucket(event_type:, window:, bucket_seconds:)
74
+ context = build_bucket_context(window: window, bucket_seconds: bucket_seconds)
75
+ return [] unless context
76
+
77
+ counts_by_bucket = fetch_counts_by_bucket(event_type: event_type, context: context)
78
+ fill_missing_buckets(context: context, counts_by_bucket: counts_by_bucket)
79
+ end
80
+
81
+ class << self
82
+ private
83
+
84
+ def build_bucket_context(window:, bucket_seconds:)
85
+ bucket_size = bucket_seconds.to_i
86
+ return nil if bucket_size <= 0 || window.to_i <= 0
87
+
88
+ end_time = Time.current
89
+ start_time = end_time - window
90
+
91
+ {
92
+ bucket_size: bucket_size,
93
+ start_time: start_time,
94
+ end_time: end_time,
95
+ start_bucket: align_bucket(start_time.to_i, bucket_size),
96
+ end_bucket: align_bucket(end_time.to_i, bucket_size)
97
+ }
98
+ end
99
+
100
+ def fetch_counts_by_bucket(event_type:, context:)
101
+ rows = fetch_grouped_rows(event_type: event_type, context: context)
102
+ rows.to_h { |row| [row["bucket_time"].to_i, row["bucket_count"].to_i] }
103
+ end
104
+
105
+ def fetch_grouped_rows(event_type:, context:)
106
+ pool = BaseEvent.connection_pool
107
+ query_context = context.merge(
108
+ event_type: event_type,
109
+ adapter: pool.db_config.adapter.to_s.downcase
110
+ )
111
+
112
+ pool.with_connection do |connection|
113
+ connection.select_all(grouped_counts_sql(connection: connection, query_context: query_context)).to_a
114
+ end
115
+ end
116
+
117
+ def grouped_counts_sql(connection:, query_context:)
118
+ <<~SQL.squish
119
+ SELECT #{bucket_time_sql(adapter: query_context[:adapter], bucket_size: query_context[:bucket_size])} AS bucket_time, COUNT(*) AS bucket_count
120
+ FROM #{table_name}
121
+ WHERE event_type = #{connection.quote(query_context[:event_type])}
122
+ AND recorded_at >= #{connection.quote(query_context[:start_time])}
123
+ AND recorded_at <= #{connection.quote(query_context[:end_time])}
124
+ GROUP BY bucket_time
125
+ ORDER BY bucket_time ASC
126
+ SQL
127
+ end
128
+
129
+ def bucket_time_sql(adapter:, bucket_size:)
130
+ case adapter
131
+ when "sqlite3", "sqlite"
132
+ "(CAST(strftime('%s', recorded_at) AS INTEGER) / #{bucket_size}) * #{bucket_size}"
133
+ when "postgresql"
134
+ if bucket_size == 60
135
+ "EXTRACT(EPOCH FROM date_trunc('minute', recorded_at))::bigint"
136
+ else
137
+ "(EXTRACT(EPOCH FROM recorded_at)::bigint / #{bucket_size}) * #{bucket_size}"
138
+ end
139
+ when "mysql2", "trilogy", "mysql"
140
+ "(UNIX_TIMESTAMP(recorded_at) DIV #{bucket_size}) * #{bucket_size}"
141
+ else
142
+ raise ArgumentError, "Unsupported adapter for bucket aggregation: #{adapter.inspect}"
143
+ end
144
+ end
145
+
146
+ def fill_missing_buckets(context:, counts_by_bucket:)
147
+ context[:start_bucket].step(context[:end_bucket], context[:bucket_size]).map do |timestamp|
148
+ {t: timestamp, v: counts_by_bucket.fetch(timestamp, 0)}
149
+ end
150
+ end
151
+
152
+ def align_bucket(value, bucket_size)
153
+ (value / bucket_size) * bucket_size
154
+ end
155
+ end
22
156
  end
23
157
  end
@@ -3,7 +3,7 @@
3
3
  module SolidObserver
4
4
  # QueueMetric provides time-series metrics storage for queue statistics.
5
5
  #
6
- # NOTE: Metrics functionality is planned for v0.2.0. This class currently
6
+ # NOTE: Metrics functionality is planned for a future release. This class currently
7
7
  # serves as a placeholder and inherits base functionality from BaseMetric.
8
8
  # The database connection will be configured by the Engine when metrics
9
9
  # are fully implemented.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class ExecutionPresenter
5
+ STATUS_MAP = {
6
+ "SolidQueue::ReadyExecution" => "ready",
7
+ "SolidQueue::ScheduledExecution" => "scheduled",
8
+ "SolidQueue::ClaimedExecution" => "claimed",
9
+ "SolidQueue::FailedExecution" => "failed"
10
+ }.freeze
11
+
12
+ def initialize(execution)
13
+ @execution = execution
14
+ end
15
+
16
+ def status
17
+ STATUS_MAP.fetch(@execution.class.name, "unknown")
18
+ end
19
+
20
+ def job
21
+ @execution.job
22
+ end
23
+
24
+ def queue_name
25
+ responded, value = value_from(@execution, :queue_name)
26
+ return value if responded
27
+
28
+ value_from(job, :queue_name).last
29
+ end
30
+
31
+ def priority
32
+ responded, value = value_from(@execution, :priority)
33
+ return value if responded
34
+
35
+ value_from(job, :priority).last
36
+ end
37
+
38
+ def to_model
39
+ @execution
40
+ end
41
+
42
+ private
43
+
44
+ def value_from(target, method_name)
45
+ return [false, nil] unless target&.respond_to?(method_name)
46
+
47
+ [true, target.method(method_name).call]
48
+ end
49
+ end
50
+ end