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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +100 -25
  4. data/app/assets/javascripts/solid_observer/live_poll.js +3 -1
  5. data/app/controllers/solid_observer/application_controller.rb +1 -0
  6. data/app/controllers/solid_observer/cable_dashboard_controller.rb +52 -0
  7. data/app/controllers/solid_observer/cable_operations_controller.rb +16 -0
  8. data/app/controllers/solid_observer/cache_dashboard_controller.rb +33 -40
  9. data/app/controllers/solid_observer/dashboard_controller.rb +21 -13
  10. data/app/controllers/solid_observer/traces_controller.rb +17 -0
  11. data/app/helpers/solid_observer/application_helper.rb +145 -3
  12. data/app/models/solid_observer/cable_event.rb +13 -0
  13. data/app/models/solid_observer/cable_metric.rb +12 -0
  14. data/app/models/solid_observer/cache_metric.rb +1 -2
  15. data/app/models/solid_observer/storage_info.rb +1 -1
  16. data/app/views/layouts/solid_observer/application.html.erb +35 -9
  17. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  18. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +39 -0
  19. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  20. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  21. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
  22. data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
  23. data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
  24. data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
  25. data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
  26. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  27. data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
  28. data/app/views/solid_observer/dashboard/index.html.erb +3 -139
  29. data/app/views/solid_observer/events/index.html.erb +7 -1
  30. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  31. data/app/views/solid_observer/storages/show.html.erb +29 -3
  32. data/app/views/solid_observer/traces/show.html.erb +80 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  35. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  36. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  37. data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
  38. data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
  39. data/lib/generators/solid_observer/install_generator.rb +8 -1
  40. data/lib/generators/solid_observer/templates/initializer.rb.tt +24 -5
  41. data/lib/solid_observer/base_event.rb +1 -1
  42. data/lib/solid_observer/base_metric.rb +1 -1
  43. data/lib/solid_observer/base_record.rb +8 -0
  44. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  45. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  46. data/lib/solid_observer/cable_subscriber.rb +57 -0
  47. data/lib/solid_observer/cache_event_buffer.rb +11 -36
  48. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  49. data/lib/solid_observer/chart_buffer.rb +84 -27
  50. data/lib/solid_observer/cli/health.rb +49 -0
  51. data/lib/solid_observer/cli/trace.rb +106 -0
  52. data/lib/solid_observer/configuration.rb +47 -7
  53. data/lib/solid_observer/correlated.rb +23 -0
  54. data/lib/solid_observer/engine.rb +46 -28
  55. data/lib/solid_observer/event_buffer_core.rb +218 -0
  56. data/lib/solid_observer/queries/trace_query.rb +151 -0
  57. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  58. data/lib/solid_observer/services/cable_operations.rb +74 -0
  59. data/lib/solid_observer/services/cable_stats.rb +385 -0
  60. data/lib/solid_observer/services/cache_stats.rb +35 -18
  61. data/lib/solid_observer/services/cleanup_storage.rb +82 -47
  62. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  63. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  64. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  65. data/lib/solid_observer/services/health_score.rb +85 -0
  66. data/lib/solid_observer/services/record_cable_event.rb +115 -0
  67. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  68. data/lib/solid_observer/services/record_cache_event.rb +24 -0
  69. data/lib/solid_observer/services/record_cache_metric.rb +13 -21
  70. data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
  71. data/lib/solid_observer/services/unified_feed.rb +101 -0
  72. data/lib/solid_observer/version.rb +1 -1
  73. data/lib/solid_observer.rb +42 -11
  74. data/lib/tasks/solid_observer.rake +97 -21
  75. metadata +41 -6
  76. data/app/assets/stylesheets/solid_observer/application.css +0 -18
  77. data/bin/console +0 -11
  78. data/bin/quality_gate +0 -95
  79. data/bin/setup +0 -8
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCableEventBuffer
6
+ BATCH_SIZE = 100
7
+
8
+ def self.call(events)
9
+ new(events).call
10
+ end
11
+
12
+ def initialize(events)
13
+ @events = events
14
+ end
15
+
16
+ def call
17
+ return 0 if @events.empty?
18
+
19
+ insert_all_events
20
+ @events.size
21
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
22
+ fallback_insert_count(error)
23
+ end
24
+
25
+ private
26
+
27
+ def insert_all_events
28
+ CableEvent.transaction { CableEvent.insert_all!(@events) }
29
+ end
30
+
31
+ def fallback_insert_count(error)
32
+ Rails.logger&.error("[SolidObserver] Cable bulk insert failed, retrying in batches: #{error.message}") if defined?(Rails)
33
+ @events.each_slice(BATCH_SIZE).sum { |batch| insert_batch(batch) }
34
+ end
35
+
36
+ def insert_batch(batch)
37
+ size = batch.size
38
+ CableEvent.insert_all(batch, returning: false)
39
+ size
40
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
41
+ handle_batch_insert_error(size, error)
42
+ end
43
+
44
+ def log_batch_warning(size, error)
45
+ Rails.logger&.warn("[SolidObserver] Failed to insert cable batch of #{size} events: #{error.message}") if defined?(Rails)
46
+ end
47
+
48
+ def handle_batch_insert_error(size, error)
49
+ log_batch_warning(size, error)
50
+ 0
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCableMetrics
6
+ def self.call(metrics)
7
+ new(metrics).call
8
+ end
9
+
10
+ def initialize(metrics)
11
+ @metrics = metrics
12
+ end
13
+
14
+ def call
15
+ return 0 if @metrics.empty?
16
+
17
+ flush_metrics
18
+ @metrics.size
19
+ rescue ActiveRecord::RecordNotUnique
20
+ retry
21
+ end
22
+
23
+ private
24
+
25
+ def flush_metrics
26
+ SolidObserver::CableMetric.transaction do
27
+ @metrics.each { |metric_data| increment_metric(metric_data) }
28
+ end
29
+ end
30
+
31
+ def increment_metric(metric_data)
32
+ period_start = metric_data.fetch(:period_start)
33
+ metric = SolidObserver::CableMetric.find_or_create_by!(period_start: period_start)
34
+
35
+ SolidObserver::CableMetric.where(id: metric.id).update_counters(update_values(metric_data))
36
+ end
37
+
38
+ def update_values(metric_data)
39
+ {
40
+ broadcasts_count: increment_expression(:broadcasts_count, metric_data),
41
+ transmissions_count: increment_expression(:transmissions_count, metric_data),
42
+ confirmations_count: increment_expression(:confirmations_count, metric_data),
43
+ rejections_count: increment_expression(:rejections_count, metric_data),
44
+ perform_actions_count: increment_expression(:perform_actions_count, metric_data),
45
+ errors_count: increment_expression(:errors_count, metric_data)
46
+ }
47
+ end
48
+
49
+ def increment_expression(column, metric_data)
50
+ metric_data.fetch(column, 0)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ module Services
5
+ class FlushCacheMetrics
6
+ def self.call(metrics)
7
+ new(metrics).call
8
+ end
9
+
10
+ def initialize(metrics)
11
+ @metrics = metrics
12
+ end
13
+
14
+ def call
15
+ return 0 if @metrics.empty?
16
+
17
+ flush_metrics
18
+ @metrics.size
19
+ rescue ActiveRecord::RecordNotUnique
20
+ retry
21
+ end
22
+
23
+ private
24
+
25
+ def flush_metrics
26
+ SolidObserver::CacheMetric.transaction do
27
+ @metrics.each { |metric_data| increment_metric(metric_data) }
28
+ end
29
+ end
30
+
31
+ def increment_metric(metric_data)
32
+ event_type, period_start = metric_data.values_at(:event_type, :period_start)
33
+ metric = SolidObserver::CacheMetric.find_or_create_by!(
34
+ event_type: event_type,
35
+ period_start: period_start
36
+ )
37
+
38
+ SolidObserver::CacheMetric.where(id: metric.id).update_counters(update_values(metric_data))
39
+ end
40
+
41
+ def update_values(metric_data)
42
+ {
43
+ operations_count: increment_expression(:operations_count, metric_data),
44
+ hits_count: increment_expression(:hits_count, metric_data),
45
+ misses_count: increment_expression(:misses_count, metric_data),
46
+ errors_count: increment_expression(:errors_count, metric_data),
47
+ duration_total: increment_expression(:duration_total, metric_data)
48
+ }
49
+ end
50
+
51
+ def increment_expression(column, metric_data)
52
+ metric_data.fetch(column, 0)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class HealthScore
8
+ STATUS_RANK = {stable: 0, degraded: 1, critical: 2}.freeze
9
+ UNAVAILABLE = {status: :degraded, available: false}.freeze
10
+ WINDOW = 15.minutes
11
+
12
+ def self.call
13
+ new.call
14
+ end
15
+
16
+ def call
17
+ components = collect_components
18
+ {
19
+ overall: overall_status(components.values.map { |entry| entry[:status] }),
20
+ components: components
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ # :reek:FeatureEnvy
27
+ # :reek:TooManyStatements
28
+ def collect_components
29
+ config = SolidObserver.config
30
+ components = {}
31
+
32
+ components[:queue] = queue_component if config.solid_queue_enabled?
33
+
34
+ unless config.realtime_mode?
35
+ components[:cache] = stats_component(:cache, CacheStats) if config.solid_cache_enabled?
36
+ components[:cable] = stats_component(:cable, CableStats) if config.solid_cable_enabled?
37
+ end
38
+
39
+ components
40
+ end
41
+
42
+ def queue_component
43
+ snapshot = SolidObserver::QueueStats.snapshot
44
+ return UNAVAILABLE.dup unless snapshot[:available]
45
+
46
+ {status: queue_stability(snapshot), available: true}
47
+ rescue => error
48
+ degrade(:queue, error)
49
+ end
50
+
51
+ def queue_stability(snapshot)
52
+ return :critical if snapshot[:failed_last_hour].to_i.positive?
53
+ return :degraded if snapshot[:failed_last_24h].to_i.positive?
54
+
55
+ :stable
56
+ end
57
+
58
+ def stats_component(name, stats_class)
59
+ stability = stats_class.call(window: WINDOW)[:stability]
60
+ return UNAVAILABLE.dup unless stability&.[](:available)
61
+
62
+ {status: stability[:state] || :degraded, available: true}
63
+ rescue => error
64
+ degrade(name, error)
65
+ end
66
+
67
+ def overall_status(statuses)
68
+ return :stable if statuses.empty?
69
+
70
+ statuses.max_by { |status| STATUS_RANK.fetch(status, 1) }
71
+ end
72
+
73
+ def degrade(name, error)
74
+ log_component_error(name, error)
75
+ UNAVAILABLE.dup
76
+ end
77
+
78
+ def log_component_error(name, error)
79
+ return unless defined?(Rails)
80
+
81
+ Rails.logger&.warn("HealthScore #{name} error: #{error.message}")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCableEvent
8
+ def self.call(event:, buffer:)
9
+ new(event, buffer).call
10
+ end
11
+
12
+ def initialize(event, buffer)
13
+ @event = event
14
+ @buffer = buffer
15
+ end
16
+
17
+ def call
18
+ record_metric_and_event
19
+ rescue => error
20
+ raise error if error.is_a?(NameError)
21
+ Rails.logger&.warn("[SolidObserver] Cable event recording failed: #{error.message}") if defined?(Rails)
22
+ end
23
+
24
+ private
25
+
26
+ def record_metric_and_event
27
+ SolidObserver::Services::RecordCableMetric.call(event: @event)
28
+ return unless should_store_event?
29
+
30
+ @buffer.push(build_event_data)
31
+ end
32
+
33
+ def should_store_event?
34
+ case @event.name
35
+ when "broadcast.action_cable"
36
+ sampled? || errored?
37
+ when "transmit_subscription_rejection.action_cable"
38
+ true
39
+ else
40
+ false
41
+ end
42
+ end
43
+
44
+ def sampled?
45
+ rand <= SolidObserver.config.cable_sampling_rate
46
+ end
47
+
48
+ def errored?
49
+ !exception_data.compact.empty?
50
+ end
51
+
52
+ def build_event_data
53
+ {
54
+ event_type: @event.name.delete_suffix(".action_cable"),
55
+ correlation_id: CorrelationIdResolver.resolve(@event),
56
+ channel_class: channel_class,
57
+ broadcasting_digest: broadcasting_digest,
58
+ duration: duration_in_seconds,
59
+ error_class: exception_data[:error_class],
60
+ error_message: exception_data[:error_message],
61
+ metadata: metadata.to_json,
62
+ recorded_at: Time.current
63
+ }
64
+ end
65
+
66
+ def broadcasting_digest
67
+ return nil unless @event.name == "broadcast.action_cable"
68
+
69
+ Digest::SHA256.hexdigest(payload[:broadcasting].to_s)
70
+ end
71
+
72
+ def channel_class
73
+ class_name = payload[:channel_class]
74
+ return class_name if class_name
75
+
76
+ channel = payload[:channel]
77
+ return nil unless channel
78
+
79
+ channel.class.name || channel.to_s
80
+ end
81
+
82
+ def duration_in_seconds
83
+ @event.duration&./(1000.0)
84
+ end
85
+
86
+ def metadata
87
+ {
88
+ action: payload[:action]&.to_s,
89
+ via: payload[:via]&.to_s,
90
+ data_size: payload[:data]&.to_s&.bytesize,
91
+ message_size: payload[:message]&.to_s&.bytesize
92
+ }.compact
93
+ end
94
+
95
+ def exception_data
96
+ @exception_data ||= begin
97
+ exception_obj = payload[:exception_object]
98
+ exception = payload[:exception]
99
+
100
+ if exception_obj
101
+ {error_class: exception_obj.class.name, error_message: exception_obj.message}
102
+ elsif exception.is_a?(Array)
103
+ {error_class: exception.first, error_message: exception.last}
104
+ else
105
+ {}
106
+ end
107
+ end
108
+ end
109
+
110
+ def payload
111
+ @event.payload || {}
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cable_metric_buffer"
4
+
5
+ module SolidObserver
6
+ module Services
7
+ class RecordCableMetric
8
+ COUNTERS = %i[
9
+ broadcasts_count
10
+ transmissions_count
11
+ confirmations_count
12
+ rejections_count
13
+ perform_actions_count
14
+ errors_count
15
+ ].freeze
16
+
17
+ EVENT_COUNTER_MAP = {
18
+ "broadcast.action_cable" => :broadcasts_count,
19
+ "transmit.action_cable" => :transmissions_count,
20
+ "transmit_subscription_confirmation.action_cable" => :confirmations_count,
21
+ "transmit_subscription_rejection.action_cable" => :rejections_count,
22
+ "perform_action.action_cable" => :perform_actions_count
23
+ }.freeze
24
+
25
+ def self.call(event:, buffer: SolidObserver::CableMetricBuffer.instance)
26
+ new(event, buffer).call
27
+ end
28
+
29
+ def initialize(event, buffer)
30
+ @event = event
31
+ @buffer = buffer
32
+ end
33
+
34
+ def call
35
+ @buffer.increment(metric_data)
36
+ rescue => error
37
+ Rails.logger&.warn("[SolidObserver] Cable metric recording failed: #{error.message}") if defined?(Rails)
38
+ end
39
+
40
+ private
41
+
42
+ def metric_data
43
+ COUNTERS.each_with_object({period_start: period_start}) do |counter, data|
44
+ data[counter] = (counter == target_counter) ? 1 : 0
45
+ end.merge(errors_count: error_increment)
46
+ end
47
+
48
+ def target_counter
49
+ EVENT_COUNTER_MAP.fetch(event_name, :broadcasts_count)
50
+ end
51
+
52
+ def period_start
53
+ Time.current.beginning_of_minute
54
+ end
55
+
56
+ def event_name
57
+ @event.name
58
+ end
59
+
60
+ def error_increment
61
+ exception_present? ? 1 : 0
62
+ end
63
+
64
+ def exception_present?
65
+ payload[:exception_object].present? || payload[:exception].is_a?(Array)
66
+ end
67
+
68
+ def payload
69
+ @event.payload || {}
70
+ end
71
+ end
72
+ end
73
+ end
@@ -5,6 +5,8 @@ require "digest"
5
5
  module SolidObserver
6
6
  module Services
7
7
  class RecordCacheEvent
8
+ INTERNAL_CACHE_KEY_PREFIX = "solid_observer/"
9
+
8
10
  def self.call(event:, buffer:)
9
11
  new(event, buffer).call
10
12
  end
@@ -15,6 +17,8 @@ module SolidObserver
15
17
  end
16
18
 
17
19
  def call
20
+ return if internal_cache_event?
21
+
18
22
  record_metric_and_event
19
23
  rescue => error
20
24
  raise error if error.is_a?(NameError)
@@ -51,6 +55,7 @@ module SolidObserver
51
55
  def build_event_data
52
56
  {
53
57
  event_type: cache_operation,
58
+ correlation_id: CorrelationIdResolver.resolve(@event),
54
59
  key_digest: key_digest,
55
60
  hit: hit_value,
56
61
  duration: duration_in_seconds,
@@ -137,6 +142,25 @@ module SolidObserver
137
142
  def payload
138
143
  @event.payload || {}
139
144
  end
145
+
146
+ def internal_cache_event?
147
+ internal_cache_key?(payload[:key])
148
+ end
149
+
150
+ def internal_cache_key?(key)
151
+ case key
152
+ when Array
153
+ nested_internal_cache_key?(key)
154
+ when Hash
155
+ nested_internal_cache_key?(key.flatten(1))
156
+ else
157
+ key.to_s.start_with?(INTERNAL_CACHE_KEY_PREFIX)
158
+ end
159
+ end
160
+
161
+ def nested_internal_cache_key?(entries)
162
+ entries.any? { |entry| internal_cache_key?(entry) }
163
+ end
140
164
  end
141
165
  end
142
166
  end
@@ -1,27 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cache_metric_buffer"
4
+
3
5
  module SolidObserver
4
6
  module Services
5
7
  class RecordCacheMetric
6
- def self.call(event:)
7
- new(event).call
8
+ def self.call(event:, buffer: SolidObserver::CacheMetricBuffer.instance)
9
+ new(event, buffer).call
8
10
  end
9
11
 
10
- def initialize(event)
12
+ def initialize(event, buffer)
11
13
  @event = event
14
+ @buffer = buffer
12
15
  end
13
16
 
14
17
  def call
15
- metric = SolidObserver::CacheMetric.find_or_create_by!(
18
+ @buffer.increment(
16
19
  event_type: event_type,
17
- period_start: period_start
20
+ period_start: period_start,
21
+ operations_count: 1,
22
+ hits_count: hit_increment,
23
+ misses_count: miss_increment,
24
+ errors_count: error_increment,
25
+ duration_total: duration_in_seconds
18
26
  )
19
-
20
- SolidObserver::CacheMetric
21
- .where(id: metric.id)
22
- .update_all(update_values)
23
- rescue ActiveRecord::RecordNotUnique
24
- retry
25
27
  rescue => error
26
28
  Rails.logger&.warn("[SolidObserver] Cache metric recording failed: #{error.message}") if defined?(Rails)
27
29
  end
@@ -36,16 +38,6 @@ module SolidObserver
36
38
  @event.name.delete_suffix(".active_support")
37
39
  end
38
40
 
39
- def update_values
40
- {
41
- operations_count: Arel.sql("operations_count + 1"),
42
- hits_count: Arel.sql("hits_count + #{hit_increment}"),
43
- misses_count: Arel.sql("misses_count + #{miss_increment}"),
44
- errors_count: Arel.sql("errors_count + #{error_increment}"),
45
- duration_total: Arel.sql("duration_total + #{duration_in_seconds}")
46
- }
47
- end
48
-
49
41
  def hit_increment
50
42
  (payload[:hit] == true) ? 1 : 0
51
43
  end