solid_observer 0.3.0 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +134 -81
  4. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  5. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  6. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  7. data/app/controllers/solid_observer/dashboard_controller.rb +44 -1
  8. data/app/controllers/solid_observer/storages_controller.rb +1 -1
  9. data/app/helpers/solid_observer/application_helper.rb +154 -5
  10. data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
  11. data/app/models/solid_observer/cache_event.rb +15 -0
  12. data/app/models/solid_observer/cache_metric.rb +14 -0
  13. data/app/models/solid_observer/storage_info.rb +4 -1
  14. data/app/views/layouts/solid_observer/application.html.erb +144 -17
  15. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  16. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  17. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  18. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  19. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  20. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  21. data/app/views/solid_observer/dashboard/index.html.erb +34 -4
  22. data/app/views/solid_observer/jobs/show.html.erb +3 -3
  23. data/app/views/solid_observer/storages/show.html.erb +64 -32
  24. data/bin/quality_gate +1 -1
  25. data/config/routes.rb +5 -0
  26. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  27. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  28. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  29. data/lib/generators/solid_observer/templates/initializer.rb.tt +2 -1
  30. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  31. data/lib/solid_observer/cache_subscriber.rb +47 -0
  32. data/lib/solid_observer/cli/storage.rb +16 -13
  33. data/lib/solid_observer/configuration.rb +22 -3
  34. data/lib/solid_observer/engine.rb +44 -7
  35. data/lib/solid_observer/services/cache_operations.rb +115 -0
  36. data/lib/solid_observer/services/cache_stats.rb +329 -0
  37. data/lib/solid_observer/services/cleanup_storage.rb +18 -2
  38. data/lib/solid_observer/services/database_size.rb +13 -8
  39. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  40. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  41. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  42. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  43. data/lib/solid_observer/version.rb +1 -1
  44. data/lib/tasks/solid_observer.rake +29 -0
  45. metadata +23 -1
@@ -0,0 +1,62 @@
1
+ <% content_for :title, "Cache overview" %>
2
+ <% status_class = @cache_dashboard_available ? "so-badge--success" : "so-badge--warning" %>
3
+
4
+ <div class="so-dashboard so-cache-dashboard">
5
+ <span class="sr-only">Cache Dashboard</span>
6
+
7
+ <div class="so-content__header">
8
+ <h1>Cache overview</h1>
9
+
10
+ <% if @cache_dashboard_available %>
11
+ <div class="so-cache-dashboard__intro">
12
+ <span class="so-badge so-badge--pill <%= status_class %>">
13
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
14
+ <circle r="3" cx="3" cy="3" />
15
+ </svg>
16
+ Available
17
+ </span>
18
+ <p class="so-cache-dashboard__hint">Selected range: <%= cache_range_label(@range) %> · keys and values are never shown.</p>
19
+ </div>
20
+ <% end %>
21
+ </div>
22
+
23
+ <% if @cache_dashboard_available %>
24
+ <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar so-cache-dashboard__range-form">
25
+ <label for="so-cache-range" class="so-card__label so-filters__label">Range</label>
26
+ <select id="so-cache-range" name="range" onchange="this.form.submit()">
27
+ <% SolidObserver::Services::CacheStats::RANGES.each_key do |key| %>
28
+ <option value="<%= key %>" <%= "selected" if key == @range %>><%= cache_range_label(key) %></option>
29
+ <% end %>
30
+ </select>
31
+ <button type="submit" class="so-btn so-btn--refresh">Refresh data</button>
32
+ </form>
33
+
34
+ <%= render "solid_observer/cache_dashboard/summary", stats: @stats, storage_components: @storage_components %>
35
+ <%= render "solid_observer/cache_dashboard/charts" %>
36
+
37
+ <% if @stability&.[](:available) %>
38
+ <section class="so-stability" data-so-zone="cache-stability" aria-labelledby="so-cache-stability-heading">
39
+ <span class="so-stability__label" id="so-cache-stability-heading">Stability</span>
40
+ <%= cache_stability_badge(@stability[:state]) %>
41
+ <span class="so-stability__detail"><%= cache_stability_detail(@stability) %></span>
42
+ <a href="#so-cache-events-heading" class="so-stability__link">View sampled events →</a>
43
+ </section>
44
+ <% end %>
45
+
46
+ <%= render "solid_observer/cache_dashboard/recent_events", recent_events: @recent_events %>
47
+ <% else %>
48
+ <section class="so-card so-card--section" aria-labelledby="so-cache-unavailable-heading">
49
+ <header class="so-dashboard-section__header">
50
+ <h2 id="so-cache-unavailable-heading" class="so-dashboard-section__title">Cache dashboard unavailable</h2>
51
+ <span class="so-badge so-badge--pill <%= status_class %>">
52
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
53
+ <circle r="3" cx="3" cy="3" />
54
+ </svg>
55
+ Unavailable
56
+ </span>
57
+ </header>
58
+
59
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Cache dashboard is unavailable because SolidCache support is disabled or not detected. Metrics are unavailable.</p>
60
+ </section>
61
+ <% end %>
62
+ </div>
@@ -0,0 +1,6 @@
1
+ <%= button_to "Clear cache",
2
+ clear_cache_operations_path,
3
+ method: :post,
4
+ class: "so-btn so-btn--danger",
5
+ form: {class: "so-form--inline"},
6
+ data: {confirm: SolidObserver::Services::CacheOperations.message(:clear, :confirmation)} %>
@@ -0,0 +1,60 @@
1
+ <% content_for :title, "Cache controls" %>
2
+ <% cache_operations = SolidObserver::Services::CacheOperations %>
3
+ <% status_class = @cache_controls_available ? "so-badge--success" : "so-badge--warning" %>
4
+
5
+ <div class="so-dashboard so-cache-controls">
6
+ <div class="so-content__header">
7
+ <h1>Cache controls</h1>
8
+ <div class="so-cache-controls__intro">
9
+ <span class="so-badge so-badge--pill <%= status_class %>">
10
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
11
+ <circle r="3" cx="3" cy="3" />
12
+ </svg>
13
+ <%= @cache_controls_available ? "Available" : "Unavailable" %>
14
+ </span>
15
+ <p class="so-cache-controls__hint">Clear or prune SolidCache safely. Cache keys and values are never shown.</p>
16
+ </div>
17
+ </div>
18
+
19
+ <% if @cache_controls_available %>
20
+ <section class="so-card so-card--section" aria-labelledby="so-cache-controls-title">
21
+ <header class="so-dashboard-section__header">
22
+ <h2 id="so-cache-controls-title" class="so-dashboard-section__title">Operational controls</h2>
23
+ </header>
24
+
25
+ <div class="so-cache-control-row">
26
+ <div class="so-cache-control-row__copy">
27
+ <h3 class="so-cache-control-row__title">Prune expired entries</h3>
28
+ <p class="so-cache-control-row__body">Removes expired SolidCache records only. Active cache entries remain eligible for hits.</p>
29
+ </div>
30
+
31
+ <div class="so-cache-control-row__action">
32
+ <%= button_to "Prune expired entries",
33
+ prune_cache_operations_path,
34
+ method: :post,
35
+ class: "so-btn",
36
+ form: {class: "so-form--inline"} %>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="so-cache-control-row">
41
+ <div class="so-cache-control-row__copy">
42
+ <h3 class="so-cache-control-row__title">Clear all cache</h3>
43
+ <p class="so-cache-control-row__body">Evicts all cached application data. Requests may slow while the cache rebuilds.</p>
44
+ </div>
45
+
46
+ <div class="so-cache-control-row__action">
47
+ <%= render "confirm_clear" %>
48
+ </div>
49
+ </div>
50
+ </section>
51
+ <% else %>
52
+ <section class="so-card so-card--section" aria-labelledby="so-cache-controls-unavailable-title">
53
+ <header class="so-dashboard-section__header">
54
+ <h2 id="so-cache-controls-unavailable-title" class="so-dashboard-section__title">Operational controls</h2>
55
+ </header>
56
+
57
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle"><%= cache_operations.unavailable_message %></p>
58
+ </section>
59
+ <% end %>
60
+ </div>
@@ -1,18 +1,46 @@
1
1
  <% content_for :title, "Dashboard" %>
2
2
 
3
+ <% if @component == "cache" %>
4
+ <%= render template: "solid_observer/cache_dashboard/index" %>
5
+ <% else %>
3
6
  <div class="so-dashboard">
7
+ <% queue_enabled = SolidObserver.config.solid_queue_enabled? %>
8
+ <% queue_status_class = queue_enabled ? "so-badge--success" : "so-badge--warning" %>
9
+
10
+ <div class="so-content__header">
11
+ <h1>Queue overview</h1>
12
+ <div class="so-queue-overview__intro">
13
+ <span class="so-badge so-badge--pill <%= queue_status_class %>">
14
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
15
+ <circle r="3" cx="3" cy="3" />
16
+ </svg>
17
+ <%= queue_enabled ? "Available" : "Unavailable" %>
18
+ </span>
19
+ <% if queue_enabled %>
20
+ <p class="so-queue-overview__hint">Selected range: <%= range_label(@range) %></p>
21
+ <% end %>
22
+ </div>
23
+ </div>
24
+
25
+ <% if !queue_enabled %>
26
+ <section class="so-card so-card--section" aria-labelledby="so-queue-unavailable-heading">
27
+ <h2 id="so-queue-unavailable-heading">Queue Unavailable</h2>
28
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--idle">Queue component is disabled or not installed. Enable SolidQueue to access dashboard controls.</p>
29
+ </section>
30
+ <% else %>
4
31
  <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
5
32
  <div class="so-dashboard-toolbar" data-so-live>
6
- <div class="so-dashboard-toolbar__left">
33
+ <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar__left">
7
34
  <label for="so-range" class="so-card__label so-filters__label">Range</label>
8
- <select id="so-range" name="range" data-so-range-select>
35
+ <select id="so-range" name="range" data-so-range-select onchange="this.form.submit()">
9
36
  <% SolidObserver::QueueStats::RANGES.each_key do |key| %>
10
37
  <option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
11
38
  <% end %>
12
39
  </select>
13
40
  <button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
14
41
  <span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
15
- </div>
42
+ <input type="hidden" name="live" value="<%= @live ? 'on' : '' %>">
43
+ </form>
16
44
  <div class="so-dashboard-toolbar__right">
17
45
  <label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
18
46
  <input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
@@ -110,4 +138,6 @@
110
138
  </div>
111
139
  <% end %>
112
140
  <% end %>
113
- </div>
141
+ <% end %>
142
+ </div>
143
+ <% end %>
@@ -52,16 +52,16 @@
52
52
  <tbody>
53
53
  <tr>
54
54
  <td>Error Class</td>
55
- <td><code><%= @execution.error.exception_class %></code></td>
55
+ <td><code><%= @execution.error["exception_class"] %></code></td>
56
56
  </tr>
57
57
  <tr>
58
58
  <td>Message</td>
59
- <td><%= @execution.error.message %></td>
59
+ <td><%= @execution.error["message"] %></td>
60
60
  </tr>
61
61
  </tbody>
62
62
  </table>
63
63
  <div class="so-card__label">Backtrace</div>
64
- <pre class="so-pre so-pre--error"><code><%= @execution.error.backtrace&.first(20)&.join("\n") || "No backtrace available" %></code></pre>
64
+ <pre class="so-pre so-pre--error"><code><%= @execution.error["backtrace"]&.first(20)&.join("\n") || "No backtrace available" %></code></pre>
65
65
  </div>
66
66
 
67
67
  <div class="so-actions">
@@ -1,39 +1,71 @@
1
1
  <% content_for :title, "Storage" %>
2
2
 
3
- <div class="so-content__header">
4
- <h1>Storage</h1>
5
- </div>
6
-
7
- <% if @current_storage %>
8
- <div class="so-stat-cards">
9
- <%= render "solid_observer/shared/stat_card", label: "Database Size", value: number_to_human_size(@current_storage.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %>
10
- <%= render "solid_observer/shared/stat_card", label: "Event Count", value: number_with_delimiter(@current_storage.event_count) %>
11
- <%= render "solid_observer/shared/stat_card", label: "Last Updated", value: time_ago_in_words(@current_storage.recorded_at) + " ago" %>
3
+ <div class="so-dashboard">
4
+ <div class="so-content__header">
5
+ <h1>Storage</h1>
12
6
  </div>
13
7
 
14
- <% if @storage_history.any? %>
15
- <div class="so-card so-card--section">
16
- <div class="so-card__label">Recent Snapshots</div>
17
- <table class="so-table so-table--card">
18
- <thead>
19
- <tr>
20
- <th>Time</th>
21
- <th>Size</th>
22
- <th>Events</th>
23
- </tr>
24
- </thead>
25
- <tbody>
26
- <% @storage_history.each do |snapshot| %>
8
+ <% if @storage_components.any? %>
9
+ <section class="so-dashboard-section" aria-labelledby="component-health-title">
10
+ <header class="so-dashboard-section__header">
11
+ <h2 id="component-health-title" class="so-dashboard-section__title">Component health</h2>
12
+ </header>
13
+
14
+ <div class="so-stat-cards">
15
+ <% @storage_components.each do |component| %>
16
+ <% status_class = component[:available] ? "so-badge--success" : "so-badge--warning" %>
17
+ <% size_value = component[:available] && component[:db_size_bytes] ? number_to_human_size(component[:db_size_bytes], precision: 1, significant: false, strip_insignificant_zeros: false) : "—" %>
18
+ <% records_value = component[:available] && !component[:event_count].nil? ? number_with_delimiter(component[:event_count]) : "—" %>
19
+
20
+ <article class="so-card">
21
+ <div class="so-card__label"><%= component[:label] %></div>
22
+ <div class="so-card__subtitle">
23
+ <span class="so-badge so-badge--pill <%= status_class %>">
24
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3"/></svg>
25
+ <%= component[:available] ? "Available" : "Unavailable" %>
26
+ </span>
27
+ </div>
28
+ <div class="so-card__value"><%= size_value %></div>
29
+ <div class="so-card__subtitle">
30
+ <% if component[:available] %>
31
+ <%= records_value %> <%= component[:record_label] %>
32
+ <% else %>
33
+ <%= component[:unavailable_reason] %>
34
+ <% end %>
35
+ </div>
36
+ </article>
37
+ <% end %>
38
+ </div>
39
+ </section>
40
+
41
+ <% if @storage_history.any? %>
42
+ <div class="so-card so-card--section">
43
+ <div class="so-card__label">Recent Snapshots</div>
44
+ <table class="so-table so-table--card">
45
+ <caption class="sr-only">Recent storage snapshots by component</caption>
46
+ <thead>
27
47
  <tr>
28
- <td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
29
- <td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
30
- <td><%= number_with_delimiter(snapshot.event_count) %></td>
48
+ <th>Time</th>
49
+ <th>Component</th>
50
+ <th>Size</th>
51
+ <th>Records</th>
31
52
  </tr>
32
- <% end %>
33
- </tbody>
34
- </table>
35
- </div>
53
+ </thead>
54
+ <tbody>
55
+ <% @storage_history.each do |snapshot| %>
56
+ <% record_label = snapshot.component == "solid_cache" ? "cache rows" : "observer events" %>
57
+ <tr>
58
+ <td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
59
+ <td><%= snapshot.component.humanize %></td>
60
+ <td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
61
+ <td><%= number_with_delimiter(snapshot.event_count) %> <%= record_label %></td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ <% end %>
68
+ <% else %>
69
+ <%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
36
70
  <% end %>
37
- <% else %>
38
- <%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
39
- <% end %>
71
+ </div>
data/bin/quality_gate CHANGED
@@ -47,7 +47,7 @@ gate_standardrb() {
47
47
  }
48
48
 
49
49
  gate_reek() {
50
- run_gate "Reek" bundle exec reek lib/ app/ db/
50
+ run_gate "Reek" bundle exec reek lib/ app/
51
51
  }
52
52
 
53
53
  gate_appraisal() {
data/config/routes.rb CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  SolidObserver::Engine.routes.draw do
4
4
  root "dashboard#index"
5
+ get "queue", to: "dashboard#index", defaults: {component: "queue"}, as: :queue_dashboard
6
+ get "cache", to: "cache_dashboard#index", as: :cache_dashboard
7
+ get "cache/controls", to: "cache_operations#index", as: :cache_operations
8
+ post "cache/controls/prune", to: "cache_operations#prune", as: :prune_cache_operations
9
+ post "cache/controls/clear", to: "cache_operations#clear", as: :clear_cache_operations
5
10
  get "poll_data", to: "dashboard#poll_data", as: :poll_data
6
11
  get "live_poll.js", to: "dashboard#live_poll", as: :live_poll_script
7
12
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCacheEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cache_events do |t|
6
+ t.string :event_type, null: false, limit: 64
7
+ t.string :key_digest, null: false, limit: 64
8
+ t.boolean :hit
9
+ t.float :duration
10
+ t.string :error_class, limit: 255
11
+ t.text :error_message
12
+ t.text :metadata
13
+ t.datetime :recorded_at, null: false
14
+
15
+ t.index :recorded_at
16
+ t.index :event_type
17
+ t.index :key_digest
18
+ t.index :hit
19
+ t.index :error_class
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCacheMetrics < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cache_metrics do |t|
6
+ t.string :event_type, null: false, limit: 64
7
+ t.datetime :period_start, null: false
8
+ t.bigint :operations_count, null: false, default: 0
9
+ t.bigint :hits_count, null: false, default: 0
10
+ t.bigint :misses_count, null: false, default: 0
11
+ t.bigint :errors_count, null: false, default: 0
12
+ t.float :duration_total, null: false, default: 0.0
13
+
14
+ t.index [:event_type, :period_start], unique: true, name: "idx_solid_observer_cache_metrics_unique"
15
+ t.index :period_start
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddComponentToSolidObserverStorageInfos < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :solid_observer_storage_info, :component, :string, null: false, default: "queue_observer"
6
+ add_index :solid_observer_storage_info, :component
7
+ end
8
+ end
@@ -19,7 +19,8 @@ SolidObserver.configure do |config|
19
19
  # === Queue Observability (v0.1.0) ===
20
20
  config.observe_queue = true
21
21
 
22
- # === Cache Observability (Coming in v0.4.0+) ===
22
+ # === Cache Observability (v0.4.0) ===
23
+ # Enable SolidCache event capture and operational clear/prune controls
23
24
  # config.observe_cache = true
24
25
  # config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
25
26
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module SolidObserver
6
+ class CacheEventBuffer
7
+ include Singleton
8
+
9
+ def initialize
10
+ @mutex = Mutex.new
11
+ @buffer = []
12
+ end
13
+
14
+ def push(event_data)
15
+ config = SolidObserver.config
16
+ return unless config.persistence_mode?
17
+
18
+ should_flush = buffer_ready_to_flush?(event_data, config.buffer_size)
19
+ flush! if should_flush
20
+ end
21
+
22
+ def flush!
23
+ events = @mutex.synchronize do
24
+ buffered_events = @buffer.dup
25
+ @buffer.clear
26
+ buffered_events
27
+ end
28
+ return if events.empty?
29
+
30
+ Services::FlushCacheEventBuffer.call(events)
31
+ rescue => error
32
+ @mutex.synchronize { @buffer = events + @buffer }
33
+ Rails.logger&.error("[SolidObserver] Cache buffer flush failed: #{error.message}") if defined?(Rails)
34
+ end
35
+
36
+ def size
37
+ @mutex.synchronize { @buffer.size }
38
+ end
39
+
40
+ def clear
41
+ @mutex.synchronize { @buffer.clear }
42
+ end
43
+
44
+ private
45
+
46
+ def buffer_ready_to_flush?(event_data, buffer_size)
47
+ @mutex.synchronize do
48
+ @buffer << event_data
49
+ @buffer.size >= buffer_size
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class CacheSubscriber
5
+ EVENTS = %w[
6
+ cache_read.active_support
7
+ cache_write.active_support
8
+ cache_delete.active_support
9
+ cache_exist?.active_support
10
+ cache_read_multi.active_support
11
+ cache_write_multi.active_support
12
+ cache_delete_multi.active_support
13
+ ].freeze
14
+
15
+ class << self
16
+ def subscribe!
17
+ return unless subscription_allowed?
18
+
19
+ @subscriptions = EVENTS.map { |event_name| subscribe_to(event_name) }
20
+ end
21
+
22
+ def unsubscribe!
23
+ return unless @subscriptions
24
+
25
+ @subscriptions.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
26
+ @subscriptions = []
27
+ end
28
+
29
+ def subscribed?
30
+ !!@subscriptions&.any?
31
+ end
32
+
33
+ private
34
+
35
+ def subscription_allowed?
36
+ SolidObserver.config.solid_cache_enabled? && !subscribed? && defined?(ActiveSupport::Notifications)
37
+ end
38
+
39
+ def subscribe_to(event_name)
40
+ ActiveSupport::Notifications.subscribe(event_name) do |*args|
41
+ event = ActiveSupport::Notifications::Event.new(*args)
42
+ Services::RecordCacheEvent.call(event: event, buffer: CacheEventBuffer.instance)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -28,47 +28,50 @@ module SolidObserver
28
28
  end
29
29
 
30
30
  def gather_storage_stats
31
- {
32
- db_size_bytes: SolidObserver::Services::DatabaseSize.call(connection: QueueEvent.connection),
33
- event_count: QueueEvent.count,
34
- max_size_bytes: SolidObserver.config.max_db_size
35
- }
31
+ {components: SolidObserver::Services::StorageInfoSnapshot.call, max_size_bytes: SolidObserver.config.max_db_size}
36
32
  rescue => e
37
33
  {error: "Failed to gather storage stats: #{e.message}"}
38
34
  end
39
35
 
40
36
  def print_storage_table(stats)
41
- event_count = stats[:event_count]
42
- db_size_bytes = stats[:db_size_bytes]
43
37
  max_size_bytes = stats[:max_size_bytes]
38
+ components = stats[:components]
44
39
 
45
40
  table(
46
41
  headers: ["Component", "Size", "Events", "Usage", "Status"],
47
- rows: [storage_row(event_count: event_count, db_size_bytes: db_size_bytes, max_size_bytes: max_size_bytes)]
42
+ rows: components.map { |component| storage_row(component: component, max_size_bytes: max_size_bytes) }
48
43
  )
49
44
 
50
45
  output("")
51
46
  end
52
47
 
53
- def storage_row(event_count:, db_size_bytes:, max_size_bytes:)
54
- size, usage, status = storage_displays(db_size_bytes, max_size_bytes)
48
+ def storage_row(component:, max_size_bytes:)
49
+ event_count = component[:event_count]
50
+ size, usage, status = storage_displays(component: component, max_size_bytes: max_size_bytes)
55
51
 
56
52
  [
57
- "Queue",
53
+ component[:label],
58
54
  size,
59
- format_number(event_count),
55
+ event_count ? format_number(event_count) : "—",
60
56
  usage,
61
57
  status
62
58
  ]
63
59
  end
64
60
 
65
- def storage_displays(db_size_bytes, max_size_bytes)
61
+ def storage_displays(component:, max_size_bytes:)
62
+ return unavailable_displays unless component[:available]
63
+
64
+ db_size_bytes = component[:db_size_bytes]
66
65
  return ["N/A", "N/A", "— Unknown"] unless db_size_bytes
67
66
 
68
67
  percentage = calculate_percentage(db_size_bytes, max_size_bytes)
69
68
  [format_size(bytes_to_mb(db_size_bytes)), "#{percentage}%", status_indicator(percentage)]
70
69
  end
71
70
 
71
+ def unavailable_displays
72
+ ["—", "—", "— Unavailable"]
73
+ end
74
+
72
75
  def print_configuration
73
76
  retention_days = (SolidObserver.config.event_retention / 1.day).to_i
74
77
  max_size_mb = bytes_to_mb(SolidObserver.config.max_db_size)
@@ -25,7 +25,9 @@ module SolidObserver
25
25
  # @note Cache and Cable observers are not yet implemented
26
26
  attr_accessor :observe_cache,
27
27
  :observe_cable,
28
- :cache_sampling_rate
28
+ :cache_sampling_rate,
29
+ :cache_slow_threshold,
30
+ :cache_store_errors
29
31
 
30
32
  # Retention Settings
31
33
  attr_accessor :event_retention
@@ -56,12 +58,13 @@ module SolidObserver
56
58
  @ui_enabled, @ui_base_controller, @ui_username, @ui_password,
57
59
  @storage_mode, @observe_queue, @observe_cache, @observe_cable,
58
60
  @event_retention, @metrics_retention, @max_db_size, @warning_threshold,
59
- @sampling_rate, @cache_sampling_rate, @buffer_size, @flush_interval,
61
+ @sampling_rate, @cache_sampling_rate, @cache_slow_threshold, @cache_store_errors,
62
+ @buffer_size, @flush_interval,
60
63
  @max_buffer_size, @buffer_overflow_strategy, @filter_cache_ttl,
61
64
  @correlation_id_generator = !production?, "::ApplicationController", nil, nil,
62
65
  :persistence, true, false, false,
63
66
  30.days, 90.days, 1.gigabyte, 0.8,
64
- 1.0, 0.1, 1000, 10.seconds,
67
+ 1.0, 0.1, 0.1, true, 1000, 10.seconds,
65
68
  10_000, :drop_old, 1.minute,
66
69
  nil
67
70
  end
@@ -84,6 +87,22 @@ module SolidObserver
84
87
  @storage_mode == :realtime
85
88
  end
86
89
 
90
+ def solid_queue_available?
91
+ !!defined?(::SolidQueue)
92
+ end
93
+
94
+ def solid_cache_available?
95
+ !!defined?(::SolidCache)
96
+ end
97
+
98
+ def solid_queue_enabled?
99
+ observe_queue
100
+ end
101
+
102
+ def solid_cache_enabled?
103
+ observe_cache && solid_cache_available?
104
+ end
105
+
87
106
  def sampling_rate=(value)
88
107
  validate_rate!(:sampling_rate, value)
89
108
  @sampling_rate = value