solid_observer 0.3.0 → 0.5.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +195 -82
  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 +52 -0
  9. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  10. data/app/controllers/solid_observer/dashboard_controller.rb +38 -1
  11. data/app/controllers/solid_observer/storages_controller.rb +1 -1
  12. data/app/helpers/solid_observer/application_helper.rb +268 -5
  13. data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
  14. data/app/models/solid_observer/cable_event.rb +13 -0
  15. data/app/models/solid_observer/cable_metric.rb +12 -0
  16. data/app/models/solid_observer/cache_event.rb +15 -0
  17. data/app/models/solid_observer/cache_metric.rb +13 -0
  18. data/app/models/solid_observer/storage_info.rb +4 -1
  19. data/app/views/layouts/solid_observer/application.html.erb +157 -19
  20. data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
  21. data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +34 -0
  22. data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
  23. data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
  24. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  25. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  26. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  27. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  28. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  29. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  30. data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
  31. data/app/views/solid_observer/dashboard/index.html.erb +32 -5
  32. data/app/views/solid_observer/events/index.html.erb +1 -0
  33. data/app/views/solid_observer/jobs/index.html.erb +1 -0
  34. data/app/views/solid_observer/jobs/show.html.erb +3 -3
  35. data/app/views/solid_observer/storages/show.html.erb +90 -32
  36. data/config/routes.rb +7 -0
  37. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  38. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  39. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  40. data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
  41. data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
  42. data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
  43. data/lib/generators/solid_observer/install_generator.rb +8 -1
  44. data/lib/generators/solid_observer/templates/initializer.rb.tt +20 -4
  45. data/lib/solid_observer/base_event.rb +1 -1
  46. data/lib/solid_observer/base_metric.rb +1 -1
  47. data/lib/solid_observer/base_record.rb +8 -0
  48. data/lib/solid_observer/cable_event_buffer.rb +28 -0
  49. data/lib/solid_observer/cable_metric_buffer.rb +230 -0
  50. data/lib/solid_observer/cable_subscriber.rb +57 -0
  51. data/lib/solid_observer/cache_event_buffer.rb +28 -0
  52. data/lib/solid_observer/cache_metric_buffer.rb +229 -0
  53. data/lib/solid_observer/cache_subscriber.rb +47 -0
  54. data/lib/solid_observer/chart_buffer.rb +84 -27
  55. data/lib/solid_observer/cli/storage.rb +16 -13
  56. data/lib/solid_observer/configuration.rb +67 -5
  57. data/lib/solid_observer/engine.rb +70 -15
  58. data/lib/solid_observer/event_buffer_core.rb +218 -0
  59. data/lib/solid_observer/queue_event_buffer.rb +9 -201
  60. data/lib/solid_observer/services/cable_operations.rb +74 -0
  61. data/lib/solid_observer/services/cable_stats.rb +385 -0
  62. data/lib/solid_observer/services/cache_operations.rb +115 -0
  63. data/lib/solid_observer/services/cache_stats.rb +346 -0
  64. data/lib/solid_observer/services/cleanup_storage.rb +98 -47
  65. data/lib/solid_observer/services/database_size.rb +13 -8
  66. data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
  67. data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
  68. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  69. data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
  70. data/lib/solid_observer/services/record_cable_event.rb +114 -0
  71. data/lib/solid_observer/services/record_cable_metric.rb +73 -0
  72. data/lib/solid_observer/services/record_cache_event.rb +165 -0
  73. data/lib/solid_observer/services/record_cache_metric.rb +66 -0
  74. data/lib/solid_observer/services/storage_info_snapshot.rb +216 -0
  75. data/lib/solid_observer/version.rb +1 -1
  76. data/lib/solid_observer.rb +36 -11
  77. data/lib/tasks/solid_observer.rake +111 -21
  78. metadata +47 -5
  79. data/bin/console +0 -11
  80. data/bin/quality_gate +0 -95
  81. data/bin/setup +0 -8
@@ -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>
@@ -8,6 +8,7 @@
8
8
  <% all_queue_names = (queues.keys | performed_by_queue.keys | failed_by_queue.keys).sort %>
9
9
  <% if all_queue_names.any? %>
10
10
  <table class="so-table so-table--card">
11
+ <caption class="sr-only">Queue throughput list</caption>
11
12
  <thead>
12
13
  <tr>
13
14
  <th>Queue</th>
@@ -1,18 +1,43 @@
1
1
  <% content_for :title, "Dashboard" %>
2
2
 
3
3
  <div class="so-dashboard">
4
+ <% queue_enabled = SolidObserver.config.solid_queue_enabled? %>
5
+ <% queue_status_class = queue_enabled ? "so-badge--success" : "so-badge--warning" %>
6
+
7
+ <div class="so-content__header">
8
+ <h1>Queue overview</h1>
9
+ <div class="so-queue-overview__intro">
10
+ <span class="so-badge so-badge--pill <%= queue_status_class %>">
11
+ <svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true">
12
+ <circle r="3" cx="3" cy="3" />
13
+ </svg>
14
+ <%= queue_enabled ? "Available" : "Unavailable" %>
15
+ </span>
16
+ <% if queue_enabled %>
17
+ <p class="so-queue-overview__hint">Selected range: <%= range_label(@range) %></p>
18
+ <% end %>
19
+ </div>
20
+ </div>
21
+
22
+ <% if !queue_enabled %>
23
+ <section class="so-card so-card--section" aria-labelledby="so-queue-unavailable-heading">
24
+ <h2 id="so-queue-unavailable-heading">Queue Unavailable</h2>
25
+ <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>
26
+ </section>
27
+ <% else %>
4
28
  <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
5
- <div class="so-dashboard-toolbar" data-so-live>
6
- <div class="so-dashboard-toolbar__left">
29
+ <div class="so-dashboard-toolbar" data-so-live data-so-poll-url="<%= poll_data_path %>">
30
+ <form action="<%= request.path %>" method="get" class="so-dashboard-toolbar__left">
7
31
  <label for="so-range" class="so-card__label so-filters__label">Range</label>
8
- <select id="so-range" name="range" data-so-range-select>
32
+ <select id="so-range" name="range" data-so-range-select onchange="this.form.submit()">
9
33
  <% SolidObserver::QueueStats::RANGES.each_key do |key| %>
10
34
  <option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
11
35
  <% end %>
12
36
  </select>
13
37
  <button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
14
38
  <span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
15
- </div>
39
+ <input type="hidden" name="live" value="<%= @live ? 'on' : '' %>">
40
+ </form>
16
41
  <div class="so-dashboard-toolbar__right">
17
42
  <label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
18
43
  <input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
@@ -83,6 +108,7 @@
83
108
  <div class="so-card">
84
109
  <div class="so-card__label">Recent Events</div>
85
110
  <table class="so-table so-table--card">
111
+ <caption class="sr-only">Recent queue events</caption>
86
112
  <thead>
87
113
  <tr>
88
114
  <th>Event</th>
@@ -110,4 +136,5 @@
110
136
  </div>
111
137
  <% end %>
112
138
  <% end %>
113
- </div>
139
+ <% end %>
140
+ </div>
@@ -25,6 +25,7 @@
25
25
 
26
26
  <% if @events.any? %>
27
27
  <table class="so-table so-table--listing">
28
+ <caption class="sr-only">Events list</caption>
28
29
  <thead>
29
30
  <tr>
30
31
  <th>Event</th>
@@ -20,6 +20,7 @@
20
20
 
21
21
  <% if @jobs.any? %>
22
22
  <table class="so-table so-table--listing">
23
+ <caption class="sr-only">Jobs list</caption>
23
24
  <thead>
24
25
  <tr>
25
26
  <th>ID</th>
@@ -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,97 @@
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
+ <% if component[:component] == "solid_cable" %>
32
+ <% parts = ["#{records_value} #{component[:record_label]}"] %>
33
+ <% parts << "#{number_with_delimiter(component[:trimmable_count])} trimmable" unless component[:trimmable_count].nil? %>
34
+ <% unless component[:oldest_message_age_seconds].nil? %>
35
+ <% age = component[:oldest_message_age_seconds] %>
36
+ <% if age < 3600 %>
37
+ <% value = [age / 60, 1].max; unit = "m" %>
38
+ <% elsif age < 86_400 %>
39
+ <% value = age / 3600; unit = "h" %>
40
+ <% else %>
41
+ <% value = age / 86_400; unit = "d" %>
42
+ <% end %>
43
+ <% parts << "oldest #{value}#{unit}" %>
44
+ <% end %>
45
+ <%= parts.join(" · ") %>
46
+ <% else %>
47
+ <%= records_value %> <%= component[:record_label] %>
48
+ <% end %>
49
+ <% else %>
50
+ <%= component[:unavailable_reason] %>
51
+ <% end %>
52
+ </div>
53
+ </article>
54
+ <% end %>
55
+ </div>
56
+ </section>
57
+
58
+ <% if @storage_history.any? %>
59
+ <div class="so-card so-card--section">
60
+ <div class="so-card__label">Recent Snapshots</div>
61
+ <table class="so-table so-table--card">
62
+ <caption class="sr-only">Recent storage snapshots by component</caption>
63
+ <thead>
27
64
  <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>
65
+ <th>Time</th>
66
+ <th>Component</th>
67
+ <th>Size</th>
68
+ <th>Records</th>
31
69
  </tr>
32
- <% end %>
33
- </tbody>
34
- </table>
35
- </div>
70
+ </thead>
71
+ <tbody>
72
+ <% @storage_history.each do |snapshot| %>
73
+ <% record_label = case snapshot.component
74
+ when "solid_cache" then "cache rows"
75
+ when "solid_cable" then "messages"
76
+ else "observer events"
77
+ end %>
78
+ <% component_label = case snapshot.component
79
+ when "solid_cable" then "Solid Cable messages"
80
+ when "cable_observer" then "Cable telemetry"
81
+ else snapshot.component.humanize
82
+ end %>
83
+ <tr>
84
+ <td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
85
+ <td><%= component_label %></td>
86
+ <td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
87
+ <td><%= number_with_delimiter(snapshot.event_count) %> <%= record_label %></td>
88
+ </tr>
89
+ <% end %>
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ <% end %>
94
+ <% else %>
95
+ <%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
36
96
  <% end %>
37
- <% else %>
38
- <%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
39
- <% end %>
97
+ </div>
data/config/routes.rb CHANGED
@@ -2,6 +2,13 @@
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 "cable", to: "cable_dashboard#index", as: :cable_dashboard
8
+ post "cable/trim", to: "cable_operations#trim", as: :trim_cable_operations
9
+ get "cache/controls", to: "cache_operations#index", as: :cache_operations
10
+ post "cache/controls/prune", to: "cache_operations#prune", as: :prune_cache_operations
11
+ post "cache/controls/clear", to: "cache_operations#clear", as: :clear_cache_operations
5
12
  get "poll_data", to: "dashboard#poll_data", as: :poll_data
6
13
  get "live_poll.js", to: "dashboard#live_poll", as: :live_poll_script
7
14
 
@@ -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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddEventTypeRecordedAtIndexToCacheEvents < ActiveRecord::Migration[8.0]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ options = concurrent_supported? ? {algorithm: :concurrently} : {}
8
+
9
+ add_index :solid_observer_cache_events,
10
+ %i[event_type recorded_at],
11
+ order: {recorded_at: :desc},
12
+ if_not_exists: true,
13
+ **options
14
+ end
15
+
16
+ private
17
+
18
+ def concurrent_supported?
19
+ connection.adapter_name.match?(/postgres/i)
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCableEvents < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cable_events do |t|
6
+ t.string :event_type, null: false, limit: 64
7
+ t.string :channel_class, limit: 255
8
+ t.string :broadcasting_digest, limit: 64
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 :channel_class
18
+ t.index :broadcasting_digest
19
+ t.index :error_class
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidObserverCableMetrics < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :solid_observer_cable_metrics do |t|
6
+ t.datetime :period_start, null: false
7
+ t.bigint :broadcasts_count, null: false, default: 0
8
+ t.bigint :transmissions_count, null: false, default: 0
9
+ t.bigint :confirmations_count, null: false, default: 0
10
+ t.bigint :rejections_count, null: false, default: 0
11
+ t.bigint :perform_actions_count, null: false, default: 0
12
+ t.bigint :errors_count, null: false, default: 0
13
+
14
+ t.index :period_start, unique: true, name: "idx_solid_observer_cable_metrics_unique"
15
+ end
16
+ end
17
+ end
@@ -17,8 +17,11 @@ module SolidObserver
17
17
  %w[development test production].each do |env|
18
18
  config_block = <<-YAML
19
19
  solid_observer_queue:
20
- <<: *default
20
+ adapter: sqlite3
21
+ pool: 5
22
+ timeout: 5000
21
23
  database: storage/#{env}_solid_observer_queue.sqlite3
24
+ migrations_paths: db/solid_observer_migrate
22
25
  YAML
23
26
  inject_into_file "config/database.yml", config_block, after: /^#{env}:\n(?: .*\n)*/
24
27
  end
@@ -40,6 +43,10 @@ module SolidObserver
40
43
  say " 5. Restart your Rails server"
41
44
  say " 6. Visit /solid_observer to access the web dashboard"
42
45
  say "\n"
46
+ say "IMPORTANT: If your host app uses PostgreSQL or MySQL, review the", :yellow
47
+ say "solid_observer_queue entries in config/database.yml before running", :yellow
48
+ say "db:create. The generated config uses adapter: sqlite3 by default.", :yellow
49
+ say "\n"
43
50
  say "Documentation: https://solid.observer", :red
44
51
  say "GitHub: https://github.com/bart-oz/solid_observer", :red
45
52
  say "\n"
@@ -9,22 +9,38 @@ SolidObserver.configure do |config|
9
9
  # Recommended: false in production, true in development/staging
10
10
  config.ui_enabled = !Rails.env.production?
11
11
 
12
- # Authentication for web UI set a username to enable HTTP Basic Auth
12
+ # Production dashboard exposure is the host app's responsibility. If you mount
13
+ # the dashboard in production, wrap the mount in your existing admin auth
14
+ # constraint, for example in config/routes.rb:
15
+ #
16
+ # authenticate :user, ->(user) { user.admin? } do
17
+ # mount SolidObserver::Engine, at: "/solid_observer"
18
+ # end
19
+ #
20
+ # Built-in HTTP Basic Auth is enabled only when BOTH credentials are present.
21
+ # Setting only one credential disables Basic Auth and logs a boot warning.
13
22
  # config.ui_username = "admin"
14
23
  # config.ui_password = "secret"
15
24
 
16
- # Base controller for UI (customize authorization)
25
+ # Base controller used only to detect API-only rendering compatibility
17
26
  # config.ui_base_controller = "ApplicationController"
18
27
 
19
28
  # === Queue Observability (v0.1.0) ===
20
29
  config.observe_queue = true
21
30
 
22
- # === Cache Observability (Coming in v0.4.0+) ===
31
+ # === Cache Observability (v0.4.0) ===
32
+ # Enable SolidCache event capture and operational clear/prune controls
23
33
  # config.observe_cache = true
24
34
  # config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
25
35
 
26
- # === Cable Observability (Coming in v0.5.0+) ===
36
+ # === Cable Observability (v0.5.0) ===
37
+ # Enable SolidCable event capture and trim controls.
38
+ # Requires SolidCable in the host app; SolidObserver does not add it.
27
39
  # config.observe_cable = true
40
+ # config.cable_sampling_rate = 0.1 # Sample 10% of broadcast events
41
+ # config.cable_rejection_threshold = 0.05 # Rejection rate → Degraded stability
42
+ # config.cable_backlog_threshold = 0.10 # Backlog ratio threshold
43
+ # config.cable_error_threshold = 0.0 # Error rate threshold
28
44
 
29
45
  # Data Retention
30
46
  config.event_retention = 30.days # How long to keep event records
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidObserver
4
- class BaseEvent < ActiveRecord::Base
4
+ class BaseEvent < BaseRecord
5
5
  self.abstract_class = true
6
6
 
7
7
  # connects_to is configured by the engine after Rails initializes
@@ -7,7 +7,7 @@ module SolidObserver
7
7
  # will be configured by the Engine (similar to BaseEvent) when metrics are
8
8
  # fully implemented.
9
9
  #
10
- class BaseMetric < ActiveRecord::Base
10
+ class BaseMetric < BaseRecord
11
11
  self.abstract_class = true
12
12
  self.table_name = "solid_observer_metrics"
13
13
 
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class BaseRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ # connects_to is configured by the engine after Rails initializes
7
+ end
8
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ require_relative "event_buffer_core"
6
+
7
+ module SolidObserver
8
+ class CableEventBuffer
9
+ include Singleton
10
+ include EventBufferCore
11
+
12
+ INITIAL_METRICS = EventBufferCore::INITIAL_METRICS
13
+
14
+ def initialize
15
+ initialize_event_buffer
16
+ end
17
+
18
+ private
19
+
20
+ def flush_service
21
+ Services::FlushCableEventBuffer
22
+ end
23
+
24
+ def log_label
25
+ "Cable buffer"
26
+ end
27
+ end
28
+ end