solid_observer 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +241 -59
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  6. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  7. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  8. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  9. data/app/controllers/solid_observer/application_controller.rb +69 -0
  10. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  11. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  12. data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
  13. data/app/controllers/solid_observer/events_controller.rb +50 -0
  14. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  15. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  16. data/app/helpers/solid_observer/application_helper.rb +244 -0
  17. data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
  18. data/app/models/solid_observer/cache_event.rb +15 -0
  19. data/app/models/solid_observer/cache_metric.rb +14 -0
  20. data/app/models/solid_observer/queue_event.rb +134 -0
  21. data/app/models/solid_observer/queue_metric.rb +1 -1
  22. data/app/models/solid_observer/storage_info.rb +4 -1
  23. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  24. data/app/views/layouts/solid_observer/application.html.erb +597 -0
  25. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  26. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  27. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  28. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  29. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  30. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  31. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  32. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  33. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  34. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  35. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  36. data/app/views/solid_observer/dashboard/index.html.erb +143 -0
  37. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  38. data/app/views/solid_observer/events/index.html.erb +53 -0
  39. data/app/views/solid_observer/events/show.html.erb +47 -0
  40. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  41. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  42. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  43. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  44. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  45. data/app/views/solid_observer/storages/show.html.erb +71 -0
  46. data/bin/quality_gate +95 -0
  47. data/config/routes.rb +22 -0
  48. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  49. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  50. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  51. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  52. data/lib/generators/solid_observer/install_generator.rb +12 -25
  53. data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
  54. data/lib/solid_observer/base_metric.rb +1 -1
  55. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  56. data/lib/solid_observer/cache_subscriber.rb +47 -0
  57. data/lib/solid_observer/chart_buffer.rb +83 -0
  58. data/lib/solid_observer/cli/base.rb +2 -2
  59. data/lib/solid_observer/cli/jobs.rb +2 -2
  60. data/lib/solid_observer/cli/status.rb +20 -2
  61. data/lib/solid_observer/cli/storage.rb +48 -44
  62. data/lib/solid_observer/configuration.rb +67 -38
  63. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  64. data/lib/solid_observer/engine.rb +110 -18
  65. data/lib/solid_observer/params/events_filter.rb +37 -0
  66. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  67. data/lib/solid_observer/queries/events_query.rb +27 -0
  68. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  69. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  70. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  71. data/lib/solid_observer/queue_stats.rb +165 -19
  72. data/lib/solid_observer/services/cache_operations.rb +115 -0
  73. data/lib/solid_observer/services/cache_stats.rb +329 -0
  74. data/lib/solid_observer/services/cleanup_storage.rb +73 -41
  75. data/lib/solid_observer/services/database_size.rb +91 -0
  76. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  77. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  78. data/lib/solid_observer/services/install_migrations.rb +49 -0
  79. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  80. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  81. data/lib/solid_observer/services/record_event.rb +51 -14
  82. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  83. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  84. data/lib/solid_observer/subscriber.rb +15 -8
  85. data/lib/solid_observer/version.rb +1 -1
  86. data/lib/solid_observer.rb +7 -0
  87. data/lib/tasks/solid_observer.rake +39 -2
  88. metadata +77 -1
@@ -0,0 +1,71 @@
1
+ <% content_for :title, "Job ##{@execution.id}" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Job #<%= @execution.id %></h1>
5
+ </div>
6
+
7
+ <div class="so-back-link">
8
+ <%= link_to "← Back to Jobs", jobs_path(status: @status), class: "so-btn" %>
9
+ </div>
10
+
11
+ <div class="so-card so-card--spaced">
12
+ <table class="so-table so-details">
13
+ <tbody>
14
+ <tr>
15
+ <td>Job Class</td>
16
+ <td><code><%= @job&.class_name || "N/A" %></code></td>
17
+ </tr>
18
+ <tr>
19
+ <td>Job ID</td>
20
+ <td><%= @job&.id || "N/A" %></td>
21
+ </tr>
22
+ <tr>
23
+ <td>Status</td>
24
+ <td><%= status_badge(@status) %></td>
25
+ </tr>
26
+ <tr>
27
+ <td>Queue</td>
28
+ <td><%= @presenter.queue_name || "N/A" %></td>
29
+ </tr>
30
+ <tr>
31
+ <td>Priority</td>
32
+ <td><%= @presenter.priority || "N/A" %></td>
33
+ </tr>
34
+ <tr>
35
+ <td>Created At</td>
36
+ <td><%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
37
+ </tr>
38
+ <% if @execution.respond_to?(:scheduled_at) && @execution.scheduled_at %>
39
+ <tr>
40
+ <td>Scheduled At</td>
41
+ <td><%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
42
+ </tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+ </div>
47
+
48
+ <% if @execution.is_a?(SolidQueue::FailedExecution) && @execution.error %>
49
+ <div class="so-card so-card--accent-danger">
50
+ <div class="so-card__label so-card__label--danger">Error Details</div>
51
+ <table class="so-table so-details so-table--card">
52
+ <tbody>
53
+ <tr>
54
+ <td>Error Class</td>
55
+ <td><code><%= @execution.error["exception_class"] %></code></td>
56
+ </tr>
57
+ <tr>
58
+ <td>Message</td>
59
+ <td><%= @execution.error["message"] %></td>
60
+ </tr>
61
+ </tbody>
62
+ </table>
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>
65
+ </div>
66
+
67
+ <div class="so-actions">
68
+ <%= button_to "Retry Job", retry_job_path(@execution.id), method: :post, class: "so-btn so-btn--primary", data: { confirm: "Retry job #{@execution.id}?" } %>
69
+ <%= button_to "Discard Job", discard_job_path(@execution.id), method: :post, class: "so-btn so-btn--danger", data: { confirm: "Discard job #{@execution.id}? This cannot be undone." } %>
70
+ </div>
71
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <% local_assigns[:message] ||= "No data available" %>
2
+ <div class="so-empty">
3
+ <div class="so-empty__icon">📭</div>
4
+ <div class="so-empty__message"><%= message %></div>
5
+ </div>
@@ -0,0 +1,17 @@
1
+ <% if total_pages > 1 %>
2
+ <div class="so-pagination">
3
+ <% if page > 1 %>
4
+ <%= link_to "Previous", url_for(request.query_parameters.merge(page: page - 1)), class: "so-btn" %>
5
+ <% else %>
6
+ <span class="so-btn so-btn--disabled" aria-disabled="true">Previous</span>
7
+ <% end %>
8
+
9
+ <span class="so-btn so-btn--static so-pagination__status">Page <%= page %> of <%= total_pages %></span>
10
+
11
+ <% if page < total_pages %>
12
+ <%= link_to "Next", url_for(request.query_parameters.merge(page: page + 1)), class: "so-btn" %>
13
+ <% else %>
14
+ <span class="so-btn so-btn--disabled" aria-disabled="true">Next</span>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% subtitle = local_assigns[:subtitle] %>
2
+ <% data_key = local_assigns[:data_key] %>
3
+ <div class="so-card">
4
+ <div class="so-card__label"><%= label %></div>
5
+ <% if subtitle.present? %>
6
+ <div class="so-card__subtitle"><%= subtitle %></div>
7
+ <% end %>
8
+ <div class="so-card__value"<%= data_key ? " data-so-card-value=\"#{data_key}\"".html_safe : "".html_safe %>><%= value || 0 %></div>
9
+ </div>
@@ -0,0 +1,71 @@
1
+ <% content_for :title, "Storage" %>
2
+
3
+ <div class="so-dashboard">
4
+ <div class="so-content__header">
5
+ <h1>Storage</h1>
6
+ </div>
7
+
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>
47
+ <tr>
48
+ <th>Time</th>
49
+ <th>Component</th>
50
+ <th>Size</th>
51
+ <th>Records</th>
52
+ </tr>
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" %>
70
+ <% end %>
71
+ </div>
data/bin/quality_gate ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # SolidObserver Quality Gate Suite
5
+ # Runs all non-negotiable gates in sequence.
6
+ # Usage: bin/quality_gate [--skip-appraisal]
7
+ #
8
+ # Individual gates can be run via:
9
+ # bin/quality_gate rspec
10
+ # bin/quality_gate standardrb
11
+ # bin/quality_gate reek
12
+ # bin/quality_gate appraisal
13
+ # bin/quality_gate audit
14
+
15
+ SKIP_APPRAISAL=false
16
+ SINGLE_GATE=""
17
+
18
+ for arg in "$@"; do
19
+ case "$arg" in
20
+ --skip-appraisal) SKIP_APPRAISAL=true ;;
21
+ rspec|standardrb|reek|appraisal|audit) SINGLE_GATE="$arg" ;;
22
+ esac
23
+ done
24
+
25
+ red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
26
+ green() { printf '\033[32m%s\033[0m\n' "$*"; }
27
+ dim() { printf '\033[2m%s\033[0m\n' "$*"; }
28
+
29
+ run_gate() {
30
+ local label="$1"; shift
31
+ dim "⏳ ${label}..."
32
+ if "$@" > /dev/null 2>&1; then
33
+ green "✅ ${label}"
34
+ return 0
35
+ else
36
+ red "❌ ${label} FAILED — check output above"
37
+ return 1
38
+ fi
39
+ }
40
+
41
+ gate_rspec() {
42
+ run_gate "RSpec" bundle exec rspec
43
+ }
44
+
45
+ gate_standardrb() {
46
+ run_gate "StandardRB" bundle exec standardrb
47
+ }
48
+
49
+ gate_reek() {
50
+ run_gate "Reek" bundle exec reek lib/ app/
51
+ }
52
+
53
+ gate_appraisal() {
54
+ run_gate "Rails 8.0 appraisal" bundle exec appraisal rails-8.0 rspec \
55
+ && run_gate "Rails 8.1 appraisal" bundle exec appraisal rails-8.1 rspec
56
+ }
57
+
58
+ gate_audit() {
59
+ dim "⏳ bundler-audit: updating advisory DB..."
60
+ bundle exec bundler-audit update > /dev/null 2>&1 || true
61
+ run_gate "bundler-audit" bundle exec bundler-audit check
62
+ }
63
+
64
+ run_all() {
65
+ local failed=0
66
+
67
+ gate_rspec || failed=$((failed + 1))
68
+ gate_standardrb || failed=$((failed + 1))
69
+ gate_reek || failed=$((failed + 1))
70
+
71
+ if [ "$SKIP_APPRAISAL" = false ]; then
72
+ gate_appraisal || failed=$((failed + 1))
73
+ else
74
+ dim "⏭️ Skipping appraisal (--skip-appraisal)"
75
+ fi
76
+
77
+ gate_audit || failed=$((failed + 1))
78
+
79
+ echo ""
80
+ if [ "$failed" -eq 0 ]; then
81
+ green "All quality gates passed"
82
+ else
83
+ red "${failed} gate(s) failed"
84
+ return 1
85
+ fi
86
+ }
87
+
88
+ case "$SINGLE_GATE" in
89
+ rspec) gate_rspec ;;
90
+ standardrb) gate_standardrb ;;
91
+ reek) gate_reek ;;
92
+ appraisal) gate_appraisal ;;
93
+ audit) gate_audit ;;
94
+ "") run_all ;;
95
+ esac
data/config/routes.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidObserver::Engine.routes.draw do
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
10
+ get "poll_data", to: "dashboard#poll_data", as: :poll_data
11
+ get "live_poll.js", to: "dashboard#live_poll", as: :live_poll_script
12
+
13
+ resources :jobs, only: %i[index show] do
14
+ member do
15
+ post :retry
16
+ post :discard
17
+ end
18
+ end
19
+
20
+ resource :storage, only: %i[show]
21
+ resources :events, only: %i[index show]
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCompositeIndexesToQueueEvents < ActiveRecord::Migration[8.0]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ options = concurrent_supported? ? {algorithm: :concurrently} : {}
8
+
9
+ add_index :solid_observer_queue_events,
10
+ %i[job_class recorded_at],
11
+ order: {recorded_at: :desc},
12
+ if_not_exists: true,
13
+ **options
14
+
15
+ add_index :solid_observer_queue_events,
16
+ %i[queue_name recorded_at],
17
+ order: {recorded_at: :desc},
18
+ if_not_exists: true,
19
+ **options
20
+
21
+ remove_index :solid_observer_queue_events, :job_class, if_exists: true, **options
22
+ remove_index :solid_observer_queue_events, :queue_name, if_exists: true, **options
23
+ end
24
+
25
+ private
26
+
27
+ def concurrent_supported?
28
+ connection.adapter_name.match?(/postgres/i)
29
+ end
30
+ end
@@ -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
@@ -24,6 +24,10 @@ module SolidObserver
24
24
  end
25
25
  end
26
26
 
27
+ def add_engine_mount
28
+ route 'mount SolidObserver::Engine, at: "/solid_observer"'
29
+ end
30
+
27
31
  def show_instructions
28
32
  say "\n"
29
33
  print_banner
@@ -34,38 +38,21 @@ module SolidObserver
34
38
  say " 3. Create database: bin/rails db:create"
35
39
  say " 4. Run migrations: bin/rails db:migrate"
36
40
  say " 5. Restart your Rails server"
41
+ say " 6. Visit /solid_observer to access the web dashboard"
37
42
  say "\n"
38
- say "Documentation: https://solid.observer", :cyan
39
- say "GitHub: https://github.com/bart-oz/solid_observer", :cyan
43
+ say "Documentation: https://solid.observer", :red
44
+ say "GitHub: https://github.com/bart-oz/solid_observer", :red
40
45
  say "\n"
41
46
  end
42
47
 
43
48
  private
44
49
 
45
50
  def print_banner
46
- banner = <<~BANNER
47
-
48
- ███████╗ ██████╗ ██╗ ██╗██████╗
49
- ██╔════╝██╔═══██╗██║ ██║██╔══██╗
50
- ███████╗██║ ██║██║ ██║██║ ██║
51
- ╚════██║██║ ██║██║ ██║██║ ██║
52
- ███████║╚██████╔╝███████╗██║██████╔╝
53
- ╚══════╝ ╚═════╝ ╚══════╝╚═╝╚═════╝
54
-
55
- ██████╗ ██████╗ ███████╗███████╗██████╗ ██╗ ██╗███████╗██████╗
56
- ██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔══██╗
57
- ██║ ██║██████╔╝███████╗█████╗ ██████╔╝██║ ██║█████╗ ██████╔╝
58
- ██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══╝ ██╔══██╗
59
- ╚██████╔╝██████╔╝███████║███████╗██║ ██║ ╚████╔╝ ███████╗██║ ██║
60
- ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝
61
-
62
- Observe your Solid Stack like a pro! 🔭
63
- v#{SolidObserver::VERSION}
64
-
65
- BANNER
66
-
67
- banner.each_line { |line| say line.chomp, :cyan }
68
- say " ✓ SolidObserver installed successfully!", :green
51
+ say " ┌─ ─┐", :red
52
+ say " ◉ solid_observer", :red
53
+ say " └─ ─┘", :red
54
+ say ""
55
+ say " ✓ SolidObserver installed successfully!", :green
69
56
  end
70
57
  end
71
58
  end
@@ -9,10 +9,9 @@ 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 UI (when ui_enabled = true)
13
- # config.http_basic_auth_enabled = true
14
- # config.http_basic_auth_user = "admin"
15
- # config.http_basic_auth_password = "secret"
12
+ # Authentication for web UI set a username to enable HTTP Basic Auth
13
+ # config.ui_username = "admin"
14
+ # config.ui_password = "secret"
16
15
 
17
16
  # Base controller for UI (customize authorization)
18
17
  # config.ui_base_controller = "ApplicationController"
@@ -20,11 +19,12 @@ SolidObserver.configure do |config|
20
19
  # === Queue Observability (v0.1.0) ===
21
20
  config.observe_queue = true
22
21
 
23
- # === Cache Observability (Coming in v0.2.0+) ===
22
+ # === Cache Observability (v0.4.0) ===
23
+ # Enable SolidCache event capture and operational clear/prune controls
24
24
  # config.observe_cache = true
25
25
  # config.cache_sampling_rate = 0.1 # Sample 10% of cache operations
26
26
 
27
- # === Cable Observability (Coming in v0.2.0+) ===
27
+ # === Cable Observability (Coming in v0.5.0+) ===
28
28
  # config.observe_cable = true
29
29
 
30
30
  # Data Retention
@@ -3,7 +3,7 @@
3
3
  module SolidObserver
4
4
  # BaseMetric provides the foundation for time-series metrics storage.
5
5
  #
6
- # NOTE: Metrics functionality is planned for v0.2.0. The database connection
6
+ # NOTE: Metrics functionality is planned for a future release. The database connection
7
7
  # will be configured by the Engine (similar to BaseEvent) when metrics are
8
8
  # fully implemented.
9
9
  #
@@ -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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class ChartBuffer
5
+ INSTANCE_MUTEX = Mutex.new
6
+
7
+ class << self
8
+ def append(value, at: Time.now)
9
+ instance.append(value, at: at)
10
+ end
11
+
12
+ def recent(window_seconds)
13
+ instance.recent(window_seconds)
14
+ end
15
+
16
+ def clear
17
+ instance.clear
18
+ end
19
+
20
+ private
21
+
22
+ def instance
23
+ INSTANCE_MUTEX.synchronize { @instance ||= new }
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ @mutex = Mutex.new
29
+ @samples = []
30
+ @cap = nil
31
+ end
32
+
33
+ def append(value, at: Time.now)
34
+ sample = {t: at.to_i, v: value.to_i}
35
+
36
+ @mutex.synchronize { store_sample(sample) }
37
+
38
+ sample
39
+ end
40
+
41
+ def recent(window_seconds)
42
+ cutoff = Time.now.to_i - window_seconds.to_i
43
+
44
+ @mutex.synchronize do
45
+ @samples.select { |sample| sample[:t] >= cutoff }.map(&:dup)
46
+ end
47
+ end
48
+
49
+ def clear
50
+ @mutex.synchronize do
51
+ @samples.clear
52
+ @cap = nil
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def store_sample(sample)
59
+ @cap ||= compute_cap
60
+ replace_or_append(sample)
61
+ trim_to_cap
62
+ end
63
+
64
+ def replace_or_append(sample)
65
+ latest_sample = @samples.last
66
+
67
+ if latest_sample && latest_sample[:t] == sample[:t]
68
+ @samples[-1] = sample
69
+ else
70
+ @samples << sample
71
+ end
72
+ end
73
+
74
+ def trim_to_cap
75
+ overflow = @samples.length - @cap
76
+ @samples.shift(overflow) if overflow.positive?
77
+ end
78
+
79
+ def compute_cap
80
+ (3600 / 5).to_i # 720 samples — 1h at the 5s cadence
81
+ end
82
+ end
83
+ end
@@ -39,8 +39,8 @@ module SolidObserver
39
39
 
40
40
  widths = calculate_column_widths(headers, rows)
41
41
 
42
- output(format_table_row(headers, widths), color: :cyan)
43
- output(separator_line(widths), color: :cyan)
42
+ output(format_table_row(headers, widths), color: :red)
43
+ output(separator_line(widths), color: :red)
44
44
 
45
45
  rows.each do |row|
46
46
  output(format_table_row(row, widths))