solid_observer 0.1.1 → 0.4.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +241 -59
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  6. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  7. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  8. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  9. data/app/controllers/solid_observer/application_controller.rb +69 -0
  10. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  11. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  12. data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
  13. data/app/controllers/solid_observer/events_controller.rb +50 -0
  14. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  15. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  16. data/app/helpers/solid_observer/application_helper.rb +244 -0
  17. data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
  18. data/app/models/solid_observer/cache_event.rb +15 -0
  19. data/app/models/solid_observer/cache_metric.rb +14 -0
  20. data/app/models/solid_observer/queue_event.rb +134 -0
  21. data/app/models/solid_observer/queue_metric.rb +1 -1
  22. data/app/models/solid_observer/storage_info.rb +4 -1
  23. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  24. data/app/views/layouts/solid_observer/application.html.erb +597 -0
  25. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  26. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  27. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  28. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  29. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  30. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  31. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  32. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  33. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  34. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  35. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  36. data/app/views/solid_observer/dashboard/index.html.erb +143 -0
  37. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  38. data/app/views/solid_observer/events/index.html.erb +53 -0
  39. data/app/views/solid_observer/events/show.html.erb +47 -0
  40. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  41. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  42. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  43. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  44. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  45. data/app/views/solid_observer/storages/show.html.erb +71 -0
  46. data/bin/quality_gate +95 -0
  47. data/config/routes.rb +22 -0
  48. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  49. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  50. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  51. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  52. data/lib/generators/solid_observer/install_generator.rb +12 -25
  53. data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
  54. data/lib/solid_observer/base_metric.rb +1 -1
  55. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  56. data/lib/solid_observer/cache_subscriber.rb +47 -0
  57. data/lib/solid_observer/chart_buffer.rb +83 -0
  58. data/lib/solid_observer/cli/base.rb +2 -2
  59. data/lib/solid_observer/cli/jobs.rb +2 -2
  60. data/lib/solid_observer/cli/status.rb +20 -2
  61. data/lib/solid_observer/cli/storage.rb +48 -44
  62. data/lib/solid_observer/configuration.rb +67 -38
  63. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  64. data/lib/solid_observer/engine.rb +110 -18
  65. data/lib/solid_observer/params/events_filter.rb +37 -0
  66. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  67. data/lib/solid_observer/queries/events_query.rb +27 -0
  68. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  69. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  70. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  71. data/lib/solid_observer/queue_stats.rb +165 -19
  72. data/lib/solid_observer/services/cache_operations.rb +115 -0
  73. data/lib/solid_observer/services/cache_stats.rb +329 -0
  74. data/lib/solid_observer/services/cleanup_storage.rb +73 -41
  75. data/lib/solid_observer/services/database_size.rb +91 -0
  76. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  77. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  78. data/lib/solid_observer/services/install_migrations.rb +49 -0
  79. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  80. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  81. data/lib/solid_observer/services/record_event.rb +51 -14
  82. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  83. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  84. data/lib/solid_observer/subscriber.rb +15 -8
  85. data/lib/solid_observer/version.rb +1 -1
  86. data/lib/solid_observer.rb +7 -0
  87. data/lib/tasks/solid_observer.rake +39 -2
  88. metadata +77 -1
@@ -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.
@@ -6,16 +6,19 @@ module SolidObserver
6
6
 
7
7
  MB_TO_BYTES = 1_048_576
8
8
  GB_TO_BYTES = 1_073_741_824
9
+ COMPONENTS = %w[queue_observer cache_observer solid_cache].freeze
9
10
 
10
11
  validates :db_size_bytes, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
11
12
  validates :event_count, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
12
13
  validates :recorded_at, presence: true
14
+ validates :component, presence: true, inclusion: {in: COMPONENTS}
13
15
 
14
16
  scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
15
17
  scope :since, ->(time) { where("recorded_at >= ?", time) }
16
18
 
17
- def self.record_snapshot(db_size:, event_count:)
19
+ def self.record_snapshot(db_size:, event_count:, component: "queue_observer")
18
20
  create!(
21
+ component: component,
19
22
  db_size_bytes: db_size || 0,
20
23
  event_count: event_count,
21
24
  recorded_at: Time.current
@@ -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