rails_pulse 0.4.2 → 0.5.0.pre.2
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 +58 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
- data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
- data/app/jobs/rails_pulse/summary_job.rb +13 -0
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +5 -18
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
- data/app/models/rails_pulse/cleanup_run.rb +32 -0
- data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
- data/app/models/rails_pulse/cloud/installation.rb +55 -0
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
- data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
- data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +41 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
- data/app/models/rails_pulse/like_pattern.rb +35 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/cloud/batch.rb +83 -0
- data/app/services/rails_pulse/cloud/client.rb +68 -0
- data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
- data/app/services/rails_pulse/cloud/health_item.rb +77 -0
- data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
- data/app/services/rails_pulse/cloud/preview.rb +83 -0
- data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
- data/app/services/rails_pulse/cloud/record_items.rb +60 -0
- data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
- data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
- data/app/services/rails_pulse/cloud/sync.rb +275 -0
- data/app/services/rails_pulse/config_recommendations.rb +132 -0
- data/app/services/rails_pulse/deployment_comparison.rb +177 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/change_point.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/period_insights.rb +131 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +39 -2
- data/config/routes.rb +23 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +19 -0
- data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +37 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +54 -0
- data/lib/rails_pulse/cli/exceptions.rb +116 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +76 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +67 -0
- data/lib/rails_pulse/cli/requests.rb +57 -0
- data/lib/rails_pulse/cli/routes.rb +73 -0
- data/lib/rails_pulse/configuration.rb +100 -2
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +78 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
- data/lib/rails_pulse/tracker.rb +76 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +123 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class SummaryService
|
|
3
|
+
# Summary rows for an hour, computed from raw requests, operations, job
|
|
4
|
+
# runs and exception occurrences.
|
|
5
|
+
#
|
|
6
|
+
# Each kind is read with one query, sorted by summarizable and then
|
|
7
|
+
# duration so every summarizable's durations arrive as one consecutive,
|
|
8
|
+
# already-sorted run; the overall request row and the per-route rows
|
|
9
|
+
# share a single read. Memory is bounded by one hour's rows, and the
|
|
10
|
+
# hour costs one query per kind however many routes, queries or jobs it
|
|
11
|
+
# saw. Percentiles are exact.
|
|
12
|
+
#
|
|
13
|
+
# Rows carry the summarizable and its metrics; SummaryService adds the
|
|
14
|
+
# period columns.
|
|
15
|
+
class FromRawRows
|
|
16
|
+
def initialize(time_range)
|
|
17
|
+
@time_range = time_range
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Always exactly one row, even for an empty hour (see SummaryService).
|
|
21
|
+
def request_rows
|
|
22
|
+
durations = request_data.map { |_, duration, _| duration }.compact.sort
|
|
23
|
+
statuses = request_data.map { |_, _, status| status }
|
|
24
|
+
|
|
25
|
+
[ row("RailsPulse::Request", 0).merge(Metrics.duration(durations), Metrics.status(statuses)) ]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def route_rows
|
|
29
|
+
request_data.slice_when { |previous, current| previous.first != current.first }.map do |rows|
|
|
30
|
+
durations = rows.map { |_, duration, _| duration }
|
|
31
|
+
statuses = rows.map { |_, _, status| status }
|
|
32
|
+
|
|
33
|
+
row("RailsPulse::Route", rows.first.first).merge(
|
|
34
|
+
Metrics.duration(durations),
|
|
35
|
+
Metrics.status(statuses),
|
|
36
|
+
count: rows.size
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def query_rows
|
|
42
|
+
rows_by_summarizable(Operation.where(occurred_at: @time_range), :query_id).filter_map do |query_id, rows|
|
|
43
|
+
durations = rows.map(&:first).compact
|
|
44
|
+
next if durations.empty?
|
|
45
|
+
|
|
46
|
+
row("RailsPulse::Query", query_id).merge(Metrics.duration(durations))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def job_rows
|
|
51
|
+
scope = JobRun.where(occurred_at: @time_range).where(status: JobRun::FINAL_STATUSES)
|
|
52
|
+
runs_by_job = rows_by_summarizable(scope, :job_id, :status)
|
|
53
|
+
return [] if runs_by_job.empty?
|
|
54
|
+
|
|
55
|
+
# A run whose job row has gone (count-based cleanup) has nothing to
|
|
56
|
+
# summarize against.
|
|
57
|
+
known_job_ids = Job.where(id: runs_by_job.map(&:first)).pluck(:id).to_set
|
|
58
|
+
|
|
59
|
+
runs_by_job.filter_map do |job_id, runs|
|
|
60
|
+
next unless known_job_ids.include?(job_id)
|
|
61
|
+
|
|
62
|
+
durations = runs.map(&:first).compact.map(&:to_f)
|
|
63
|
+
next if durations.empty?
|
|
64
|
+
|
|
65
|
+
statuses = runs.map(&:second)
|
|
66
|
+
row("RailsPulse::Job", job_id).merge(
|
|
67
|
+
Metrics.duration(durations),
|
|
68
|
+
count: runs.size,
|
|
69
|
+
error_count: statuses.count { |s| s != "success" },
|
|
70
|
+
success_count: statuses.count { |s| s == "success" }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Per-group counts plus an all-groups row (id 0), so the dashboard can
|
|
76
|
+
# chart total exception volume without loading one series per group.
|
|
77
|
+
def exception_rows
|
|
78
|
+
counts = ExceptionOccurrence.where(occurred_at: @time_range).group(:exception_group_id).count
|
|
79
|
+
return [] if counts.empty?
|
|
80
|
+
|
|
81
|
+
rows = counts.map { |group_id, occurrences| row("RailsPulse::ExceptionGroup", group_id).merge(count: occurrences) }
|
|
82
|
+
rows << row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def row(summarizable_type, summarizable_id)
|
|
88
|
+
{ summarizable_type: summarizable_type, summarizable_id: summarizable_id }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One read serves the overall request row and every route's. route_id
|
|
92
|
+
# and duration are NOT NULL on requests, so ordering by (route_id,
|
|
93
|
+
# duration) hands each route its durations already sorted.
|
|
94
|
+
def request_data
|
|
95
|
+
@request_data ||= Request
|
|
96
|
+
.where(occurred_at: @time_range)
|
|
97
|
+
.order(:route_id, :duration)
|
|
98
|
+
.pluck(:route_id, :duration, :status)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# [[summarizable_id, [[duration, *extra_columns], ...]], ...], with
|
|
102
|
+
# each summarizable's rows in ascending duration order (NULL durations
|
|
103
|
+
# land at one end or the other depending on the database; callers
|
|
104
|
+
# compact them).
|
|
105
|
+
def rows_by_summarizable(scope, column, *extra_columns)
|
|
106
|
+
scope
|
|
107
|
+
.where.not(column => nil)
|
|
108
|
+
.order(column, :duration)
|
|
109
|
+
.pluck(column, :duration, *extra_columns)
|
|
110
|
+
.slice_when { |previous, current| previous.first != current.first }
|
|
111
|
+
.map { |rows| [ rows.first.first, rows.map { |row| row.drop(1) } ] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class SummaryService
|
|
3
|
+
# The duration and status columns of a summary row, computed from raw
|
|
4
|
+
# values.
|
|
5
|
+
module Metrics
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# `durations` must be sorted. Empty input yields count 0 and nil
|
|
9
|
+
# percentiles, which is what an idle period should record.
|
|
10
|
+
def duration(durations)
|
|
11
|
+
avg = durations.any? ? durations.sum.to_f / durations.size : 0
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
count: durations.size,
|
|
15
|
+
avg_duration: avg,
|
|
16
|
+
min_duration: durations.first,
|
|
17
|
+
max_duration: durations.last,
|
|
18
|
+
total_duration: durations.sum,
|
|
19
|
+
p50_duration: Statistics.calculate_percentile(durations, 0.5),
|
|
20
|
+
p95_duration: Statistics.calculate_percentile(durations, 0.95),
|
|
21
|
+
p99_duration: Statistics.calculate_percentile(durations, 0.99),
|
|
22
|
+
stddev_duration: Statistics.calculate_stddev(durations, avg)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status(statuses)
|
|
27
|
+
{
|
|
28
|
+
error_count: statuses.count { |s| s >= 500 },
|
|
29
|
+
success_count: statuses.count { |s| s < 500 },
|
|
30
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
|
31
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
|
32
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
|
33
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
|
-
# Aggregates one period (hour, day, week or month)
|
|
3
|
-
#
|
|
2
|
+
# Aggregates one period (hour, day, week or month) into
|
|
3
|
+
# rails_pulse_summaries rows.
|
|
4
|
+
#
|
|
5
|
+
# Hours are computed from raw rows by FromRawRows. Days are rolled up from
|
|
6
|
+
# their hours, and weeks and months from their days, by FromChildPeriods,
|
|
7
|
+
# so a long period never re-reads raw rows: its cost does not grow with
|
|
8
|
+
# its length, and it stays correct after raw retention has pruned them.
|
|
9
|
+
#
|
|
10
|
+
# Every period gets one overall request row (type RailsPulse::Request,
|
|
11
|
+
# summarizable_id 0), written even when the period is empty (count: 0) so
|
|
12
|
+
# its timestamp keeps advancing every period — and only once the period
|
|
13
|
+
# has ended. It is the heartbeat several
|
|
14
|
+
# health checks use to detect whether SummaryJob is still running
|
|
15
|
+
# (dashboard banner, rails_pulse:status, StoragePressure staleness, and
|
|
16
|
+
# CleanupService's summarized_cutoff), and the marker this service uses to
|
|
17
|
+
# tell whether a child period has been summarized.
|
|
4
18
|
#
|
|
5
19
|
# Rows are written with upsert_all against the summaries table's unique
|
|
6
20
|
# index, one statement per summarizable kind, so re-running a period (the
|
|
@@ -10,6 +24,11 @@ module RailsPulse
|
|
|
10
24
|
class SummaryService
|
|
11
25
|
UNIQUE_INDEX = :idx_pulse_summaries_unique
|
|
12
26
|
|
|
27
|
+
# The period each longer period is rolled up from. Weeks and months build
|
|
28
|
+
# from days because weeks do not tile months.
|
|
29
|
+
CHILD_PERIOD_TYPES = { "day" => "hour", "week" => "day", "month" => "day" }.freeze
|
|
30
|
+
CHILD_PERIOD_STEPS = { "hour" => 1.hour, "day" => 1.day }.freeze
|
|
31
|
+
|
|
13
32
|
attr_reader :period_type, :start_time, :end_time
|
|
14
33
|
|
|
15
34
|
def initialize(period_type, start_time)
|
|
@@ -19,15 +38,35 @@ module RailsPulse
|
|
|
19
38
|
end
|
|
20
39
|
|
|
21
40
|
def perform
|
|
41
|
+
# A period is summarized only once it has ended. The overall request
|
|
42
|
+
# row doubles as the heartbeat that CleanupService's summarized_cutoff
|
|
43
|
+
# and summarize_missing_child_periods trust to mean "fully aggregated";
|
|
44
|
+
# written mid-period, it would let cleanup delete raw rows that were
|
|
45
|
+
# never counted and let a later rollup bake the partial numbers in as
|
|
46
|
+
# final.
|
|
47
|
+
if end_time >= Time.current
|
|
48
|
+
RailsPulse.logger.info "Skipping #{period_type} summary for #{start_time}: period has not ended"
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
22
52
|
RailsPulse.logger.info "Starting #{period_type} summary for #{start_time}"
|
|
23
53
|
|
|
54
|
+
summarize_missing_child_periods if rollup?
|
|
55
|
+
|
|
56
|
+
# Rows are computed before the transaction opens. A busy period's
|
|
57
|
+
# aggregation can take seconds; doing it with the transaction already
|
|
58
|
+
# open leaves it idle from the database's perspective and vulnerable to
|
|
59
|
+
# a configured idle_in_transaction_session_timeout on installs that set
|
|
60
|
+
# one.
|
|
61
|
+
rows = summary_rows
|
|
62
|
+
|
|
24
63
|
# The engine's own connection: on a separate-database install
|
|
25
64
|
# ActiveRecord::Base would open the transaction on the host's primary.
|
|
26
65
|
RailsPulse::ApplicationRecord.transaction do
|
|
27
|
-
upsert_summaries(
|
|
28
|
-
upsert_summaries(
|
|
29
|
-
upsert_summaries(
|
|
30
|
-
|
|
66
|
+
upsert_summaries(rows[:requests_and_routes])
|
|
67
|
+
upsert_summaries(rows[:queries])
|
|
68
|
+
upsert_summaries(rows[:jobs])
|
|
69
|
+
upsert_exception_summaries(rows[:exceptions])
|
|
31
70
|
end
|
|
32
71
|
|
|
33
72
|
RailsPulse.logger.info "Completed #{period_type} summary"
|
|
@@ -38,126 +77,42 @@ module RailsPulse
|
|
|
38
77
|
|
|
39
78
|
private
|
|
40
79
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# status columns, query rows do not, exception rows carry only a count.
|
|
44
|
-
def upsert_summaries(rows)
|
|
45
|
-
return if rows.empty?
|
|
46
|
-
|
|
47
|
-
# MySQL resolves the conflict through any unique key and rejects an
|
|
48
|
-
# explicit target.
|
|
49
|
-
unique_by = Summary.connection.supports_insert_conflict_target? ? UNIQUE_INDEX : nil
|
|
50
|
-
Summary.upsert_all(rows, unique_by: unique_by)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def summary_row(summarizable_type, summarizable_id)
|
|
54
|
-
{
|
|
55
|
-
summarizable_type: summarizable_type,
|
|
56
|
-
summarizable_id: summarizable_id,
|
|
57
|
-
period_type: period_type,
|
|
58
|
-
period_start: start_time,
|
|
59
|
-
period_end: end_time
|
|
60
|
-
}
|
|
80
|
+
def rollup?
|
|
81
|
+
CHILD_PERIOD_TYPES.key?(period_type)
|
|
61
82
|
end
|
|
62
83
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
avg_duration: avg,
|
|
71
|
-
min_duration: durations.first,
|
|
72
|
-
max_duration: durations.last,
|
|
73
|
-
total_duration: durations.sum,
|
|
74
|
-
p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
|
|
75
|
-
p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
|
|
76
|
-
p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
|
|
77
|
-
stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg)
|
|
78
|
-
}
|
|
84
|
+
def source
|
|
85
|
+
@source ||=
|
|
86
|
+
if rollup?
|
|
87
|
+
FromChildPeriods.new(child_period_type, child_period_starts)
|
|
88
|
+
else
|
|
89
|
+
FromRawRows.new(start_time...end_time)
|
|
90
|
+
end
|
|
79
91
|
end
|
|
80
92
|
|
|
81
|
-
def
|
|
93
|
+
def summary_rows
|
|
82
94
|
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
|
88
|
-
status_5xx: statuses.count { |s| s >= 500 }
|
|
95
|
+
requests_and_routes: with_period(source.request_rows + source.route_rows),
|
|
96
|
+
queries: with_period(source.query_rows),
|
|
97
|
+
jobs: with_period(source.job_rows),
|
|
98
|
+
exceptions: with_period(exception_rows)
|
|
89
99
|
}
|
|
90
100
|
end
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# the heartbeat several health checks use to detect whether SummaryJob is
|
|
95
|
-
# still running (dashboard banner, rails_pulse:status, StoragePressure
|
|
96
|
-
# staleness, and CleanupService's summarized_cutoff). summarizable_id 0
|
|
97
|
-
# marks the overall rollup.
|
|
98
|
-
def request_summary_rows
|
|
99
|
-
request_data = Request.where(occurred_at: start_time...end_time).pluck(:duration, :status)
|
|
100
|
-
durations = request_data.map(&:first).compact.sort
|
|
101
|
-
statuses = request_data.map(&:second)
|
|
102
|
-
|
|
103
|
-
[ summary_row("RailsPulse::Request", 0).merge(duration_metrics(durations), status_metrics(statuses)) ]
|
|
102
|
+
def with_period(rows)
|
|
103
|
+
rows.map { |row| row.merge(period_type: period_type, period_start: start_time, period_end: end_time) }
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
all_rows.group_by(&:first).map do |route_id, rows|
|
|
113
|
-
durations = rows.map { |_, d, _| d }.compact.sort
|
|
114
|
-
statuses = rows.map { |_, _, s| s }
|
|
115
|
-
|
|
116
|
-
summary_row("RailsPulse::Route", route_id)
|
|
117
|
-
.merge(duration_metrics(durations), status_metrics(statuses), count: rows.size)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def query_summary_rows
|
|
122
|
-
all_rows = Operation
|
|
123
|
-
.where(occurred_at: start_time...end_time)
|
|
124
|
-
.where.not(query_id: nil)
|
|
125
|
-
.pluck(:query_id, :duration)
|
|
126
|
-
|
|
127
|
-
all_rows.group_by(&:first).filter_map do |query_id, rows|
|
|
128
|
-
durations = rows.map(&:last).compact.sort
|
|
129
|
-
next if durations.empty?
|
|
130
|
-
|
|
131
|
-
summary_row("RailsPulse::Query", query_id).merge(duration_metrics(durations))
|
|
132
|
-
end
|
|
133
|
-
end
|
|
106
|
+
# Every row in one call must have the same keys, which is why the
|
|
107
|
+
# summarizable kinds are upserted separately: request and route rows carry
|
|
108
|
+
# status columns, query rows do not, exception rows carry only a count.
|
|
109
|
+
def upsert_summaries(rows)
|
|
110
|
+
return if rows.empty?
|
|
134
111
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.where.not(job_id: nil)
|
|
140
|
-
.pluck(:job_id, :duration, :status)
|
|
141
|
-
return [] if all_rows.empty?
|
|
142
|
-
|
|
143
|
-
# A run whose job row has gone (count-based cleanup) has nothing to
|
|
144
|
-
# summarize against.
|
|
145
|
-
known_job_ids = Job.where(id: all_rows.map(&:first).uniq).pluck(:id).to_set
|
|
146
|
-
|
|
147
|
-
all_rows.group_by(&:first).filter_map do |job_id, runs|
|
|
148
|
-
next unless known_job_ids.include?(job_id)
|
|
149
|
-
|
|
150
|
-
durations = runs.map { |_, d, _| d }.compact.map(&:to_f).sort
|
|
151
|
-
next if durations.empty?
|
|
152
|
-
|
|
153
|
-
statuses = runs.map { |_, _, s| s }
|
|
154
|
-
summary_row("RailsPulse::Job", job_id).merge(
|
|
155
|
-
duration_metrics(durations),
|
|
156
|
-
count: runs.size,
|
|
157
|
-
error_count: statuses.count { |s| s != "success" },
|
|
158
|
-
success_count: statuses.count { |s| s == "success" }
|
|
159
|
-
)
|
|
160
|
-
end
|
|
112
|
+
# MySQL resolves the conflict through any unique key and rejects an
|
|
113
|
+
# explicit target.
|
|
114
|
+
unique_by = Summary.connection.supports_insert_conflict_target? ? UNIQUE_INDEX : nil
|
|
115
|
+
Summary.upsert_all(rows, unique_by: unique_by)
|
|
161
116
|
end
|
|
162
117
|
|
|
163
118
|
# Exception frequency, per group and overall.
|
|
@@ -167,28 +122,61 @@ module RailsPulse
|
|
|
167
122
|
# something happened last week — the history is gone as soon as cleanup
|
|
168
123
|
# runs. Only `count` is meaningful here; the duration columns stay null
|
|
169
124
|
# because an exception has no duration.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
rows << summary_row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
|
|
125
|
+
#
|
|
126
|
+
# Exceptions are the newest summarizable and the only optional one, so
|
|
127
|
+
# both the query here and the upsert in upsert_exception_summaries are
|
|
128
|
+
# individually rescued: a failure in either must not lose the route,
|
|
129
|
+
# query and job summaries computed or written alongside it.
|
|
130
|
+
def exception_rows
|
|
131
|
+
return [] unless RailsPulse.configuration.track_exceptions
|
|
132
|
+
return [] unless ExceptionOccurrence.table_exists?
|
|
133
|
+
|
|
134
|
+
source.exception_rows
|
|
135
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
136
|
+
RailsPulse.logger.error "Exception summary skipped: #{e.message}"
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
185
139
|
|
|
140
|
+
def upsert_exception_summaries(rows)
|
|
186
141
|
upsert_summaries(rows)
|
|
187
142
|
rescue ActiveRecord::ActiveRecordError => e
|
|
188
|
-
# Exceptions are the newest summarizable and the only optional one. A
|
|
189
|
-
# failure here must not lose the route, query and job summaries that were
|
|
190
|
-
# written in the same transaction.
|
|
191
143
|
RailsPulse.logger.error "Exception summary skipped: #{e.message}"
|
|
192
144
|
end
|
|
145
|
+
|
|
146
|
+
def child_period_type
|
|
147
|
+
CHILD_PERIOD_TYPES.fetch(period_type)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Every child boundary inside this period. perform refuses periods that
|
|
151
|
+
# have not ended, so a rollup's children have all ended too.
|
|
152
|
+
def child_period_starts
|
|
153
|
+
@child_period_starts ||= begin
|
|
154
|
+
step = CHILD_PERIOD_STEPS.fetch(child_period_type)
|
|
155
|
+
starts = []
|
|
156
|
+
current = start_time
|
|
157
|
+
while current <= end_time
|
|
158
|
+
starts << current
|
|
159
|
+
current += step
|
|
160
|
+
end
|
|
161
|
+
starts
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A child period without its heartbeat row was never summarized
|
|
166
|
+
# (SummaryJob was not running, or a backfill asked only for the longer
|
|
167
|
+
# period), so it is summarized now, recursively, before being rolled up.
|
|
168
|
+
def summarize_missing_child_periods
|
|
169
|
+
summarized = Summary
|
|
170
|
+
.where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: child_period_type)
|
|
171
|
+
.where(period_start: start_time..end_time)
|
|
172
|
+
.pluck(:period_start)
|
|
173
|
+
.to_set(&:to_i)
|
|
174
|
+
|
|
175
|
+
child_period_starts.each do |child_start|
|
|
176
|
+
next if summarized.include?(child_start.to_i)
|
|
177
|
+
|
|
178
|
+
SummaryService.new(child_period_type, child_start).perform
|
|
179
|
+
end
|
|
180
|
+
end
|
|
193
181
|
end
|
|
194
182
|
end
|
|
@@ -17,12 +17,12 @@ module RailsPulse
|
|
|
17
17
|
def self.scope(model_class, disabled_tags, show_non_tagged)
|
|
18
18
|
relation = model_class.all
|
|
19
19
|
|
|
20
|
-
# Exclude items with disabled tags.
|
|
21
|
-
# default escape character, so without it an underscore in a tag is
|
|
22
|
-
# never matched literally there and the exclusion matches nothing.
|
|
20
|
+
# Exclude items with disabled tags.
|
|
23
21
|
disabled_tags.each do |tag|
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
relation = relation.where.not(
|
|
23
|
+
"tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}",
|
|
24
|
+
RailsPulse::LikePattern.containing(tag)
|
|
25
|
+
)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Exclude non-tagged items if show_non_tagged is false
|
|
@@ -136,7 +136,10 @@ end %>
|
|
|
136
136
|
<div class="flex items-start justify-between">
|
|
137
137
|
<div>
|
|
138
138
|
<h2 class="text-lg font-semibold mbe-4">Custom Date Range</h2>
|
|
139
|
-
<p class="text-sm text-subtle">
|
|
139
|
+
<p class="text-sm text-subtle">
|
|
140
|
+
Select a custom date and time range for filtering data. All times are in
|
|
141
|
+
<%= RailsPulse::TimeRange.aggregation_zone_label %>.
|
|
142
|
+
</p>
|
|
140
143
|
</div>
|
|
141
144
|
<%= link_to '#', "aria-label": "Close", data: { action: "click->rails-pulse--time-range#cancelCustom" }, class: "text-subtle hover:text-default" do %>
|
|
142
145
|
<%= rails_pulse_icon "x", width: "20" %>
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<div>
|
|
27
27
|
<h4 class="text-xs font-medium text-subtle uppercase">Occurred At</h4>
|
|
28
28
|
<div class="text-sm">
|
|
29
|
-
<%= operation.occurred_at.
|
|
29
|
+
<%= operation.occurred_at.strftime("%H:%M:%S.%L") %>
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
<% end %>
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<%= column[:label] %>
|
|
7
7
|
<% else %>
|
|
8
8
|
<% sort_params = {} %>
|
|
9
|
-
<% sort_params[:zoom_start_time] = @zoom_start if @zoom_start.present? %>
|
|
10
|
-
<% sort_params[:zoom_end_time] = @zoom_end if @zoom_end.present? %>
|
|
9
|
+
<% sort_params[:zoom_start_time] = @time_range&.zoom_start if @time_range&.zoom_start.present? %>
|
|
10
|
+
<% sort_params[:zoom_end_time] = @time_range&.zoom_end if @time_range&.zoom_end.present? %>
|
|
11
11
|
<% sort_params[:selected_column_time] = params[:selected_column_time] if params[:selected_column_time].present? %>
|
|
12
12
|
<%= sort_link @ransack_query, column[:field], column[:label],
|
|
13
13
|
sort_params.merge(
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<div class="grid-item" style="<%= flex_style if defined?(flex_style) %>">
|
|
2
2
|
<div class="card b-full flex flex-col">
|
|
3
3
|
<div class="flex mbe-2">
|
|
4
|
-
<h2 class="grow font-semibold leading-none uppercase text-xs"
|
|
4
|
+
<h2 class="grow font-semibold leading-none uppercase text-xs">
|
|
5
|
+
<%= title %>
|
|
6
|
+
<% if chart_data %>
|
|
7
|
+
· <%= render "rails_pulse/shared/aggregation_zone_badge" %>
|
|
8
|
+
<% end %>
|
|
9
|
+
</h2>
|
|
5
10
|
<div class="panel-icon-bar flex gap items-center">
|
|
6
11
|
<% if chart_data %>
|
|
7
12
|
<%= series_toggles %>
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
<div>
|
|
35
35
|
<p class="font-semibold uppercase text-xs text-subtle mbe-1">In this range</p>
|
|
36
36
|
<p class="text-lg font-bold"><%= number_with_delimiter(@deployment_count) %></p>
|
|
37
|
-
<p class="text-xs text-subtle"><%= humanize_time_range(@selected_time_range) %></p>
|
|
37
|
+
<p class="text-xs text-subtle"><%= humanize_time_range(@time_range.selected_time_range) %></p>
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
<% end %>
|
|
56
56
|
<% else %>
|
|
57
57
|
<p class="text-sm text-subtle">
|
|
58
|
-
No deployments recorded in the <%= humanize_time_range(@selected_time_range) %>.
|
|
58
|
+
No deployments recorded in the <%= humanize_time_range(@time_range.selected_time_range) %>.
|
|
59
59
|
Post to the deployments endpoint from your deploy pipeline, or run
|
|
60
60
|
<code>rails_pulse:record_deployment</code>, and they will appear here and as markers on the charts.
|
|
61
61
|
</p>
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
<%# Exceptions do not have a "slow" state — a group that fired at all is
|
|
2
2
|
firing — so the two non-critical labels are overridable. %>
|
|
3
|
-
<%
|
|
4
|
-
<%
|
|
5
|
-
<%
|
|
6
|
-
<%
|
|
7
|
-
<%
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<% if
|
|
20
|
-
|
|
21
|
-
<%
|
|
22
|
-
|
|
3
|
+
<% hide_when_healthy = local_assigns.fetch(:hide_when_healthy, false) %>
|
|
4
|
+
<% is_healthy = health[:critical].zero? && health[:slow].zero? %>
|
|
5
|
+
<% unless hide_when_healthy && is_healthy %>
|
|
6
|
+
<% healthy_label = local_assigns.fetch(:healthy_label, "healthy") %>
|
|
7
|
+
<% slow_label = local_assigns.fetch(:slow_label, "slow") %>
|
|
8
|
+
<% dot_color = health[:critical] > 0 ? "var(--color-negative)" : (health[:slow] > 0 ? "#eab308" : "var(--color-positive)") %>
|
|
9
|
+
<% border_color = health[:critical] > 0 ? "color-mix(in srgb, var(--color-negative) 40%, transparent)" : (health[:slow] > 0 ? "color-mix(in srgb, #eab308 40%, transparent)" : "color-mix(in srgb, var(--color-positive) 40%, transparent)") %>
|
|
10
|
+
<% subtle_parts = [] %>
|
|
11
|
+
<% subtle_parts << "#{health[:healthy]} #{healthy_label}" if health[:healthy] > 0 %>
|
|
12
|
+
<% subtle_parts << "#{health[:slow]} #{slow_label}" if health[:slow] > 0 %>
|
|
13
|
+
<% subtle_parts << "0 #{healthy_label}" if subtle_parts.empty? && health[:critical].zero? %>
|
|
14
|
+
<% critical_part = "#{health[:critical]} #{critical_label}" if health[:critical] > 0 %>
|
|
15
|
+
<% badge = capture do %>
|
|
16
|
+
<span class="badge flex items-center gap-half p-2" style="border-color: <%= border_color %>;">
|
|
17
|
+
<span class="shrink-0 rounded-full" style="width: 10px; height: 10px; background: <%= dot_color %>;"></span>
|
|
18
|
+
<span class="text-xs font-semibold"><%= name %></span>
|
|
19
|
+
<% if subtle_parts.any? %>
|
|
20
|
+
<span class="text-xs text-subtle"><%= subtle_parts.join(" · ") %><%= " ·" if critical_part %></span>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if critical_part %>
|
|
23
|
+
<span class="text-xs font-semibold text-negative"><%= critical_part %></span>
|
|
24
|
+
<% end %>
|
|
25
|
+
</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% if local_assigns[:link_url].present? %>
|
|
28
|
+
<%= link_to link_url, class: "health-badge-link" do %><%= badge %><% end %>
|
|
29
|
+
<% else %>
|
|
30
|
+
<%= badge %>
|
|
31
|
+
<% end %>
|
|
23
32
|
<% end %>
|