solid_observer 0.4.0 → 0.6.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 +30 -0
- data/README.md +100 -25
- data/app/assets/javascripts/solid_observer/live_poll.js +3 -1
- data/app/controllers/solid_observer/application_controller.rb +1 -0
- data/app/controllers/solid_observer/cable_dashboard_controller.rb +52 -0
- data/app/controllers/solid_observer/cable_operations_controller.rb +16 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +33 -40
- data/app/controllers/solid_observer/dashboard_controller.rb +21 -13
- data/app/controllers/solid_observer/traces_controller.rb +17 -0
- data/app/helpers/solid_observer/application_helper.rb +145 -3
- data/app/models/solid_observer/cable_event.rb +13 -0
- data/app/models/solid_observer/cable_metric.rb +12 -0
- data/app/models/solid_observer/cache_metric.rb +1 -2
- data/app/models/solid_observer/storage_info.rb +1 -1
- data/app/views/layouts/solid_observer/application.html.erb +35 -9
- data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
- data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +39 -0
- data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
- data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
- data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
- data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
- data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
- data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
- data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
- data/app/views/solid_observer/dashboard/index.html.erb +3 -139
- data/app/views/solid_observer/events/index.html.erb +7 -1
- data/app/views/solid_observer/jobs/index.html.erb +1 -0
- data/app/views/solid_observer/storages/show.html.erb +29 -3
- data/app/views/solid_observer/traces/show.html.erb +80 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
- data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
- data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
- data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
- data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
- data/lib/generators/solid_observer/install_generator.rb +8 -1
- data/lib/generators/solid_observer/templates/initializer.rb.tt +24 -5
- data/lib/solid_observer/base_event.rb +1 -1
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/base_record.rb +8 -0
- data/lib/solid_observer/cable_event_buffer.rb +28 -0
- data/lib/solid_observer/cable_metric_buffer.rb +230 -0
- data/lib/solid_observer/cable_subscriber.rb +57 -0
- data/lib/solid_observer/cache_event_buffer.rb +11 -36
- data/lib/solid_observer/cache_metric_buffer.rb +229 -0
- data/lib/solid_observer/chart_buffer.rb +84 -27
- data/lib/solid_observer/cli/health.rb +49 -0
- data/lib/solid_observer/cli/trace.rb +106 -0
- data/lib/solid_observer/configuration.rb +47 -7
- data/lib/solid_observer/correlated.rb +23 -0
- data/lib/solid_observer/engine.rb +46 -28
- data/lib/solid_observer/event_buffer_core.rb +218 -0
- data/lib/solid_observer/queries/trace_query.rb +151 -0
- data/lib/solid_observer/queue_event_buffer.rb +9 -201
- data/lib/solid_observer/services/cable_operations.rb +74 -0
- data/lib/solid_observer/services/cable_stats.rb +385 -0
- data/lib/solid_observer/services/cache_stats.rb +35 -18
- data/lib/solid_observer/services/cleanup_storage.rb +82 -47
- data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
- data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
- data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
- data/lib/solid_observer/services/health_score.rb +85 -0
- data/lib/solid_observer/services/record_cable_event.rb +115 -0
- data/lib/solid_observer/services/record_cable_metric.rb +73 -0
- data/lib/solid_observer/services/record_cache_event.rb +24 -0
- data/lib/solid_observer/services/record_cache_metric.rb +13 -21
- data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
- data/lib/solid_observer/services/unified_feed.rb +101 -0
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +42 -11
- data/lib/tasks/solid_observer.rake +97 -21
- metadata +41 -6
- data/app/assets/stylesheets/solid_observer/application.css +0 -18
- data/bin/console +0 -11
- data/bin/quality_gate +0 -95
- data/bin/setup +0 -8
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storage_info_snapshot"
|
|
4
|
+
|
|
5
|
+
module SolidObserver
|
|
6
|
+
module Services
|
|
7
|
+
# :reek:TooManyConstants
|
|
8
|
+
class CableStats
|
|
9
|
+
RANGES = {
|
|
10
|
+
"15m" => 15.minutes,
|
|
11
|
+
"30m" => 30.minutes,
|
|
12
|
+
"1h" => 1.hour,
|
|
13
|
+
"7h" => 7.hours,
|
|
14
|
+
"1d" => 1.day,
|
|
15
|
+
"7d" => 7.days,
|
|
16
|
+
"14d" => 14.days
|
|
17
|
+
}.freeze
|
|
18
|
+
DEFAULT_RANGE = "15m"
|
|
19
|
+
ACTIVITY_TREND_EMPTY = {
|
|
20
|
+
available: false,
|
|
21
|
+
broadcasts: [],
|
|
22
|
+
rejections: []
|
|
23
|
+
}.freeze
|
|
24
|
+
STABILITY_EMPTY = {
|
|
25
|
+
available: false,
|
|
26
|
+
state: :stable,
|
|
27
|
+
rejection_count: 0,
|
|
28
|
+
error_count: 0,
|
|
29
|
+
rejection_rate: 0.0,
|
|
30
|
+
backlog_ratio: nil,
|
|
31
|
+
backlog_available: false,
|
|
32
|
+
latest_recorded_at: nil
|
|
33
|
+
}.freeze
|
|
34
|
+
STABILITY_DEGRADED = {
|
|
35
|
+
available: true,
|
|
36
|
+
state: :degraded,
|
|
37
|
+
rejection_count: 0,
|
|
38
|
+
error_count: 0,
|
|
39
|
+
rejection_rate: 0.0,
|
|
40
|
+
backlog_ratio: nil,
|
|
41
|
+
backlog_available: false,
|
|
42
|
+
latest_recorded_at: nil
|
|
43
|
+
}.freeze
|
|
44
|
+
BUCKET_RULES = [
|
|
45
|
+
[2.hours.to_i, 1.minute.to_i],
|
|
46
|
+
[1.day.to_i, 15.minutes.to_i],
|
|
47
|
+
[7.days.to_i, 2.hours.to_i]
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
class TrendData
|
|
51
|
+
class BucketSnapshot
|
|
52
|
+
attr_reader :broadcasts_count, :rejections_count
|
|
53
|
+
|
|
54
|
+
def initialize
|
|
55
|
+
@broadcasts_count = 0
|
|
56
|
+
@rejections_count = 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add(row)
|
|
60
|
+
@broadcasts_count += row[1].to_i
|
|
61
|
+
@rejections_count += row[4].to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def value_for(key)
|
|
65
|
+
public_send(key)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize(metric_rows:, window:, current_time:)
|
|
70
|
+
@metric_rows = metric_rows
|
|
71
|
+
@window = window
|
|
72
|
+
@current_time = current_time
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
return CableStats::ACTIVITY_TREND_EMPTY.dup if metric_rows.empty?
|
|
77
|
+
|
|
78
|
+
buckets = blank_buckets
|
|
79
|
+
metric_rows.each do |row|
|
|
80
|
+
buckets[align_bucket(row[0].to_i)]&.add(row)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
available: true,
|
|
85
|
+
broadcasts: count_series(buckets, :broadcasts_count),
|
|
86
|
+
rejections: count_series(buckets, :rejections_count)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
attr_reader :metric_rows, :window, :current_time
|
|
93
|
+
|
|
94
|
+
def blank_buckets
|
|
95
|
+
start_bucket = align_bucket((current_time - window).to_i)
|
|
96
|
+
end_bucket = align_bucket(current_time.to_i)
|
|
97
|
+
|
|
98
|
+
start_bucket.step(end_bucket, bucket_seconds).each_with_object({}) do |timestamp, buckets|
|
|
99
|
+
buckets[timestamp] = BucketSnapshot.new
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def count_series(buckets, key)
|
|
104
|
+
buckets.map do |timestamp, totals|
|
|
105
|
+
{t: timestamp, v: totals.value_for(key)}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bucket_seconds
|
|
110
|
+
seconds = window.to_i
|
|
111
|
+
CableStats::BUCKET_RULES.find { |limit, _bucket| seconds <= limit }&.last || 4.hours.to_i
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def align_bucket(value)
|
|
115
|
+
(value / bucket_seconds) * bucket_seconds
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class BacklogSnapshot
|
|
120
|
+
def self.call
|
|
121
|
+
new.call
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# :reek:TooManyStatements
|
|
125
|
+
def call
|
|
126
|
+
snapshot = fetch_snapshot
|
|
127
|
+
return unavailable unless snapshot && snapshot[:available]
|
|
128
|
+
|
|
129
|
+
count = snapshot[:trimmable_count].to_i
|
|
130
|
+
total = snapshot[:event_count].to_i
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
available: true,
|
|
134
|
+
count: count,
|
|
135
|
+
ratio: total.positive? ? count.to_f / total.to_f : 0.0
|
|
136
|
+
}
|
|
137
|
+
rescue *StorageInfoSnapshot::CONNECTION_ERRORS, TypeError, NoMethodError
|
|
138
|
+
unavailable
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def fetch_snapshot
|
|
144
|
+
StorageInfoSnapshot.call.find { |component| component[:component] == "solid_cable" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unavailable
|
|
148
|
+
{available: false, count: nil, ratio: nil}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class StabilityData
|
|
153
|
+
def initialize(window:, current_time:, backlog_snapshot:)
|
|
154
|
+
@window = window
|
|
155
|
+
@current_time = current_time
|
|
156
|
+
@backlog_snapshot = backlog_snapshot
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def to_h(metric_broadcasts_count:, metric_rejections_count:)
|
|
160
|
+
compute(
|
|
161
|
+
metric_broadcasts_count: metric_broadcasts_count,
|
|
162
|
+
metric_rejections_count: metric_rejections_count
|
|
163
|
+
).to_h
|
|
164
|
+
rescue ActiveRecord::StatementInvalid
|
|
165
|
+
CableStats::STABILITY_DEGRADED.dup
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
attr_reader :window, :current_time, :backlog_snapshot
|
|
171
|
+
|
|
172
|
+
# :reek:TooManyStatements
|
|
173
|
+
def compute(metric_broadcasts_count:, metric_rejections_count:)
|
|
174
|
+
event_counts = query_event_counts
|
|
175
|
+
rejection_count = event_counts[:rejection_count]
|
|
176
|
+
error_count = event_counts[:error_count]
|
|
177
|
+
rejection_rate = CableStats.ratio(metric_rejections_count, metric_broadcasts_count)
|
|
178
|
+
rejection_present = rejection_count.to_i.positive? || metric_rejections_count.to_i.positive?
|
|
179
|
+
backlog_ratio = backlog_snapshot[:ratio]
|
|
180
|
+
backlog_available = backlog_snapshot[:available]
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
available: true,
|
|
184
|
+
state: stability_state(
|
|
185
|
+
error_count: error_count,
|
|
186
|
+
rejection_rate: rejection_rate,
|
|
187
|
+
rejection_present: rejection_present,
|
|
188
|
+
backlog_ratio: backlog_ratio,
|
|
189
|
+
backlog_available: backlog_available
|
|
190
|
+
),
|
|
191
|
+
rejection_count: rejection_count,
|
|
192
|
+
error_count: error_count,
|
|
193
|
+
rejection_rate: rejection_rate,
|
|
194
|
+
backlog_ratio: backlog_ratio,
|
|
195
|
+
backlog_available: backlog_available,
|
|
196
|
+
latest_recorded_at: event_counts[:latest_recorded_at]
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# :reek:ControlParameter
|
|
201
|
+
# :reek:LongParameterList
|
|
202
|
+
# :reek:TooManyStatements
|
|
203
|
+
def stability_state(error_count:, rejection_rate:, rejection_present:, backlog_ratio:, backlog_available:)
|
|
204
|
+
config = SolidObserver.config
|
|
205
|
+
|
|
206
|
+
return :critical if error_count.to_i > config.cable_error_threshold.to_i
|
|
207
|
+
return :critical if rejection_rate >= config.cable_rejection_threshold.to_f
|
|
208
|
+
return :critical if backlog_ratio && backlog_ratio >= 0.5
|
|
209
|
+
return :degraded if backlog_ratio && backlog_ratio >= config.cable_backlog_threshold.to_f
|
|
210
|
+
return :degraded if rejection_present
|
|
211
|
+
return :degraded unless backlog_available
|
|
212
|
+
|
|
213
|
+
:stable
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def query_event_counts
|
|
217
|
+
rejection_count, error_count, latest_recorded_at = SolidObserver::CableEvent.where(recorded_at: window_range).pick(
|
|
218
|
+
Arel.sql("COUNT(CASE WHEN event_type = 'transmit_subscription_rejection' THEN 1 END)"),
|
|
219
|
+
Arel.sql("COUNT(CASE WHEN error_class IS NOT NULL AND TRIM(error_class) != '' THEN 1 END)"),
|
|
220
|
+
Arel.sql("MAX(CASE WHEN event_type = 'transmit_subscription_rejection' OR (error_class IS NOT NULL AND TRIM(error_class) != '') THEN recorded_at END)")
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
{
|
|
224
|
+
rejection_count: rejection_count.to_i,
|
|
225
|
+
error_count: error_count.to_i,
|
|
226
|
+
latest_recorded_at: parse_latest_recorded_at(latest_recorded_at)
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_latest_recorded_at(value)
|
|
231
|
+
return nil unless value.present?
|
|
232
|
+
|
|
233
|
+
value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
234
|
+
rescue ArgumentError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def window_range
|
|
239
|
+
(current_time - window)..current_time
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class << self
|
|
244
|
+
def parse_range(value, fallback: DEFAULT_RANGE)
|
|
245
|
+
range_key = value.to_s
|
|
246
|
+
RANGES.key?(range_key) ? range_key : fallback
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def range_duration(value, fallback: DEFAULT_RANGE)
|
|
250
|
+
RANGES.fetch(parse_range(value, fallback: fallback))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def ratio(numerator, denominator)
|
|
254
|
+
return 0.0 if denominator.to_i.zero?
|
|
255
|
+
|
|
256
|
+
numerator.to_f / denominator.to_f
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.call(window:)
|
|
261
|
+
new.call(window: window)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def call(window:)
|
|
265
|
+
current_time = Time.current
|
|
266
|
+
dashboard_response(window: window, current_time: current_time)
|
|
267
|
+
rescue => error
|
|
268
|
+
Rails.logger&.error("[SolidObserver] CableStats call failed: #{error.class} #{error.message}") if defined?(Rails)
|
|
269
|
+
error_response
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def dashboard_response(window:, current_time:)
|
|
275
|
+
time_window = (current_time - window)..current_time
|
|
276
|
+
metric_rows = metric_rows(time_window: time_window)
|
|
277
|
+
totals = metric_totals(time_window: time_window)
|
|
278
|
+
backlog_snapshot = BacklogSnapshot.call
|
|
279
|
+
|
|
280
|
+
build_response(
|
|
281
|
+
totals: totals,
|
|
282
|
+
backlog_snapshot: backlog_snapshot,
|
|
283
|
+
dashboard_data: dashboard_data(
|
|
284
|
+
window: window,
|
|
285
|
+
current_time: current_time,
|
|
286
|
+
metric_rows: metric_rows,
|
|
287
|
+
totals: totals,
|
|
288
|
+
backlog_snapshot: backlog_snapshot
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# :reek:FeatureEnvy
|
|
294
|
+
# :reek:LongParameterList
|
|
295
|
+
def build_response(totals:, backlog_snapshot:, dashboard_data:)
|
|
296
|
+
broadcasts_count = totals[:broadcasts_count]
|
|
297
|
+
rejections_count = totals[:rejections_count]
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
broadcasts_count: broadcasts_count,
|
|
301
|
+
transmissions_count: totals[:transmissions_count],
|
|
302
|
+
confirmations_count: totals[:confirmations_count],
|
|
303
|
+
rejections_count: rejections_count,
|
|
304
|
+
perform_actions_count: totals[:perform_actions_count],
|
|
305
|
+
errors_count: totals[:errors_count],
|
|
306
|
+
rejection_rate: self.class.ratio(rejections_count, broadcasts_count),
|
|
307
|
+
activity_trends: dashboard_data[:activity_trends],
|
|
308
|
+
stability: dashboard_data[:stability],
|
|
309
|
+
backlog_count: backlog_snapshot[:count],
|
|
310
|
+
backlog_available: backlog_snapshot[:available]
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# :reek:LongParameterList
|
|
315
|
+
def dashboard_data(window:, current_time:, metric_rows:, totals:, backlog_snapshot:)
|
|
316
|
+
{
|
|
317
|
+
activity_trends: TrendData.new(
|
|
318
|
+
metric_rows: metric_rows,
|
|
319
|
+
window: window,
|
|
320
|
+
current_time: current_time
|
|
321
|
+
).to_h,
|
|
322
|
+
stability: StabilityData.new(
|
|
323
|
+
window: window,
|
|
324
|
+
current_time: current_time,
|
|
325
|
+
backlog_snapshot: backlog_snapshot
|
|
326
|
+
).to_h(
|
|
327
|
+
metric_broadcasts_count: totals[:broadcasts_count],
|
|
328
|
+
metric_rejections_count: totals[:rejections_count]
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def metric_rows(time_window:)
|
|
334
|
+
SolidObserver::CableMetric.where(period_start: time_window).pluck(
|
|
335
|
+
:period_start,
|
|
336
|
+
:broadcasts_count,
|
|
337
|
+
:transmissions_count,
|
|
338
|
+
:confirmations_count,
|
|
339
|
+
:rejections_count,
|
|
340
|
+
:perform_actions_count,
|
|
341
|
+
:errors_count
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def metric_totals(time_window:)
|
|
346
|
+
broadcasts_count, transmissions_count, confirmations_count, rejections_count, perform_actions_count, errors_count = SolidObserver::CableMetric.where(
|
|
347
|
+
period_start: time_window
|
|
348
|
+
).pick(
|
|
349
|
+
Arel.sql("COALESCE(SUM(broadcasts_count), 0)"),
|
|
350
|
+
Arel.sql("COALESCE(SUM(transmissions_count), 0)"),
|
|
351
|
+
Arel.sql("COALESCE(SUM(confirmations_count), 0)"),
|
|
352
|
+
Arel.sql("COALESCE(SUM(rejections_count), 0)"),
|
|
353
|
+
Arel.sql("COALESCE(SUM(perform_actions_count), 0)"),
|
|
354
|
+
Arel.sql("COALESCE(SUM(errors_count), 0)")
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
{
|
|
358
|
+
broadcasts_count: broadcasts_count,
|
|
359
|
+
transmissions_count: transmissions_count,
|
|
360
|
+
confirmations_count: confirmations_count,
|
|
361
|
+
rejections_count: rejections_count,
|
|
362
|
+
perform_actions_count: perform_actions_count,
|
|
363
|
+
errors_count: errors_count
|
|
364
|
+
}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def error_response
|
|
368
|
+
{
|
|
369
|
+
broadcasts_count: 0,
|
|
370
|
+
transmissions_count: 0,
|
|
371
|
+
confirmations_count: 0,
|
|
372
|
+
rejections_count: 0,
|
|
373
|
+
perform_actions_count: 0,
|
|
374
|
+
errors_count: 0,
|
|
375
|
+
rejection_rate: 0.0,
|
|
376
|
+
activity_trends: ACTIVITY_TREND_EMPTY.dup,
|
|
377
|
+
stability: STABILITY_EMPTY.dup,
|
|
378
|
+
backlog_count: nil,
|
|
379
|
+
backlog_available: false,
|
|
380
|
+
error: "Service temporarily unavailable"
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -123,10 +123,10 @@ module SolidObserver
|
|
|
123
123
|
class EventCounts
|
|
124
124
|
attr_reader :error_count, :slow_count, :latest_recorded_at
|
|
125
125
|
|
|
126
|
-
def initialize
|
|
127
|
-
@error_count =
|
|
128
|
-
@slow_count =
|
|
129
|
-
@latest_recorded_at =
|
|
126
|
+
def initialize(error_count: 0, slow_count: 0, latest_recorded_at: nil)
|
|
127
|
+
@error_count = error_count.to_i
|
|
128
|
+
@slow_count = slow_count.to_i
|
|
129
|
+
@latest_recorded_at = latest_recorded_at
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def record(recorded_at:, error_class:, duration:)
|
|
@@ -181,22 +181,38 @@ module SolidObserver
|
|
|
181
181
|
attr_reader :window, :current_time
|
|
182
182
|
|
|
183
183
|
def event_counts
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
error_count, slow_count, latest_recorded_at = SolidObserver::CacheEvent.where(recorded_at: window_range).pick(
|
|
185
|
+
Arel.sql("COUNT(CASE WHEN #{error_condition_sql} THEN 1 END)"),
|
|
186
|
+
Arel.sql("COUNT(CASE WHEN #{slow_condition_sql} THEN 1 END)"),
|
|
187
|
+
Arel.sql("MAX(CASE WHEN #{tracked_condition_sql} THEN recorded_at END)")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
EventCounts.new(
|
|
191
|
+
error_count: error_count,
|
|
192
|
+
slow_count: slow_count,
|
|
193
|
+
latest_recorded_at: latest_recorded_at
|
|
194
|
+
)
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
def window_range
|
|
198
198
|
(current_time - window)..current_time
|
|
199
199
|
end
|
|
200
|
+
|
|
201
|
+
def tracked_condition_sql
|
|
202
|
+
"(#{error_condition_sql}) OR (#{slow_condition_sql})"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def error_condition_sql
|
|
206
|
+
"error_class IS NOT NULL AND TRIM(error_class) != ''"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def slow_condition_sql
|
|
210
|
+
"(error_class IS NULL OR TRIM(error_class) = '') AND duration >= #{slow_threshold}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def slow_threshold
|
|
214
|
+
SolidObserver.config.cache_slow_threshold.to_f
|
|
215
|
+
end
|
|
200
216
|
end
|
|
201
217
|
|
|
202
218
|
class << self
|
|
@@ -218,7 +234,8 @@ module SolidObserver
|
|
|
218
234
|
current_time = Time.current
|
|
219
235
|
dashboard_response(window: window, current_time: current_time)
|
|
220
236
|
rescue => error
|
|
221
|
-
|
|
237
|
+
Rails.logger&.error("[SolidObserver] CacheStats call failed: #{error.class} #{error.message}") if defined?(Rails)
|
|
238
|
+
error_response
|
|
222
239
|
end
|
|
223
240
|
|
|
224
241
|
private
|
|
@@ -308,7 +325,7 @@ module SolidObserver
|
|
|
308
325
|
numerator.to_f / denominator
|
|
309
326
|
end
|
|
310
327
|
|
|
311
|
-
def error_response
|
|
328
|
+
def error_response
|
|
312
329
|
{
|
|
313
330
|
hit_rate: 0.0,
|
|
314
331
|
throughput: 0.0,
|
|
@@ -321,7 +338,7 @@ module SolidObserver
|
|
|
321
338
|
duration_total: 0.0,
|
|
322
339
|
activity_trends: ACTIVITY_TREND_EMPTY.dup,
|
|
323
340
|
stability: STABILITY_EMPTY.dup,
|
|
324
|
-
error:
|
|
341
|
+
error: "Service temporarily unavailable"
|
|
325
342
|
}
|
|
326
343
|
end
|
|
327
344
|
end
|
|
@@ -6,6 +6,13 @@ require_relative "storage_info_snapshot"
|
|
|
6
6
|
module SolidObserver
|
|
7
7
|
module Services
|
|
8
8
|
class CleanupStorage
|
|
9
|
+
MAINTENANCE_STATEMENT_BUILDERS = {
|
|
10
|
+
"sqlite" => ->(_tables) { ["VACUUM"] },
|
|
11
|
+
"postgresql" => ->(tables) { tables.map { |table_name| "VACUUM ANALYZE #{table_name}" } },
|
|
12
|
+
"mysql2" => ->(tables) { ["OPTIMIZE TABLE #{tables.join(", ")}"] },
|
|
13
|
+
"trilogy" => ->(tables) { ["OPTIMIZE TABLE #{tables.join(", ")}"] }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
9
16
|
def self.call
|
|
10
17
|
new.call
|
|
11
18
|
end
|
|
@@ -13,37 +20,54 @@ module SolidObserver
|
|
|
13
20
|
def call
|
|
14
21
|
return 0 if SolidObserver.config.realtime_mode?
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
handle_cleanup_failure(e)
|
|
23
|
+
post_cleanup(cleanup_counts)
|
|
24
|
+
rescue => error
|
|
25
|
+
handle_cleanup_failure(error)
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
private
|
|
23
29
|
|
|
30
|
+
def cleanup_counts
|
|
31
|
+
perform_cleanup.tap { record_snapshot_after_cleanup }
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
def handle_cleanup_failure(error)
|
|
25
35
|
Rails.logger.error "[SolidObserver] Cleanup failed: #{error.message}"
|
|
26
36
|
raise
|
|
27
37
|
end
|
|
28
38
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
def perform_cleanup
|
|
40
|
+
config = SolidObserver.config
|
|
41
|
+
event_cutoff = config.event_retention.ago
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
queue_events: QueueEvent.transaction do
|
|
45
|
+
QueueEvent.where("recorded_at < ?", event_cutoff).delete_all
|
|
46
|
+
end,
|
|
47
|
+
cache_events: delete_telemetry_records(
|
|
48
|
+
SolidObserver::CacheEvent,
|
|
49
|
+
column: :recorded_at,
|
|
50
|
+
cutoff: event_cutoff
|
|
51
|
+
),
|
|
52
|
+
cache_metrics: delete_telemetry_records(
|
|
53
|
+
SolidObserver::CacheMetric,
|
|
54
|
+
column: :period_start,
|
|
55
|
+
cutoff: config.metrics_retention.ago
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def post_cleanup(cleanup_counts)
|
|
38
61
|
vacuum_database
|
|
39
62
|
check_storage_warnings
|
|
40
|
-
log_results(
|
|
41
|
-
|
|
63
|
+
log_results(cleanup_counts)
|
|
64
|
+
cleanup_counts.values.sum
|
|
42
65
|
end
|
|
43
66
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
def delete_telemetry_records(model, column:, cutoff:)
|
|
68
|
+
return 0 unless data_source_available?(model)
|
|
69
|
+
|
|
70
|
+
model.where("#{column} < ?", cutoff).delete_all
|
|
47
71
|
end
|
|
48
72
|
|
|
49
73
|
def record_snapshot_after_cleanup
|
|
@@ -70,58 +94,69 @@ module SolidObserver
|
|
|
70
94
|
end
|
|
71
95
|
|
|
72
96
|
def vacuum_database
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Rails.logger.warn "[SolidObserver] Database maintenance failed: #{e.message}"
|
|
97
|
+
maintenance_statements.each do |statement|
|
|
98
|
+
QueueEvent.connection.execute(statement)
|
|
99
|
+
end
|
|
100
|
+
rescue => error
|
|
101
|
+
Rails.logger.warn "[SolidObserver] Database maintenance failed: #{error.message}"
|
|
79
102
|
end
|
|
80
103
|
|
|
81
104
|
def check_storage_warnings
|
|
82
105
|
current_size = current_database_size
|
|
83
|
-
return unless
|
|
106
|
+
return unless current_size
|
|
107
|
+
return unless current_size > (SolidObserver.config.max_db_size * SolidObserver.config.warning_threshold)
|
|
84
108
|
|
|
85
109
|
Rails.logger.warn(storage_warning_message(current_size))
|
|
86
110
|
end
|
|
87
111
|
|
|
88
|
-
def warning_needed?(current_size)
|
|
89
|
-
return false unless current_size
|
|
90
|
-
|
|
91
|
-
config = SolidObserver.config
|
|
92
|
-
max_size = config.max_db_size
|
|
93
|
-
threshold = config.warning_threshold
|
|
94
|
-
current_size > (max_size * threshold)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
112
|
def storage_warning_message(current_size)
|
|
98
113
|
max_size = SolidObserver.config.max_db_size
|
|
99
114
|
percentage = ((current_size.to_f / max_size) * 100).round(1)
|
|
100
|
-
current_size_human =
|
|
101
|
-
|
|
115
|
+
current_size_human = ActiveSupport::NumberHelper.number_to_human_size(
|
|
116
|
+
current_size,
|
|
117
|
+
precision: 1,
|
|
118
|
+
significant: false,
|
|
119
|
+
strip_insignificant_zeros: false
|
|
120
|
+
)
|
|
121
|
+
max_size_human = ActiveSupport::NumberHelper.number_to_human_size(
|
|
122
|
+
max_size,
|
|
123
|
+
precision: 1,
|
|
124
|
+
significant: false,
|
|
125
|
+
strip_insignificant_zeros: false
|
|
126
|
+
)
|
|
102
127
|
"[SolidObserver] Queue DB approaching limit: #{current_size_human} / #{max_size_human} (#{percentage}%)"
|
|
103
128
|
end
|
|
104
129
|
|
|
105
|
-
def human_size(bytes)
|
|
106
|
-
ActiveSupport::NumberHelper.number_to_human_size(bytes, precision: 1, significant: false, strip_insignificant_zeros: false)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
130
|
def current_database_size
|
|
110
131
|
return @current_database_size if defined?(@current_database_size)
|
|
111
132
|
|
|
112
133
|
@current_database_size = DatabaseSize.call(connection: QueueEvent.connection)
|
|
113
134
|
end
|
|
114
135
|
|
|
115
|
-
def log_results(
|
|
116
|
-
Rails.logger.info
|
|
136
|
+
def log_results(cleanup_counts)
|
|
137
|
+
Rails.logger.info(
|
|
138
|
+
"[SolidObserver] Cleaned #{cleanup_counts[:queue_events]} queue events, " \
|
|
139
|
+
"#{cleanup_counts[:cache_events]} cache events, " \
|
|
140
|
+
"#{cleanup_counts[:cache_metrics]} cache metrics"
|
|
141
|
+
)
|
|
117
142
|
end
|
|
118
143
|
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
when "postgresql" then "VACUUM ANALYZE solid_observer_queue_events"
|
|
123
|
-
when "mysql2", "trilogy" then "OPTIMIZE TABLE solid_observer_queue_events"
|
|
144
|
+
def maintenance_statements
|
|
145
|
+
tables = [QueueEvent, SolidObserver::CacheEvent, SolidObserver::CacheMetric].filter_map do |model|
|
|
146
|
+
model.table_name if data_source_available?(model)
|
|
124
147
|
end
|
|
148
|
+
return [] if tables.empty?
|
|
149
|
+
|
|
150
|
+
MAINTENANCE_STATEMENT_BUILDERS.fetch(QueueEvent.connection.adapter_name.downcase, ->(_known_tables) { [] }).call(tables)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def data_source_available?(model)
|
|
154
|
+
table_name = model.table_name.to_s
|
|
155
|
+
return false if table_name.empty?
|
|
156
|
+
|
|
157
|
+
model.connection.data_source_exists?(table_name)
|
|
158
|
+
rescue *StorageInfoSnapshot::CONNECTION_ERRORS, TypeError
|
|
159
|
+
false
|
|
125
160
|
end
|
|
126
161
|
end
|
|
127
162
|
end
|