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
|
@@ -1,8 +1,81 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class Configuration
|
|
3
|
+
# Settings for sending summaries to Rails Pulse Cloud, read as
|
|
4
|
+
# `config.cloud.api_key` and so on. Sending is off unless both api_key
|
|
5
|
+
# and application are set.
|
|
6
|
+
class CloudSettings
|
|
7
|
+
DEFAULT_URL = "https://ingest.railspulse.com".freeze
|
|
8
|
+
# The prefix every Cloud key carries, so a leaked one is recognisable
|
|
9
|
+
# and api_token cannot be pasted here by mistake.
|
|
10
|
+
API_KEY_PREFIX = "rpc_".freeze
|
|
11
|
+
APPLICATION_FORMAT = /\A[a-z0-9][a-z0-9-]{0,62}\z/
|
|
12
|
+
MAX_ENVIRONMENT_LENGTH = 64
|
|
13
|
+
MAX_HOST_LABEL_LENGTH = 255
|
|
14
|
+
|
|
15
|
+
attr_accessor :api_key, :url, :application, :environment, :host_label
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@api_key = nil
|
|
19
|
+
@url = DEFAULT_URL
|
|
20
|
+
@application = nil
|
|
21
|
+
@environment = Rails.env.to_s
|
|
22
|
+
@host_label = ENV["RAILS_PULSE_HOST_LABEL"].presence
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled?
|
|
26
|
+
api_key.present? && application.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{ api_key: api_key, url: url, application: application, environment: environment, host_label: host_label }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ==(other)
|
|
34
|
+
other.is_a?(CloudSettings) && to_h == other.to_h
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The key is left out so it does not end up in a log or console.
|
|
38
|
+
def inspect
|
|
39
|
+
"#<#{self.class.name} #{to_h.merge(api_key: api_key.present? ? "[FILTERED]" : nil).inspect}>"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate!
|
|
43
|
+
if !api_key.nil? && !(api_key.is_a?(String) && api_key.start_with?(API_KEY_PREFIX) && api_key.length > API_KEY_PREFIX.length)
|
|
44
|
+
raise ArgumentError, "cloud.api_key must be a Rails Pulse Cloud key starting with #{API_KEY_PREFIX}, or nil"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless http_url?(url)
|
|
48
|
+
raise ArgumentError, "cloud.url must be an http or https URL, got #{url.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if !application.nil? && !(application.is_a?(String) && application.match?(APPLICATION_FORMAT))
|
|
52
|
+
raise ArgumentError, "cloud.application must be the Application's slug in Rails Pulse Cloud " \
|
|
53
|
+
"(lowercase letters, digits and dashes), got #{application.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless environment.is_a?(String) && environment.present? && environment.length <= MAX_ENVIRONMENT_LENGTH
|
|
57
|
+
raise ArgumentError, "cloud.environment must be a name of 1 to #{MAX_ENVIRONMENT_LENGTH} characters, got #{environment.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if !host_label.nil? && !(host_label.is_a?(String) && host_label.present? && host_label.length <= MAX_HOST_LABEL_LENGTH)
|
|
61
|
+
raise ArgumentError, "cloud.host_label must be a name of 1 to #{MAX_HOST_LABEL_LENGTH} characters, or nil"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def http_url?(value)
|
|
68
|
+
uri = URI.parse(value.to_s)
|
|
69
|
+
uri.is_a?(URI::HTTP) && uri.host.present?
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
3
75
|
attr_writer :ignored_routes
|
|
4
76
|
attr_accessor :enabled,
|
|
5
|
-
:
|
|
77
|
+
:api_token,
|
|
78
|
+
:deployment_token,
|
|
6
79
|
:ignored_requests,
|
|
7
80
|
:ignored_queries,
|
|
8
81
|
:ignored_jobs,
|
|
@@ -16,6 +89,8 @@ module RailsPulse
|
|
|
16
89
|
:custom_asset_patterns,
|
|
17
90
|
:mount_path,
|
|
18
91
|
:full_retention_period,
|
|
92
|
+
:event_retention_period,
|
|
93
|
+
:event_retention_exempt_kinds,
|
|
19
94
|
:archiving_enabled,
|
|
20
95
|
:connects_to,
|
|
21
96
|
:authentication_enabled,
|
|
@@ -39,6 +114,15 @@ module RailsPulse
|
|
|
39
114
|
:comparison_window,
|
|
40
115
|
:hourly_summary_retention
|
|
41
116
|
|
|
117
|
+
# Two credentials, so a token handed to a coding agent cannot write.
|
|
118
|
+
# api_token reads the JSON API; deployment_token records deployment
|
|
119
|
+
# markers. Setting both to the same value restores single-token behaviour.
|
|
120
|
+
#
|
|
121
|
+
# deployment_api_token is the pre-0.5 name for the deployment credential
|
|
122
|
+
# and still writes deployments, which is all it ever authorized.
|
|
123
|
+
alias_method :deployment_api_token, :deployment_token
|
|
124
|
+
alias_method :deployment_api_token=, :deployment_token=
|
|
125
|
+
|
|
42
126
|
# Override the attr_accessor setter to track explicit assignment.
|
|
43
127
|
# The default (enabled in production) should not trigger a warning
|
|
44
128
|
# when no authentication_method is configured — only an explicit
|
|
@@ -49,6 +133,8 @@ module RailsPulse
|
|
|
49
133
|
end
|
|
50
134
|
|
|
51
135
|
# Read-only access to thresholds (use setters for validation)
|
|
136
|
+
attr_reader :cloud
|
|
137
|
+
|
|
52
138
|
attr_reader :route_thresholds,
|
|
53
139
|
:request_thresholds,
|
|
54
140
|
:query_thresholds,
|
|
@@ -59,6 +145,7 @@ module RailsPulse
|
|
|
59
145
|
|
|
60
146
|
def initialize
|
|
61
147
|
@enabled = true
|
|
148
|
+
@cloud = CloudSettings.new
|
|
62
149
|
@route_thresholds = { slow: 500, very_slow: 1500, critical: 3000 }
|
|
63
150
|
@request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 }
|
|
64
151
|
@query_thresholds = { slow: 100, very_slow: 500, critical: 1000 }
|
|
@@ -88,6 +175,8 @@ module RailsPulse
|
|
|
88
175
|
@custom_asset_patterns = []
|
|
89
176
|
@mount_path = nil
|
|
90
177
|
@full_retention_period = 30.days
|
|
178
|
+
@event_retention_period = 90.days
|
|
179
|
+
@event_retention_exempt_kinds = []
|
|
91
180
|
@archiving_enabled = true
|
|
92
181
|
@max_table_records = {
|
|
93
182
|
rails_pulse_operations: 100_000,
|
|
@@ -154,7 +243,7 @@ module RailsPulse
|
|
|
154
243
|
# pruned aggressively because the 1-day UI view is all that reads them, so
|
|
155
244
|
# raising this is the knob that buys hour-accurate change points further
|
|
156
245
|
# back, at the cost of summary-table growth.
|
|
157
|
-
@hourly_summary_retention =
|
|
246
|
+
@hourly_summary_retention = 7.days
|
|
158
247
|
|
|
159
248
|
# Deterministic regression rules. A change must clear both the ratio and
|
|
160
249
|
# the absolute floor: without the floor, 2ms to 4ms reports as a 100%
|
|
@@ -233,6 +322,7 @@ module RailsPulse
|
|
|
233
322
|
validate_query_service_level_objectives_settings!
|
|
234
323
|
validate_comparison_settings!
|
|
235
324
|
validate_schema_check_settings!
|
|
325
|
+
@cloud.validate!
|
|
236
326
|
end
|
|
237
327
|
|
|
238
328
|
private
|
|
@@ -262,6 +352,14 @@ module RailsPulse
|
|
|
262
352
|
raise ArgumentError, "full_retention_period must be a time duration (e.g., 2.weeks), got #{@full_retention_period}"
|
|
263
353
|
end
|
|
264
354
|
|
|
355
|
+
unless @event_retention_period.is_a?(ActiveSupport::Duration)
|
|
356
|
+
raise ArgumentError, "event_retention_period must be a time duration (e.g., 90.days), got #{@event_retention_period.inspect}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
unless @event_retention_exempt_kinds.is_a?(Array) && @event_retention_exempt_kinds.all?(String)
|
|
360
|
+
raise ArgumentError, "event_retention_exempt_kinds must be an array of strings, got #{@event_retention_exempt_kinds.inspect}"
|
|
361
|
+
end
|
|
362
|
+
|
|
265
363
|
@max_table_records.each do |table, count|
|
|
266
364
|
unless count.is_a?(Integer) && count > 0
|
|
267
365
|
raise ArgumentError, "max_table_records[#{table}] must be a positive integer, got #{count}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Per-request/per-job state threaded from collection (middleware, job
|
|
3
|
+
# wrapper, subscribers) through to the writer. Thread-local; Rails' executor
|
|
4
|
+
# resets it automatically around requests and jobs, but RequestCollector and
|
|
5
|
+
# JobRunCollector also reset it explicitly so behaviour does not depend on
|
|
6
|
+
# the executor being present (rack-only apps, `perform_now` in a console).
|
|
7
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
8
|
+
attribute :rails_pulse_request_id, :rails_pulse_job_run_id, :rails_pulse_operations,
|
|
9
|
+
:skip_recording_rails_pulse_activity, :rails_pulse_captured_exception
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/rails_pulse/engine.rb
CHANGED
|
@@ -7,7 +7,8 @@ require "rails_pulse/subscribers/operation_subscriber"
|
|
|
7
7
|
require "rails_pulse/subscribers/exception_subscriber"
|
|
8
8
|
require "rails_pulse/job_run_collector"
|
|
9
9
|
require "rails_pulse/active_job_extensions"
|
|
10
|
-
require "
|
|
10
|
+
require "rails_pulse/current"
|
|
11
|
+
require "rails_pulse/scoped_inflector"
|
|
11
12
|
require "rack/static"
|
|
12
13
|
require "ransack"
|
|
13
14
|
|
|
@@ -38,15 +39,23 @@ module RailsPulse
|
|
|
38
39
|
# would expect sql_query_normalizer.rb to define SQLQueryNormalizer and
|
|
39
40
|
# fail to eager load in production. Pin the names of the files whose
|
|
40
41
|
# basenames contain a common acronym so the gem's constants do not depend
|
|
41
|
-
# on the host's inflections.
|
|
42
|
+
# on the host's inflections. Applied only to files under this engine's
|
|
43
|
+
# root (see ScopedInflector), so a host's own app/controllers/api/ keeps
|
|
44
|
+
# whatever its inflections say.
|
|
42
45
|
ACRONYM_SAFE_INFLECTIONS = {
|
|
46
|
+
"api" => "Api",
|
|
43
47
|
"sql_query_normalizer" => "SqlQueryNormalizer",
|
|
44
48
|
"csp_helper" => "CspHelper",
|
|
45
49
|
"csp_test_controller" => "CspTestController"
|
|
46
50
|
}.freeze
|
|
47
51
|
|
|
48
52
|
initializer "rails_pulse.inflections", before: :set_autoload_paths do
|
|
49
|
-
|
|
53
|
+
if Rails.respond_to?(:autoloaders)
|
|
54
|
+
loader = Rails.autoloaders.main
|
|
55
|
+
loader.inflector = RailsPulse::ScopedInflector.new(
|
|
56
|
+
loader.inflector, root: RailsPulse::Engine.root.join("app"), overrides: ACRONYM_SAFE_INFLECTIONS
|
|
57
|
+
)
|
|
58
|
+
end
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
# Load Rake tasks
|
|
@@ -5,7 +5,9 @@ module RailsPulse
|
|
|
5
5
|
# the review: keep or discard hunks. Values that differ between a new
|
|
6
6
|
# install and an upgrade (today: track_exceptions) use the gem default.
|
|
7
7
|
class ConfigUpdater
|
|
8
|
-
|
|
8
|
+
# Nested settings (`config.cloud.api_key`) are keyed by their full
|
|
9
|
+
# dotted name.
|
|
10
|
+
ASSIGNMENT = /\A\s*(#\s*)?config\.(\w+(?:\.\w+)*)\s*=/.freeze
|
|
9
11
|
HASH_ENTRY = /\A\s*([a-z_][a-z0-9_]*):/.freeze
|
|
10
12
|
|
|
11
13
|
UPGRADE_VALUE_OVERRIDES = {
|
|
@@ -128,8 +130,13 @@ module RailsPulse
|
|
|
128
130
|
code.count("{") - code.count("}") + code.count("[") - code.count("]")
|
|
129
131
|
end
|
|
130
132
|
|
|
133
|
+
# Every key the content names, and each prefix of a dotted one, so
|
|
134
|
+
# `config.cloud.api_key` mentions both `cloud.api_key` and `cloud`.
|
|
131
135
|
def mentioned_keys(content)
|
|
132
|
-
content.scan(/config\.(\w+)/).flatten.
|
|
136
|
+
content.scan(/config\.(\w+(?:\.\w+)*)/).flatten.flat_map do |key|
|
|
137
|
+
parts = key.split(".")
|
|
138
|
+
(1..parts.size).map { |length| parts.first(length).join(".") }
|
|
139
|
+
end.uniq
|
|
133
140
|
end
|
|
134
141
|
|
|
135
142
|
def missing_hash_entries(host, missing_keys)
|
|
@@ -7,9 +7,9 @@ module RailsPulse
|
|
|
7
7
|
return yield unless tracking_enabled?
|
|
8
8
|
return yield if ignore_job?(active_job)
|
|
9
9
|
|
|
10
|
-
previous_request_id =
|
|
11
|
-
previous_operations =
|
|
12
|
-
previous_job_run_id =
|
|
10
|
+
previous_request_id = RailsPulse::Current.rails_pulse_request_id
|
|
11
|
+
previous_operations = RailsPulse::Current.rails_pulse_operations
|
|
12
|
+
previous_job_run_id = RailsPulse::Current.rails_pulse_job_run_id
|
|
13
13
|
|
|
14
14
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
15
|
occurred_at = Time.current
|
|
@@ -28,9 +28,9 @@ module RailsPulse
|
|
|
28
28
|
job_run = nil
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
RailsPulse::Current.rails_pulse_request_id = nil
|
|
32
|
+
RailsPulse::Current.rails_pulse_job_run_id = job_run&.id
|
|
33
|
+
RailsPulse::Current.rails_pulse_operations = []
|
|
34
34
|
|
|
35
35
|
yield
|
|
36
36
|
|
|
@@ -65,7 +65,7 @@ module RailsPulse
|
|
|
65
65
|
# checks skip_recording_rails_pulse_activity and would bail out.
|
|
66
66
|
# The service's own SQL is already filtered by the rails_pulse_ prefix.
|
|
67
67
|
RailsPulse::ExceptionCaptureService.capture(error, environment: Rails.env.to_s)
|
|
68
|
-
|
|
68
|
+
RailsPulse::Current.rails_pulse_captured_exception = error
|
|
69
69
|
rescue => e
|
|
70
70
|
RailsPulse.logger.error "Failed to record job failure: #{e.class} - #{e.message}"
|
|
71
71
|
end
|
|
@@ -76,9 +76,9 @@ module RailsPulse
|
|
|
76
76
|
rescue => e
|
|
77
77
|
RailsPulse.logger.error "Failed to persist job operations: #{e.class} - #{e.message}"
|
|
78
78
|
ensure
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
RailsPulse::Current.rails_pulse_job_run_id = previous_job_run_id
|
|
80
|
+
RailsPulse::Current.rails_pulse_operations = previous_operations
|
|
81
|
+
RailsPulse::Current.rails_pulse_request_id = previous_request_id
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
@@ -155,14 +155,14 @@ module RailsPulse
|
|
|
155
155
|
def save_operations(job_run)
|
|
156
156
|
return unless job_run
|
|
157
157
|
|
|
158
|
-
ops =
|
|
158
|
+
ops = RailsPulse::Current.rails_pulse_operations || []
|
|
159
159
|
with_recording_suppressed do
|
|
160
160
|
RailsPulse::Operation.persist_bulk(ops, job_run_id: job_run.id, request_id: nil)
|
|
161
161
|
end
|
|
162
162
|
rescue => e
|
|
163
163
|
RailsPulse.logger.error "Failed to save job operations: #{e.class} - #{e.message}"
|
|
164
164
|
ensure
|
|
165
|
-
|
|
165
|
+
RailsPulse::Current.rails_pulse_operations = nil
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def detect_adapter
|
|
@@ -198,11 +198,11 @@ module RailsPulse
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def with_recording_suppressed
|
|
201
|
-
previous =
|
|
202
|
-
|
|
201
|
+
previous = RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
202
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = true
|
|
203
203
|
yield
|
|
204
204
|
ensure
|
|
205
|
-
|
|
205
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = previous
|
|
206
206
|
end
|
|
207
207
|
end
|
|
208
208
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Mcp
|
|
3
|
+
class Server
|
|
4
|
+
# The `mcp` gem is not a dependency of rails_pulse; the host adds it
|
|
5
|
+
# when it wants the MCP server.
|
|
6
|
+
class MissingDependencyError < StandardError; end
|
|
7
|
+
|
|
8
|
+
MISSING_DEPENDENCY_MESSAGE = "the `mcp` gem is not installed. Add `gem \"mcp\", \"~> 1.0\"` to the " \
|
|
9
|
+
"application's Gemfile (a development group is enough) and run bundle install, " \
|
|
10
|
+
"or `gem install mcp` when running rails-pulse outside Bundler.".freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
require "mcp"
|
|
17
|
+
rescue LoadError
|
|
18
|
+
raise RailsPulse::Mcp::Server::MissingDependencyError, RailsPulse::Mcp::Server::MISSING_DEPENDENCY_MESSAGE
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require "rails_pulse/version"
|
|
22
|
+
require_relative "../cli/config"
|
|
23
|
+
require_relative "../cli/client"
|
|
24
|
+
require_relative "tools/helpers"
|
|
25
|
+
require_relative "tools/slow_requests"
|
|
26
|
+
require_relative "tools/errors"
|
|
27
|
+
require_relative "tools/endpoint"
|
|
28
|
+
require_relative "tools/queries"
|
|
29
|
+
require_relative "tools/jobs"
|
|
30
|
+
require_relative "tools/exceptions"
|
|
31
|
+
require_relative "tools/exception_detail"
|
|
32
|
+
require_relative "tools/routes"
|
|
33
|
+
require_relative "tools/deployments"
|
|
34
|
+
require_relative "tools/coverage"
|
|
35
|
+
require_relative "tools/insights"
|
|
36
|
+
|
|
37
|
+
module RailsPulse
|
|
38
|
+
module Mcp
|
|
39
|
+
# Read-only MCP server over stdio. Every tool goes through the JSON API
|
|
40
|
+
# with the same token as the CLI; nothing here touches the database or
|
|
41
|
+
# the application directly.
|
|
42
|
+
class Server
|
|
43
|
+
def self.start!
|
|
44
|
+
server = build_server
|
|
45
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
46
|
+
transport.open
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.build_server(client: nil)
|
|
50
|
+
config = CLI::Config.load
|
|
51
|
+
client ||= CLI::Client.new(config)
|
|
52
|
+
|
|
53
|
+
::MCP::Server.new(
|
|
54
|
+
name: "rails_pulse",
|
|
55
|
+
version: RailsPulse::VERSION,
|
|
56
|
+
tools: tools,
|
|
57
|
+
server_context: { client: client }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.tools
|
|
62
|
+
[
|
|
63
|
+
Tools::Routes,
|
|
64
|
+
Tools::SlowRequests,
|
|
65
|
+
Tools::Errors,
|
|
66
|
+
Tools::Endpoint,
|
|
67
|
+
Tools::Queries,
|
|
68
|
+
Tools::Jobs,
|
|
69
|
+
Tools::Exceptions,
|
|
70
|
+
Tools::ExceptionDetail,
|
|
71
|
+
Tools::Deployments,
|
|
72
|
+
Tools::Coverage,
|
|
73
|
+
Tools::Insights
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class Coverage < ::MCP::Tool
|
|
5
|
+
extend Helpers
|
|
6
|
+
|
|
7
|
+
tool_name "rails_pulse_coverage"
|
|
8
|
+
description "Orientation for this installation: which application and environment it is, what has " \
|
|
9
|
+
"been recorded and how recently, what retention keeps, and whether any requests were " \
|
|
10
|
+
"dropped. Call this first in a session, and before reporting that nothing went wrong, " \
|
|
11
|
+
"to tell 'no failures recorded' apart from 'no data captured'."
|
|
12
|
+
|
|
13
|
+
annotations(
|
|
14
|
+
read_only_hint: true,
|
|
15
|
+
destructive_hint: false,
|
|
16
|
+
open_world_hint: false
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
input_schema(properties: {})
|
|
20
|
+
|
|
21
|
+
def self.call(server_context:, **_options)
|
|
22
|
+
respond(server_context) do |client|
|
|
23
|
+
data = client.get("/coverage", {})
|
|
24
|
+
capabilities = client.get("/capabilities", {})
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
as_of: data["as_of"],
|
|
28
|
+
installation: installation(capabilities),
|
|
29
|
+
telemetry: data["telemetry"],
|
|
30
|
+
summaries: data["summaries"],
|
|
31
|
+
retention: data["retention"],
|
|
32
|
+
collection: data["collection"],
|
|
33
|
+
summary: build_summary(data),
|
|
34
|
+
next_steps: build_next_steps(data)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Which application and environment answered, so results gathered
|
|
40
|
+
# against staging are not read as production.
|
|
41
|
+
private_class_method def self.installation(capabilities)
|
|
42
|
+
{
|
|
43
|
+
application: capabilities["application"],
|
|
44
|
+
environment: capabilities["environment"],
|
|
45
|
+
rails_pulse_version: capabilities["rails_pulse_version"]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private_class_method def self.build_summary(data)
|
|
50
|
+
requests = data.dig("telemetry", "requests") || {}
|
|
51
|
+
parts = []
|
|
52
|
+
|
|
53
|
+
parts << if requests["tracked"] == false
|
|
54
|
+
"Requests are not being recorded."
|
|
55
|
+
elsif requests["newest"]
|
|
56
|
+
"Requests recorded #{requests['oldest']} to #{requests['newest']} (#{requests['count']} rows)."
|
|
57
|
+
else
|
|
58
|
+
"No requests have been recorded."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
collection = data["collection"] || {}
|
|
62
|
+
parts << "Collection gap suspected." if collection["gap_suspected"]
|
|
63
|
+
parts << "Summaries are behind." if data.dig("summaries", "stale")
|
|
64
|
+
parts.join(" ")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An agent that reads "no errors" without these caveats will report an
|
|
68
|
+
# all-clear the data does not support.
|
|
69
|
+
private_class_method def self.build_next_steps(data)
|
|
70
|
+
steps = []
|
|
71
|
+
collection = data["collection"] || {}
|
|
72
|
+
summaries = data["summaries"] || {}
|
|
73
|
+
|
|
74
|
+
steps << collection["note"] if collection["note"]
|
|
75
|
+
steps << summaries["note"] if summaries["note"]
|
|
76
|
+
|
|
77
|
+
if (oldest = data.dig("telemetry", "requests", "oldest"))
|
|
78
|
+
steps << "Questions about anything before #{oldest} cannot be answered from raw requests; " \
|
|
79
|
+
"retention has removed them. Summaries may still cover the window."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
(data["telemetry"] || {}).each do |kind, info|
|
|
83
|
+
next unless info.is_a?(Hash) && info["tracked"] == false
|
|
84
|
+
|
|
85
|
+
label = kind.tr("_", " ").capitalize
|
|
86
|
+
steps << "#{label} are not being recorded (#{info['reason']}), so an empty #{kind.tr('_', ' ')} result " \
|
|
87
|
+
"says nothing about whether any occurred."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
steps << "Treat an empty result from another tool as 'not recorded' rather than 'did not happen' whenever a caveat above applies."
|
|
91
|
+
steps
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class Deployments < ::MCP::Tool
|
|
5
|
+
extend Helpers
|
|
6
|
+
|
|
7
|
+
tool_name "rails_pulse_deployments"
|
|
8
|
+
description "Recent deployments with revision, start and finish time, metadata, and whether each one made " \
|
|
9
|
+
"the application slower or more error-prone. Each deployment's `comparison` sets the hour " \
|
|
10
|
+
"before it started against the hour after it finished: `degraded` when average or p95 " \
|
|
11
|
+
"response time is more than 1.5x and at least 50ms worse, or the error rate more than 1.25x " \
|
|
12
|
+
"and at least a percentage point worse, `insufficient_data` under 10 requests in either hour, " \
|
|
13
|
+
"`pending` until the hour after has been summarized, `unavailable` when it never will be " \
|
|
14
|
+
"because SummaryJob is not running. Use it to pin an investigation to a release."
|
|
15
|
+
|
|
16
|
+
annotations(
|
|
17
|
+
read_only_hint: true,
|
|
18
|
+
destructive_hint: false,
|
|
19
|
+
open_world_hint: false
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
input_schema(
|
|
23
|
+
properties: {
|
|
24
|
+
**Helpers.window_properties("last_7_days"),
|
|
25
|
+
limit: {
|
|
26
|
+
type: "integer",
|
|
27
|
+
description: "Maximum number of deployments (1-100)",
|
|
28
|
+
default: 10
|
|
29
|
+
},
|
|
30
|
+
revision: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "A full revision or the start of one, e.g. 'a1b2c3d'. Searched inside the window, " \
|
|
33
|
+
"so widen period or pass since for an older deployment."
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def self.call(period: "last_7_days", limit: 10, revision: nil, server_context:, **options)
|
|
39
|
+
respond(server_context) do |client|
|
|
40
|
+
window = resolve_window(period: period, since: options[:since], until_time: options[:until])
|
|
41
|
+
limit = limit.to_i.clamp(1, 100)
|
|
42
|
+
params = window_params(window).merge(limit: limit)
|
|
43
|
+
params[:revision] = revision if revision
|
|
44
|
+
result = client.get("/deployments", params)
|
|
45
|
+
deployments = (result["data"] || []).map { |d| format_deployment(d) }
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
window: window,
|
|
49
|
+
total_deployments: result.dig("meta", "total") || deployments.size,
|
|
50
|
+
deployments: deployments,
|
|
51
|
+
summary: build_summary(deployments),
|
|
52
|
+
next_steps: build_next_steps(deployments)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method def self.format_deployment(deployment)
|
|
58
|
+
{
|
|
59
|
+
revision: deployment["revision"],
|
|
60
|
+
short_revision: deployment["short_revision"],
|
|
61
|
+
started_at: deployment["started_at"],
|
|
62
|
+
finished_at: deployment["finished_at"],
|
|
63
|
+
duration_seconds: deployment["duration_seconds"],
|
|
64
|
+
in_progress: deployment["in_progress"] == true,
|
|
65
|
+
metadata: deployment["metadata"],
|
|
66
|
+
comparison: deployment["comparison"]
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method def self.build_summary(deployments)
|
|
71
|
+
return "No deployments recorded in this period." if deployments.empty?
|
|
72
|
+
|
|
73
|
+
latest = deployments.first
|
|
74
|
+
state = latest[:in_progress] ? " (in progress)" : ""
|
|
75
|
+
summary = "#{deployments.size} deployment(s). Latest: #{latest[:short_revision]} at #{latest[:started_at]}#{state}."
|
|
76
|
+
|
|
77
|
+
degraded = deployments.select { |d| outcome(d) == "degraded" }
|
|
78
|
+
return summary if degraded.empty?
|
|
79
|
+
|
|
80
|
+
"#{summary} Degraded: #{degraded.map { |d| "#{d[:short_revision]} (#{degraded_metrics(d).join(', ')})" }.join('; ')}."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method def self.build_next_steps(deployments)
|
|
84
|
+
if deployments.empty?
|
|
85
|
+
return [ "Record deployments via `rails_pulse:record_deployment` or POST /deployments so an investigation can be pinned to a release." ]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The hour after a pending or unavailable deployment has not been
|
|
89
|
+
# summarized, so point at one that can be compared when there is one.
|
|
90
|
+
target = deployments.find { |d| outcome(d) == "degraded" } ||
|
|
91
|
+
deployments.find { |d| d[:comparison] && !%w[pending unavailable].include?(outcome(d)) } ||
|
|
92
|
+
deployments.first
|
|
93
|
+
before = target.dig(:comparison, "before")
|
|
94
|
+
after = target.dig(:comparison, "after")
|
|
95
|
+
unless before && after && !%w[pending unavailable].include?(outcome(target))
|
|
96
|
+
return [ "Call rails_pulse_slow_requests and rails_pulse_errors with since: \"#{target[:started_at]}\" " \
|
|
97
|
+
"and compare against the same length of time before it." ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[
|
|
101
|
+
"To see what #{target[:short_revision]} changed, call rails_pulse_slow_requests and rails_pulse_errors " \
|
|
102
|
+
"with since: \"#{after['from']}\", until: \"#{after['to']}\", then with since: \"#{before['from']}\", " \
|
|
103
|
+
"until: \"#{before['to']}\", and compare the two."
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private_class_method def self.outcome(deployment)
|
|
108
|
+
deployment.dig(:comparison, "outcome")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private_class_method def self.degraded_metrics(deployment)
|
|
112
|
+
(deployment.dig(:comparison, "metrics") || []).select { |m| m["outcome"] == "degraded" }.map { |m| m["metric"] }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|