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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/README.md +241 -59
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/assets/stylesheets/solid_observer/application.css +18 -0
- data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
- data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
- data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
- data/app/controllers/solid_observer/application_controller.rb +69 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
- data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
- data/app/controllers/solid_observer/events_controller.rb +50 -0
- data/app/controllers/solid_observer/jobs_controller.rb +85 -0
- data/app/controllers/solid_observer/storages_controller.rb +12 -0
- data/app/helpers/solid_observer/application_helper.rb +244 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
- data/app/models/solid_observer/cache_event.rb +15 -0
- data/app/models/solid_observer/cache_metric.rb +14 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +597 -0
- data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
- data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
- data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
- data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
- data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
- data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
- data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
- data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
- data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
- data/app/views/solid_observer/dashboard/index.html.erb +143 -0
- data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
- data/app/views/solid_observer/events/index.html.erb +53 -0
- data/app/views/solid_observer/events/show.html.erb +47 -0
- data/app/views/solid_observer/jobs/index.html.erb +61 -0
- data/app/views/solid_observer/jobs/show.html.erb +71 -0
- data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
- data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
- data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
- data/app/views/solid_observer/storages/show.html.erb +71 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
- data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
- data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- data/lib/solid_observer/chart_buffer.rb +83 -0
- data/lib/solid_observer/cli/base.rb +2 -2
- data/lib/solid_observer/cli/jobs.rb +2 -2
- data/lib/solid_observer/cli/status.rb +20 -2
- data/lib/solid_observer/cli/storage.rb +48 -44
- data/lib/solid_observer/configuration.rb +67 -38
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +110 -18
- data/lib/solid_observer/params/events_filter.rb +37 -0
- data/lib/solid_observer/params/jobs_filter.rb +35 -0
- data/lib/solid_observer/queries/events_query.rb +27 -0
- data/lib/solid_observer/queries/execution_finder.rb +42 -0
- data/lib/solid_observer/queries/job_executions_query.rb +73 -0
- data/lib/solid_observer/queue_event_buffer.rb +163 -25
- data/lib/solid_observer/queue_stats.rb +165 -19
- data/lib/solid_observer/services/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +73 -41
- data/lib/solid_observer/services/database_size.rb +91 -0
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
- data/lib/solid_observer/services/install_migrations.rb +49 -0
- data/lib/solid_observer/services/record_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- data/lib/solid_observer/services/ui_auth_check.rb +65 -0
- data/lib/solid_observer/subscriber.rb +15 -8
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +7 -0
- data/lib/tasks/solid_observer.rake +39 -2
- 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,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", :
|
|
39
|
-
say "GitHub: https://github.com/bart-oz/solid_observer", :
|
|
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
|
-
|
|
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
|
|
13
|
-
# config.
|
|
14
|
-
# config.
|
|
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 (
|
|
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.
|
|
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
|
|
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: :
|
|
43
|
-
output(separator_line(widths), color: :
|
|
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))
|