rails_pulse 0.4.2 → 0.5.0.pre.1
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 +33 -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 +130 -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 +24 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -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 +114 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
- 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/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 +2 -6
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
- 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/time_range_helper.rb +1 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
- data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
- data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +40 -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 -5
- 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 -1
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
- data/app/models/rails_pulse/like_pattern.rb +29 -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 -6
- 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/writer_heartbeat.rb +86 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -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 +21 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- 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/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 +37 -0
- data/config/routes.rb +19 -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 +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
- data/lib/rails_pulse/cleanup_service.rb +26 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -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 +40 -0
- data/lib/rails_pulse/cli/exceptions.rb +111 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -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 +72 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +62 -0
- data/lib/rails_pulse/cli/requests.rb +49 -0
- data/lib/rails_pulse/cli/routes.rb +66 -0
- data/lib/rails_pulse/configuration.rb +23 -1
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +76 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +97 -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 +26 -0
- data/lib/rails_pulse/tracker.rb +75 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +96 -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
|
@@ -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 %>
|
|
@@ -29,10 +29,24 @@
|
|
|
29
29
|
critical_label: "critical",
|
|
30
30
|
link_url: exceptions_path %>
|
|
31
31
|
<% end %>
|
|
32
|
+
<%# Nil until a background writer has reported a heartbeat. Shown only
|
|
33
|
+
when a writer is backlogged or dropping requests. %>
|
|
34
|
+
<% if @health_summary[:tracking] %>
|
|
35
|
+
<%= render "rails_pulse/dashboard/health_badge",
|
|
36
|
+
name: "Tracking",
|
|
37
|
+
health: @health_summary[:tracking],
|
|
38
|
+
healthy_label: "writing",
|
|
39
|
+
slow_label: "backlogged",
|
|
40
|
+
critical_label: "dropping",
|
|
41
|
+
hide_when_healthy: true,
|
|
42
|
+
link_url: storage_path %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<%# Shown only when storage is under warning or critical pressure. %>
|
|
32
45
|
<%= render "rails_pulse/dashboard/health_badge",
|
|
33
46
|
name: "Storage",
|
|
34
47
|
health: @health_summary[:storage],
|
|
35
48
|
critical_label: "critical",
|
|
49
|
+
hide_when_healthy: true,
|
|
36
50
|
link_url: storage_path %>
|
|
37
51
|
</div>
|
|
38
52
|
</div>
|
|
@@ -130,7 +144,7 @@
|
|
|
130
144
|
</span>
|
|
131
145
|
<% end %>
|
|
132
146
|
<span class="text-xs text-subtle mis-2">Ranked by severity ·
|
|
133
|
-
<%= humanize_time_range(@selected_time_range) %></span>
|
|
147
|
+
<%= humanize_time_range(@time_range.selected_time_range) %></span>
|
|
134
148
|
</div>
|
|
135
149
|
<div
|
|
136
150
|
data-controller="rails-pulse--popover"
|
|
@@ -179,7 +193,7 @@
|
|
|
179
193
|
</span>
|
|
180
194
|
<p class="font-semibold text-sm text-positive">All clear</p>
|
|
181
195
|
<p class="text-xs text-subtle">No routes, queries, or jobs need attention in the
|
|
182
|
-
<%= humanize_time_range(@selected_time_range) %>.</p>
|
|
196
|
+
<%= humanize_time_range(@time_range.selected_time_range) %>.</p>
|
|
183
197
|
</div>
|
|
184
198
|
<% else %>
|
|
185
199
|
<div class="flex flex-col">
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
>
|
|
25
25
|
<%= form.select :duration,
|
|
26
26
|
duration_options(:query),
|
|
27
|
-
{ selected: @selected_response_range },
|
|
27
|
+
{ selected: @time_range&.selected_response_range },
|
|
28
28
|
{ class: "input" }
|
|
29
29
|
%>
|
|
30
30
|
<%= link_to "Reset", query_path(@query), class: "btn btn--plain", style: "text-decoration: none; color: var(--color-text-muted);" if params.has_key?(:q) %>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<%# Zone every chart on the page is bucketed and labelled in. The short
|
|
2
|
+
label is always visible; the title carries the full zone name and, when
|
|
3
|
+
a controller resolved one, the exact window being shown. %>
|
|
4
|
+
<span class="zone-badge" title="<%= time_range_zone_tooltip %>"><%= RailsPulse::TimeRange.aggregation_zone_short_label %></span>
|
|
@@ -120,4 +120,41 @@
|
|
|
120
120
|
<% end %>
|
|
121
121
|
</dl>
|
|
122
122
|
<% end %>
|
|
123
|
+
|
|
124
|
+
<%= render "rails_pulse/components/panel",
|
|
125
|
+
title: "Tracking writers",
|
|
126
|
+
help_heading: "The background writer",
|
|
127
|
+
help_text: "Each process queues tracked requests for one writer thread, which reports a heartbeat every minute. When a queue is full the newest request is dropped rather than slowing the app; dropped requests mean the charts undercount traffic. Raise async_queue_size or fix database latency." do %>
|
|
128
|
+
<% tracking = @status.tracking %>
|
|
129
|
+
<dl class="descriptive-list mbs-4">
|
|
130
|
+
<dt>Live writers</dt>
|
|
131
|
+
<dd><%= tracking[:live_count] %></dd>
|
|
132
|
+
<dt>Queue depth</dt>
|
|
133
|
+
<dd><%= number_with_delimiter(tracking[:queue_depth]) %><% if tracking[:queue_size].to_i.positive? %> <span class="text-subtle">/ <%= number_with_delimiter(tracking[:queue_size]) %> per process</span><% end %></dd>
|
|
134
|
+
<dt>Dropped in the last hour</dt>
|
|
135
|
+
<dd class="<%= tracking[:dropped].to_i.positive? ? "text-negative font-semibold" : "" %>"><%= number_with_delimiter(tracking[:dropped]) %></dd>
|
|
136
|
+
<dt>Last heartbeat</dt>
|
|
137
|
+
<dd>
|
|
138
|
+
<% if tracking[:last_sampled_at] %>
|
|
139
|
+
<%= human_readable_occurred_at(tracking[:last_sampled_at]) %>
|
|
140
|
+
<span class="text-subtle">(<%= time_ago_in_words(tracking[:last_sampled_at]) %>)</span>
|
|
141
|
+
<% else %>
|
|
142
|
+
Never — heartbeats start when a process with <code>async</code> on tracks its first request
|
|
143
|
+
<% end %>
|
|
144
|
+
</dd>
|
|
145
|
+
<% tracking[:processes].each do |process| %>
|
|
146
|
+
<dt><code><%= process[:label] %></code></dt>
|
|
147
|
+
<dd>
|
|
148
|
+
<%= number_with_delimiter(process[:queue_depth]) %> / <%= number_with_delimiter(process[:queue_size]) %> queued
|
|
149
|
+
<% if process[:dropped_last_hour].positive? %>
|
|
150
|
+
<span class="badge storage-table__percent storage-table__percent--critical"><%= number_with_delimiter(process[:dropped_last_hour]) %> dropped</span>
|
|
151
|
+
<% else %>
|
|
152
|
+
<span class="badge storage-table__percent storage-table__percent--uncapped">0 dropped</span>
|
|
153
|
+
<% end %>
|
|
154
|
+
<br>
|
|
155
|
+
<span class="text-subtle">last heartbeat <%= time_ago_in_words(process[:last_seen_at]) %></span>
|
|
156
|
+
</dd>
|
|
157
|
+
<% end %>
|
|
158
|
+
</dl>
|
|
159
|
+
<% end %>
|
|
123
160
|
</div>
|