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,132 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Suggests retuning route_thresholds and query_thresholds when one summary
|
|
3
|
+
# period shows them to be out of step with the application: a slow
|
|
4
|
+
# threshold most of the slowest routes or queries exceed only adds noise,
|
|
5
|
+
# and a critical threshold nothing came within half of will never fire.
|
|
6
|
+
#
|
|
7
|
+
# Reads the period's ten slowest routes (by P95) and ten most expensive
|
|
8
|
+
# queries (by total time). The query critical check also reads the
|
|
9
|
+
# period's highest query P95, because a rarely run slow query is not among
|
|
10
|
+
# the most expensive yet is exactly what a critical threshold must catch.
|
|
11
|
+
# Each recommendation carries a config_snippet that keeps the threshold
|
|
12
|
+
# keys it does not change.
|
|
13
|
+
#
|
|
14
|
+
# A recommendation is only emitted when its suggestion actually moves the
|
|
15
|
+
# threshold the way the text says and keeps slow < very_slow < critical in
|
|
16
|
+
# order: a "lower your critical threshold" snippet that repeats or raises
|
|
17
|
+
# the current value, or merges the very_slow and critical bands, would be
|
|
18
|
+
# worse than silence.
|
|
19
|
+
class ConfigRecommendations
|
|
20
|
+
SAMPLE_SIZE = 10
|
|
21
|
+
|
|
22
|
+
def self.for_period(period_type:, period_start:)
|
|
23
|
+
period = RailsPulse::Summary.where(period_type: period_type, period_start: period_start)
|
|
24
|
+
|
|
25
|
+
route_rows = period.for_routes.order(p95_duration: :desc).limit(SAMPLE_SIZE).map do |summary|
|
|
26
|
+
{ route_id: summary.summarizable_id, p95_duration: summary.p95_duration&.round(0) }
|
|
27
|
+
end
|
|
28
|
+
query_rows = period.for_queries.order(Arel.sql("rails_pulse_summaries.count * rails_pulse_summaries.avg_duration DESC")).limit(SAMPLE_SIZE).map do |summary|
|
|
29
|
+
{ query_id: summary.summarizable_id, p95_duration: summary.p95_duration&.round(0) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
query_max_p95 = period.for_queries.maximum(:p95_duration)&.round(0)
|
|
33
|
+
|
|
34
|
+
new(route_rows: route_rows, query_rows: query_rows, query_max_p95: query_max_p95)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# query_max_p95 is the period's highest query P95; without it the
|
|
38
|
+
# highest among query_rows stands in.
|
|
39
|
+
def initialize(route_rows:, query_rows:, query_max_p95: nil)
|
|
40
|
+
@route_rows = route_rows
|
|
41
|
+
@query_rows = query_rows
|
|
42
|
+
@query_max_p95 = query_max_p95
|
|
43
|
+
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
44
|
+
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_recommendations
|
|
48
|
+
route_recommendations + query_recommendations
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def route_recommendations
|
|
54
|
+
return [] if @route_rows.size < 3
|
|
55
|
+
|
|
56
|
+
slow = @route_thresholds[:slow].to_i
|
|
57
|
+
critical = @route_thresholds[:critical].to_i
|
|
58
|
+
p95s = @route_rows.map { |r| r[:p95_duration].to_i }
|
|
59
|
+
above_slow = p95s.count { |p| p >= slow }
|
|
60
|
+
max_p95 = p95s.max.to_i
|
|
61
|
+
recs = []
|
|
62
|
+
|
|
63
|
+
suggested = ceil_to(slow * 1.5, 50)
|
|
64
|
+
if above_slow >= 3 && above_slow.to_f / @route_rows.size >= 0.4 && suggested < @route_thresholds[:very_slow].to_i
|
|
65
|
+
recs << {
|
|
66
|
+
title: "Route slow threshold may be too low",
|
|
67
|
+
detail: "#{above_slow} of #{@route_rows.size} sampled routes exceeded the #{slow}ms slow " \
|
|
68
|
+
"threshold this period, which may generate more noise than signal in " \
|
|
69
|
+
"the Needs Attention section.",
|
|
70
|
+
config_snippet: snippet("route_thresholds", @route_thresholds, slow: suggested)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
suggested = [ ceil_to(max_p95 * 2.0, 500), slow * 3, @route_thresholds[:very_slow].to_i + 500 ].max
|
|
75
|
+
if max_p95 > 0 && max_p95 < critical / 2 && suggested < critical
|
|
76
|
+
recs << {
|
|
77
|
+
title: "Route critical threshold may be too permissive",
|
|
78
|
+
detail: "No route came close to the #{critical}ms critical threshold this period " \
|
|
79
|
+
"(highest P95: #{max_p95}ms). Lowering it helps surface real regressions " \
|
|
80
|
+
"before they become user-visible.",
|
|
81
|
+
config_snippet: snippet("route_thresholds", @route_thresholds, critical: suggested)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
recs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def query_recommendations
|
|
89
|
+
return [] if @query_rows.size < 3
|
|
90
|
+
|
|
91
|
+
slow = @query_thresholds[:slow].to_i
|
|
92
|
+
critical = @query_thresholds[:critical].to_i
|
|
93
|
+
p95s = @query_rows.map { |r| r[:p95_duration].to_i }
|
|
94
|
+
above_slow = p95s.count { |p| p >= slow }
|
|
95
|
+
max_p95 = (@query_max_p95 || p95s.max).to_i
|
|
96
|
+
recs = []
|
|
97
|
+
|
|
98
|
+
suggested = ceil_to(slow * 1.5, 10)
|
|
99
|
+
if above_slow >= 3 && above_slow.to_f / @query_rows.size >= 0.4 && suggested < @query_thresholds[:very_slow].to_i
|
|
100
|
+
recs << {
|
|
101
|
+
title: "Query slow threshold may be too low",
|
|
102
|
+
detail: "#{above_slow} of #{@query_rows.size} sampled queries exceeded the #{slow}ms slow " \
|
|
103
|
+
"threshold this period.",
|
|
104
|
+
config_snippet: snippet("query_thresholds", @query_thresholds, slow: suggested)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
suggested = [ ceil_to(max_p95 * 2.0, 100), slow * 3, @query_thresholds[:very_slow].to_i + 100 ].max
|
|
109
|
+
if max_p95 > 0 && max_p95 < critical / 2 && suggested < critical
|
|
110
|
+
recs << {
|
|
111
|
+
title: "Query critical threshold may be too permissive",
|
|
112
|
+
detail: "No query came close to the #{critical}ms critical threshold this period " \
|
|
113
|
+
"(highest P95: #{max_p95}ms).",
|
|
114
|
+
config_snippet: snippet("query_thresholds", @query_thresholds, critical: suggested)
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
recs
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The whole hash, so pasting the line does not drop very_slow.
|
|
122
|
+
def snippet(setting, current, **changes)
|
|
123
|
+
pairs = current.merge(changes).map { |key, value| "#{key}: #{value}" }
|
|
124
|
+
"config.#{setting} = { #{pairs.join(', ')} }"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Round value up to the nearest multiple of step.
|
|
128
|
+
def ceil_to(value, step)
|
|
129
|
+
(value.to_f / step).ceil * step
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Whether a deployment made the application slower or more error-prone,
|
|
3
|
+
# answered from the hourly overall request summaries either side of it.
|
|
4
|
+
#
|
|
5
|
+
# Every hour the deploy ran in is skipped: during a rolling deploy those
|
|
6
|
+
# hours hold traffic from both versions. "Before" is the hour ending at
|
|
7
|
+
# the start of the hour the deploy started in, "after" the hour following
|
|
8
|
+
# the hour it finished in (the start hour when no finish was recorded), so
|
|
9
|
+
# a deploy running 14:28-15:40 compares 13:00-14:00 with 16:00-17:00, and
|
|
10
|
+
# its answer is available once SummaryJob has summarized 16:00.
|
|
11
|
+
#
|
|
12
|
+
# Computed on request and never stored. A metric degraded when it is more
|
|
13
|
+
# than its multiplier worse after the deploy and worse by at least the
|
|
14
|
+
# regression_thresholds absolute floor; either window holding fewer than
|
|
15
|
+
# MINIMUM_REQUESTS requests makes the comparison insufficient_data.
|
|
16
|
+
class DeploymentComparison
|
|
17
|
+
METRICS = %i[avg_response_time p95_response_time error_rate].freeze
|
|
18
|
+
|
|
19
|
+
MULTIPLIERS = {
|
|
20
|
+
avg_response_time: 1.5,
|
|
21
|
+
p95_response_time: 1.5,
|
|
22
|
+
error_rate: 1.25
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
UNITS = {
|
|
26
|
+
avg_response_time: "ms",
|
|
27
|
+
p95_response_time: "ms",
|
|
28
|
+
error_rate: "%"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
MINIMUM_REQUESTS = 10
|
|
32
|
+
|
|
33
|
+
# SummaryJob runs hourly, so the after-hour's row should exist within
|
|
34
|
+
# this long of that hour ending; a row still missing after that will not
|
|
35
|
+
# appear on its own, and the comparison is reported unavailable rather
|
|
36
|
+
# than leaving a polling agent waiting on a job that is not running.
|
|
37
|
+
SUMMARY_GRACE = 2.hours
|
|
38
|
+
|
|
39
|
+
# One comparison per deployment, reading every hourly row they need in
|
|
40
|
+
# a single query so a page of deployments costs one statement.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Integer => Hash}] keyed by deployment id
|
|
43
|
+
def self.for(deployments)
|
|
44
|
+
deployments = deployments.to_a
|
|
45
|
+
return {} if deployments.empty?
|
|
46
|
+
|
|
47
|
+
hours = deployments.flat_map { |deployment| windows(deployment).values }
|
|
48
|
+
rows = RailsPulse::Summary.overall_requests
|
|
49
|
+
.where(period_type: "hour", period_start: hours.uniq)
|
|
50
|
+
.index_by(&:period_start)
|
|
51
|
+
|
|
52
|
+
deployments.to_h do |deployment|
|
|
53
|
+
[ deployment.id, new(deployment, rows: rows).call ]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The start of the hour before a deployment and the hour after it. The
|
|
58
|
+
# after window follows the hour the deploy finished in, so a rolling
|
|
59
|
+
# deploy that ran past the top of the hour does not leak mixed-version
|
|
60
|
+
# traffic into the measurement.
|
|
61
|
+
def self.windows(deployment)
|
|
62
|
+
start_hour = RailsPulse::Summary.normalize_period_start("hour", deployment.started_at.in_time_zone)
|
|
63
|
+
finish_hour = if deployment.finished_at
|
|
64
|
+
RailsPulse::Summary.normalize_period_start("hour", deployment.finished_at.in_time_zone)
|
|
65
|
+
else
|
|
66
|
+
start_hour
|
|
67
|
+
end
|
|
68
|
+
{ before: start_hour - 1.hour, after: finish_hour + 1.hour }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(deployment, rows: nil, now: Time.current)
|
|
72
|
+
@deployment = deployment
|
|
73
|
+
@now = now
|
|
74
|
+
@windows = self.class.windows(deployment)
|
|
75
|
+
@rows = rows || RailsPulse::Summary.overall_requests
|
|
76
|
+
.where(period_type: "hour", period_start: @windows.values)
|
|
77
|
+
.index_by(&:period_start)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def call
|
|
81
|
+
before = @rows[@windows[:before]]
|
|
82
|
+
after = @rows[@windows[:after]]
|
|
83
|
+
metrics = METRICS.map { |metric| compare(metric, before, after) }
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
outcome: outcome(metrics, after),
|
|
87
|
+
before: window(@windows[:before], before),
|
|
88
|
+
after: window(@windows[:after], after),
|
|
89
|
+
metrics: metrics,
|
|
90
|
+
note: note(before, after)
|
|
91
|
+
}.compact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def compare(metric, before, after)
|
|
97
|
+
result = { metric: metric, unit: UNITS[metric], multiplier: MULTIPLIERS[metric] }
|
|
98
|
+
|
|
99
|
+
unless sufficient?(before) && sufficient?(after)
|
|
100
|
+
return result.merge(outcome: "insufficient_data", before: nil, after: nil, ratio: nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
before_value = value(metric, before)
|
|
104
|
+
after_value = value(metric, after)
|
|
105
|
+
|
|
106
|
+
result.merge(
|
|
107
|
+
outcome: degraded?(metric, before_value, after_value) ? "degraded" : "clean",
|
|
108
|
+
before: before_value.round(2),
|
|
109
|
+
after: after_value.round(2),
|
|
110
|
+
ratio: before_value.zero? ? nil : (after_value / before_value).round(3)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Both gates matter, as in Operations::Comparison#regression?: the ratio
|
|
115
|
+
# alone flags trivial noise (8ms to 13ms is 1.6x), and the floor alone
|
|
116
|
+
# flags slow endpoints that barely moved. Errors where there were none is
|
|
117
|
+
# degraded once past the floor, though it has no ratio.
|
|
118
|
+
def degraded?(metric, before_value, after_value)
|
|
119
|
+
return false unless after_value - before_value >= min_delta(metric)
|
|
120
|
+
return true if before_value.zero?
|
|
121
|
+
|
|
122
|
+
after_value / before_value > MULTIPLIERS[metric]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def min_delta(metric)
|
|
126
|
+
thresholds = RailsPulse.configuration.regression_thresholds
|
|
127
|
+
metric == :error_rate ? thresholds[:min_delta_rate].to_f : thresholds[:min_delta_ms].to_f
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def value(metric, row)
|
|
131
|
+
case metric
|
|
132
|
+
when :avg_response_time then row.avg_duration.to_f
|
|
133
|
+
when :p95_response_time then row.p95_duration.to_f
|
|
134
|
+
when :error_rate then row.error_count.to_f / row.count * 100
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sufficient?(row)
|
|
139
|
+
row.present? && row.count.to_i >= MINIMUM_REQUESTS
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A missing after-hour row is pending only while SummaryJob could still
|
|
143
|
+
# write it; past SUMMARY_GRACE it is unavailable, a distinct value so an
|
|
144
|
+
# agent polling for the comparison is not left waiting forever when the
|
|
145
|
+
# job is not running (note says which).
|
|
146
|
+
def outcome(metrics, after)
|
|
147
|
+
if after.nil? && !pruned?(@windows[:after])
|
|
148
|
+
return @now < @windows[:after] + 1.hour + SUMMARY_GRACE ? "pending" : "unavailable"
|
|
149
|
+
end
|
|
150
|
+
return "degraded" if metrics.any? { |m| m[:outcome] == "degraded" }
|
|
151
|
+
return "insufficient_data" if metrics.any? { |m| m[:outcome] == "insufficient_data" }
|
|
152
|
+
|
|
153
|
+
"clean"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def window(start, row)
|
|
157
|
+
{ from: start, to: start + 1.hour, requests: row&.count.to_i }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def note(before, after)
|
|
161
|
+
if (after.nil? && pruned?(@windows[:after])) || (before.nil? && pruned?(@windows[:before]))
|
|
162
|
+
"Hourly summaries this far back have been pruned (config.hourly_summary_retention keeps " \
|
|
163
|
+
"#{(RailsPulse.configuration.hourly_summary_retention / 1.day).round(1)} days), so this deployment can no longer be compared."
|
|
164
|
+
elsif after.nil? && @windows[:after] + 1.hour > @now
|
|
165
|
+
"The hour after the deploy ends at #{(@windows[:after] + 1.hour).iso8601}; the comparison is available once SummaryJob has summarized it."
|
|
166
|
+
elsif after.nil?
|
|
167
|
+
"The hour after the deploy has not been summarized. Check that RailsPulse::SummaryJob is scheduled hourly."
|
|
168
|
+
elsif !sufficient?(before) || !sufficient?(after)
|
|
169
|
+
"Fewer than #{MINIMUM_REQUESTS} requests in the hour before or after the deploy, too few to compare."
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pruned?(hour_start)
|
|
174
|
+
hour_start < RailsPulse.configuration.hourly_summary_retention.ago
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -55,7 +55,7 @@ module RailsPulse
|
|
|
55
55
|
|
|
56
56
|
def capture
|
|
57
57
|
return unless RailsPulse.configuration.track_exceptions
|
|
58
|
-
return if
|
|
58
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
59
59
|
|
|
60
60
|
frames = parse_backtrace(@exception.backtrace || [])
|
|
61
61
|
location = fingerprint_location(frames)
|
|
@@ -11,7 +11,7 @@ module RailsPulse
|
|
|
11
11
|
# assumption to defend.
|
|
12
12
|
#
|
|
13
13
|
# Precision is bounded by what summaries still exist. Hourly summaries are
|
|
14
|
-
# pruned at `hourly_summary_retention` (
|
|
14
|
+
# pruned at `hourly_summary_retention` (7 days by default), so a change point
|
|
15
15
|
# can only be pinned to the hour inside that window. Beyond it the answer is
|
|
16
16
|
# a day, and `granularity` says which the caller got — never silently one
|
|
17
17
|
# pretending to be the other.
|
|
@@ -3,7 +3,7 @@ module RailsPulse
|
|
|
3
3
|
# The result of measuring a recent window against a historical baseline.
|
|
4
4
|
#
|
|
5
5
|
# This is a value object on purpose. It is the shape callers outside this
|
|
6
|
-
# namespace — dashboards today, findings and
|
|
6
|
+
# namespace — dashboards today, findings and agent tooling later — are expected
|
|
7
7
|
# to depend on, so it carries every number needed to explain the verdict
|
|
8
8
|
# rather than just the verdict.
|
|
9
9
|
class Comparison
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# The routes, queries and jobs that need attention over one summary period
|
|
3
|
+
# (an hour, day, week or month), read from that period's own summary rows so
|
|
4
|
+
# every P95 is the period's real one rather than a blend of shorter periods.
|
|
5
|
+
# Classified by the same rules as the dashboard's Needs Attention panel and
|
|
6
|
+
# capped the same way: critical first, MAX_ITEMS in all.
|
|
7
|
+
#
|
|
8
|
+
# Served by GET api/v1/insights; items carry the record's id rather than a
|
|
9
|
+
# dashboard link.
|
|
10
|
+
class PeriodInsights
|
|
11
|
+
include RailsPulse::Dashboard::Concerns::AttentionClassification
|
|
12
|
+
|
|
13
|
+
MAX_ITEMS = 10
|
|
14
|
+
|
|
15
|
+
def initialize(period_type:, period_start:)
|
|
16
|
+
@period_type = period_type
|
|
17
|
+
@period_start = period_start
|
|
18
|
+
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
19
|
+
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
20
|
+
@job_thresholds = RailsPulse.configuration.job_thresholds
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_insights_data
|
|
24
|
+
items = route_items + query_items + job_items
|
|
25
|
+
critical = items.select { |i| i[:severity] == :critical }.sort_by { |i| -i[:sort_score] }
|
|
26
|
+
warning = items.select { |i| i[:severity] == :warning }.sort_by { |i| -i[:sort_score] }
|
|
27
|
+
capped = (critical + warning).first(MAX_ITEMS).map { |i| i.except(:sort_score) }
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
critical: capped.select { |i| i[:severity] == :critical },
|
|
31
|
+
warning: capped.select { |i| i[:severity] == :warning },
|
|
32
|
+
total: capped.size
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Classification can only keep a row past its slow threshold or carrying
|
|
39
|
+
# errors, so healthy rows are excluded in SQL rather than loading every
|
|
40
|
+
# summary row for the period just to drop almost all of them in Ruby.
|
|
41
|
+
def summaries(type, slow_threshold, with_errors: true)
|
|
42
|
+
scope = RailsPulse::Summary.where(summarizable_type: type, period_type: @period_type, period_start: @period_start)
|
|
43
|
+
return scope.where("rails_pulse_summaries.p95_duration >= ?", slow_threshold) unless with_errors
|
|
44
|
+
|
|
45
|
+
scope.where("rails_pulse_summaries.p95_duration >= ? OR rails_pulse_summaries.error_count > 0", slow_threshold)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def route_items
|
|
49
|
+
route_data = summaries("RailsPulse::Route", @route_thresholds[:slow])
|
|
50
|
+
.joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
|
|
51
|
+
.select(
|
|
52
|
+
"rails_pulse_summaries.summarizable_id as route_id",
|
|
53
|
+
"rails_pulse_routes.path",
|
|
54
|
+
"rails_pulse_routes.http_methods as http_methods_raw",
|
|
55
|
+
"rails_pulse_summaries.p95_duration",
|
|
56
|
+
"rails_pulse_summaries.count",
|
|
57
|
+
"rails_pulse_summaries.error_count"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
route_data.filter_map do |record|
|
|
61
|
+
p95 = record.p95_duration.to_f
|
|
62
|
+
total = record.count.to_i
|
|
63
|
+
errors = record.error_count.to_i
|
|
64
|
+
error_rate = total > 0 ? (errors * 100.0 / total).round(1) : 0.0
|
|
65
|
+
|
|
66
|
+
severity, reason, metric, metric_sub, sort_score = classify_route(p95, total, errors, error_rate, "this period")
|
|
67
|
+
next unless severity
|
|
68
|
+
|
|
69
|
+
item("route", record.route_id, "#{http_methods(record.http_methods_raw).join('|')} #{record.path}".strip,
|
|
70
|
+
severity, reason, metric, metric_sub, sort_score)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def query_items
|
|
75
|
+
query_data = summaries("RailsPulse::Query", @query_thresholds[:slow], with_errors: false)
|
|
76
|
+
.joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
|
|
77
|
+
.select(
|
|
78
|
+
"rails_pulse_summaries.summarizable_id as query_id",
|
|
79
|
+
"rails_pulse_queries.normalized_sql",
|
|
80
|
+
"rails_pulse_summaries.p95_duration",
|
|
81
|
+
"rails_pulse_summaries.count"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
query_data.filter_map do |record|
|
|
85
|
+
severity, reason, metric, metric_sub, sort_score = classify_query(record.p95_duration.to_f, record.count.to_i)
|
|
86
|
+
next unless severity
|
|
87
|
+
|
|
88
|
+
item("query", record.query_id, truncate_sql(record.normalized_sql), severity, reason, metric, metric_sub, sort_score)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def job_items
|
|
93
|
+
return [] unless RailsPulse.configuration.track_jobs
|
|
94
|
+
|
|
95
|
+
job_data = summaries("RailsPulse::Job", @job_thresholds[:slow])
|
|
96
|
+
.joins("INNER JOIN rails_pulse_jobs ON rails_pulse_jobs.id = rails_pulse_summaries.summarizable_id")
|
|
97
|
+
.select(
|
|
98
|
+
"rails_pulse_summaries.summarizable_id as job_id",
|
|
99
|
+
"rails_pulse_jobs.name as job_name",
|
|
100
|
+
"rails_pulse_jobs.queue_name",
|
|
101
|
+
"rails_pulse_summaries.p95_duration",
|
|
102
|
+
"rails_pulse_summaries.count",
|
|
103
|
+
"rails_pulse_summaries.error_count"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
job_data.filter_map do |record|
|
|
107
|
+
runs = record.count.to_i
|
|
108
|
+
failures = record.error_count.to_i
|
|
109
|
+
failure_rate = runs > 0 ? (failures * 100.0 / runs).round(1) : 0.0
|
|
110
|
+
|
|
111
|
+
severity, reason, metric, metric_sub, sort_score = classify_job(
|
|
112
|
+
failure_rate, record.p95_duration.to_f, runs, failures, record.queue_name.presence || "default"
|
|
113
|
+
)
|
|
114
|
+
next unless severity
|
|
115
|
+
|
|
116
|
+
item("job", record.job_id, record.job_name, severity, reason, metric, metric_sub, sort_score)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def item(type, id, name, severity, reason, metric, metric_sub, sort_score)
|
|
121
|
+
{ type: type, id: id, name: name, severity: severity, reason: reason,
|
|
122
|
+
metric: metric, metric_sub: metric_sub, sort_score: sort_score }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def http_methods(raw)
|
|
126
|
+
Array(JSON.parse(raw.to_s))
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|