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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/README.md +241 -59
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/assets/stylesheets/solid_observer/application.css +18 -0
- data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
- data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
- data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
- data/app/controllers/solid_observer/application_controller.rb +69 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
- data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
- data/app/controllers/solid_observer/events_controller.rb +50 -0
- data/app/controllers/solid_observer/jobs_controller.rb +85 -0
- data/app/controllers/solid_observer/storages_controller.rb +12 -0
- data/app/helpers/solid_observer/application_helper.rb +244 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
- data/app/models/solid_observer/cache_event.rb +15 -0
- data/app/models/solid_observer/cache_metric.rb +14 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +597 -0
- data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
- data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
- data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
- data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
- data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
- data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
- data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
- data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
- data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
- data/app/views/solid_observer/dashboard/index.html.erb +143 -0
- data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
- data/app/views/solid_observer/events/index.html.erb +53 -0
- data/app/views/solid_observer/events/show.html.erb +47 -0
- data/app/views/solid_observer/jobs/index.html.erb +61 -0
- data/app/views/solid_observer/jobs/show.html.erb +71 -0
- data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
- data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
- data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
- data/app/views/solid_observer/storages/show.html.erb +71 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
- data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
- data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- data/lib/solid_observer/chart_buffer.rb +83 -0
- data/lib/solid_observer/cli/base.rb +2 -2
- data/lib/solid_observer/cli/jobs.rb +2 -2
- data/lib/solid_observer/cli/status.rb +20 -2
- data/lib/solid_observer/cli/storage.rb +48 -44
- data/lib/solid_observer/configuration.rb +67 -38
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +110 -18
- data/lib/solid_observer/params/events_filter.rb +37 -0
- data/lib/solid_observer/params/jobs_filter.rb +35 -0
- data/lib/solid_observer/queries/events_query.rb +27 -0
- data/lib/solid_observer/queries/execution_finder.rb +42 -0
- data/lib/solid_observer/queries/job_executions_query.rb +73 -0
- data/lib/solid_observer/queue_event_buffer.rb +163 -25
- data/lib/solid_observer/queue_stats.rb +165 -19
- data/lib/solid_observer/services/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +73 -41
- data/lib/solid_observer/services/database_size.rb +91 -0
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
- data/lib/solid_observer/services/install_migrations.rb +49 -0
- data/lib/solid_observer/services/record_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- data/lib/solid_observer/services/ui_auth_check.rb +65 -0
- data/lib/solid_observer/subscriber.rb +15 -8
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +7 -0
- data/lib/tasks/solid_observer.rake +39 -2
- 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
|
|
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
|