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,111 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# The path a recognised route is sent to Cloud under: its pattern as
|
|
4
|
+
# written in the host's routes, never a value from a request.
|
|
5
|
+
#
|
|
6
|
+
# The stored path is the request path with each parameter value swapped
|
|
7
|
+
# back for its name, which leaves a value in place when two parameters
|
|
8
|
+
# share it (`/users/5/posts/5`) or when a glob captures several segments
|
|
9
|
+
# (`get "*slug"`). So the stored path is matched against the router's
|
|
10
|
+
# patterns for the same controller action, and the pattern is sent.
|
|
11
|
+
# When no pattern lines up (a route since removed, or one inside a
|
|
12
|
+
# mounted engine), each literal segment that does not look like code is
|
|
13
|
+
# replaced with `*`; a segment without a letter is taken for an ID.
|
|
14
|
+
#
|
|
15
|
+
# patterns = RoutePattern.new
|
|
16
|
+
# patterns.path_for("/users/5/posts/5", "posts#show") # => "/users/:user_id/posts/:id"
|
|
17
|
+
# patterns.path_for("/docs/getting-started", "pages#show") # => "/*slug" for `get "*slug"`
|
|
18
|
+
class RoutePattern
|
|
19
|
+
PARAMETER = /\A:[A-Za-z_]\w*(?:\.[A-Za-z0-9]+)?\z/
|
|
20
|
+
# More optional groups than this in one route are not expanded.
|
|
21
|
+
MAX_VARIANTS = 16
|
|
22
|
+
|
|
23
|
+
def initialize(routes = Rails.application.routes.routes)
|
|
24
|
+
@routes = routes
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def path_for(path, controller_action)
|
|
28
|
+
stored = segments(path.to_s)
|
|
29
|
+
patterns_for(controller_action).each do |pattern|
|
|
30
|
+
matched = match(pattern, stored)
|
|
31
|
+
return matched if matched
|
|
32
|
+
end
|
|
33
|
+
fallback(stored)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# { "posts#show" => [["", "posts", ":id"], ...] }, built once per instance.
|
|
39
|
+
def patterns_for(controller_action)
|
|
40
|
+
@patterns ||= @routes.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |route, index|
|
|
41
|
+
controller = route.defaults[:controller]
|
|
42
|
+
action = route.defaults[:action]
|
|
43
|
+
next unless controller && action
|
|
44
|
+
|
|
45
|
+
variants(route.path.spec.to_s).each { |spec| index["#{controller}##{action}"] << segments(spec) }
|
|
46
|
+
end
|
|
47
|
+
@patterns.fetch(controller_action.to_s, [])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# "/posts(/:page)(.:format)" => ["/posts/:page", "/posts"], with any
|
|
51
|
+
# `.:format` dropped because the stored path keeps the extension on its
|
|
52
|
+
# last segment rather than as a separate one.
|
|
53
|
+
def variants(spec)
|
|
54
|
+
spec = spec.gsub("(.:format)", "")
|
|
55
|
+
expanded = [ spec ]
|
|
56
|
+
while (template = expanded.find { |candidate| candidate.include?("(") })
|
|
57
|
+
break if expanded.size > MAX_VARIANTS
|
|
58
|
+
|
|
59
|
+
expanded.delete(template)
|
|
60
|
+
expanded.push(*optional_group(template))
|
|
61
|
+
end
|
|
62
|
+
expanded.reject { |candidate| candidate.include?("(") }.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The template with its innermost optional group kept and removed.
|
|
66
|
+
def optional_group(template)
|
|
67
|
+
start = template.rindex("(")
|
|
68
|
+
finish = template.index(")", start)
|
|
69
|
+
return [ template.delete("()") ] unless finish
|
|
70
|
+
|
|
71
|
+
before = template[0...start]
|
|
72
|
+
after = template[(finish + 1)..]
|
|
73
|
+
[ before + template[(start + 1)...finish] + after, before + after ]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def segments(path)
|
|
77
|
+
path.split("/", -1)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def match(pattern, stored)
|
|
81
|
+
result = []
|
|
82
|
+
pattern.each_with_index do |expected, index|
|
|
83
|
+
if expected.start_with?("*")
|
|
84
|
+
return nil if index >= stored.size
|
|
85
|
+
|
|
86
|
+
result << expected
|
|
87
|
+
return result.join("/")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
actual = stored[index]
|
|
91
|
+
return nil if actual.nil?
|
|
92
|
+
return nil unless expected == actual || expected.include?(":")
|
|
93
|
+
|
|
94
|
+
result << expected
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
stored.size == pattern.size ? result.join("/") : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fallback(stored)
|
|
101
|
+
stored.map do |segment|
|
|
102
|
+
next segment if segment.empty?
|
|
103
|
+
next segment if segment.match?(PARAMETER)
|
|
104
|
+
next segment if PathPrefix.safe_segment?(segment) && segment.match?(/[A-Za-z]/)
|
|
105
|
+
|
|
106
|
+
"*"
|
|
107
|
+
end.join("/")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# The `summary` items for one hour, built from that hour's
|
|
4
|
+
# rails_pulse_summaries rows. Nothing is returned for an hour SummaryJob
|
|
5
|
+
# has not written, so a partial hour is never sent.
|
|
6
|
+
#
|
|
7
|
+
# Each item names its subject by content (a route's pattern, a query's
|
|
8
|
+
# hash, a job's class), never by a local id, and carries no field the
|
|
9
|
+
# sync contract does not list. Route and query tags are not sent: they
|
|
10
|
+
# are free-form and can name customers.
|
|
11
|
+
class SummaryItems
|
|
12
|
+
ROUTE_LIMIT = 2_000
|
|
13
|
+
QUERY_LIMIT = 2_000
|
|
14
|
+
UNMATCHED_LIMIT = 20
|
|
15
|
+
CALL_SITES = 2
|
|
16
|
+
|
|
17
|
+
# The key of the item that combines everything past a limit.
|
|
18
|
+
OTHER = "*".freeze
|
|
19
|
+
|
|
20
|
+
DURATIONS = %i[
|
|
21
|
+
avg_duration min_duration max_duration p50_duration p95_duration p99_duration stddev_duration total_duration
|
|
22
|
+
].freeze
|
|
23
|
+
REQUEST_COUNTS = %i[error_count status_2xx status_3xx status_4xx status_5xx].freeze
|
|
24
|
+
JOB_COUNTS = %i[error_count success_count].freeze
|
|
25
|
+
|
|
26
|
+
# @param period_start [Time] the start of an hour, in the application's zone
|
|
27
|
+
# @param adapter [String] the Rails Pulse database adapter, which decides
|
|
28
|
+
# how a double-quoted SQL token is read
|
|
29
|
+
def initialize(period_start, adapter:, route_patterns: RoutePattern.new, limits: {})
|
|
30
|
+
@period_start = period_start
|
|
31
|
+
@adapter = adapter
|
|
32
|
+
@route_patterns = route_patterns
|
|
33
|
+
@route_limit = limits.fetch(:route, ROUTE_LIMIT)
|
|
34
|
+
@query_limit = limits.fetch(:query, QUERY_LIMIT)
|
|
35
|
+
@unmatched_limit = limits.fetch(:unmatched, UNMATCHED_LIMIT)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def items
|
|
39
|
+
return [] unless summarized?
|
|
40
|
+
|
|
41
|
+
requests_items + route_items + unmatched_items + query_items + job_items + exception_items
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def summarized?
|
|
45
|
+
rows_of("RailsPulse::Request").any? { |row| row.summarizable_id.zero? }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rows
|
|
51
|
+
@rows ||= Summary.where(period_type: "hour", period_start: @period_start).to_a.group_by(&:summarizable_type)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rows_of(type)
|
|
55
|
+
rows.fetch(type, [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def item(kind, fields)
|
|
59
|
+
{ type: "summary", kind: kind, period_start: @period_start.utc.iso8601, **fields }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def metrics(row_or_rows, counts)
|
|
63
|
+
group = Array(row_or_rows)
|
|
64
|
+
if group.size == 1
|
|
65
|
+
row = group.first
|
|
66
|
+
{ count: row[:count].to_i, **DURATIONS.index_with { |column| row[column]&.to_f }, **counts.index_with { |column| row[column].to_i } }
|
|
67
|
+
else
|
|
68
|
+
CombinedMetrics.of(group, counts: counts)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -- Requests and routes -------------------------------------------
|
|
73
|
+
|
|
74
|
+
def requests_items
|
|
75
|
+
overall = rows_of("RailsPulse::Request").find { |row| row.summarizable_id.zero? }
|
|
76
|
+
[ item("requests", metrics(overall, REQUEST_COUNTS)) ]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Route rows split into recognised routes and requests the router did
|
|
80
|
+
# not match, which are stored without a controller action.
|
|
81
|
+
def route_rows
|
|
82
|
+
@route_rows ||= begin
|
|
83
|
+
route_summaries = rows_of("RailsPulse::Route").select { |row| row.count.to_i.positive? }
|
|
84
|
+
routes = Route.where(id: route_summaries.map(&:summarizable_id)).index_by(&:id)
|
|
85
|
+
route_summaries.filter_map do |row|
|
|
86
|
+
route = routes[row.summarizable_id]
|
|
87
|
+
[ route, row ] if route
|
|
88
|
+
end.partition { |route, _| route.controller_action.present? }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def route_items
|
|
93
|
+
groups = route_rows.first.group_by do |route, _|
|
|
94
|
+
[ @route_patterns.path_for(route.path, route.controller_action), route.controller_action ]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ranked = groups.sort_by { |key, members| [ -members.sum { |_, row| row.total_duration.to_f }, key.map(&:to_s) ] }
|
|
98
|
+
kept, rest = ranked.first(@route_limit), ranked.drop(@route_limit)
|
|
99
|
+
|
|
100
|
+
items = kept.map do |(path, controller_action), members|
|
|
101
|
+
methods = members.flat_map { |route, _| route.http_methods_list }.uniq.sort
|
|
102
|
+
item("route", path: path, controller_action: controller_action, http_methods: methods,
|
|
103
|
+
**metrics(members.map(&:last), REQUEST_COUNTS))
|
|
104
|
+
end
|
|
105
|
+
items << item("route", path: OTHER, controller_action: nil, http_methods: [], **metrics(rest.flat_map { |_, members| members.map(&:last) }, REQUEST_COUNTS)) if rest.any?
|
|
106
|
+
items
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unmatched_items
|
|
110
|
+
groups = route_rows.last.group_by { |route, _| PathPrefix.for(route.path) }
|
|
111
|
+
ranked = groups.sort_by { |prefix, members| [ -members.sum { |_, row| row.count.to_i }, prefix ] }
|
|
112
|
+
kept, rest = ranked.first(@unmatched_limit), ranked.drop(@unmatched_limit)
|
|
113
|
+
|
|
114
|
+
items = kept.map { |prefix, members| item("unmatched", path_prefix: prefix, **metrics(members.map(&:last), REQUEST_COUNTS)) }
|
|
115
|
+
items << item("unmatched", path_prefix: PathPrefix::OTHER, **metrics(rest.flat_map { |_, members| members.map(&:last) }, REQUEST_COUNTS)) if rest.any?
|
|
116
|
+
items
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# -- Queries -------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def query_items
|
|
122
|
+
query_summaries = rows_of("RailsPulse::Query").select { |row| row.count.to_i.positive? }
|
|
123
|
+
queries = Query.where(id: query_summaries.map(&:summarizable_id)).index_by(&:id)
|
|
124
|
+
ranked = query_summaries.select { |row| queries[row.summarizable_id] }.sort_by { |row| [ -row.total_duration.to_f, row.summarizable_id ] }
|
|
125
|
+
kept, rest = ranked.first(@query_limit), ranked.drop(@query_limit)
|
|
126
|
+
sites = call_sites(kept.map(&:summarizable_id))
|
|
127
|
+
|
|
128
|
+
items = kept.map do |row|
|
|
129
|
+
query = queries[row.summarizable_id]
|
|
130
|
+
shape = QueryShape.for_adapter(query.normalized_sql, @adapter)
|
|
131
|
+
item("query", hashed_sql: query.hashed_sql, label: shape.label, sql_shape: shape.sql_shape,
|
|
132
|
+
call_sites: sites.fetch(query.id, []), **metrics(row, []))
|
|
133
|
+
end
|
|
134
|
+
items << item("query", hashed_sql: OTHER, label: "Other queries", sql_shape: nil, call_sites: [], **metrics(rest, [])) if rest.any?
|
|
135
|
+
items
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The two code locations that ran each query most often in the hour.
|
|
139
|
+
# Only paths inside the application are sent: a location outside it is
|
|
140
|
+
# stored as an absolute path, which can name the server's directories.
|
|
141
|
+
def call_sites(query_ids)
|
|
142
|
+
return {} if query_ids.empty?
|
|
143
|
+
|
|
144
|
+
Operation
|
|
145
|
+
.where(occurred_at: @period_start...(@period_start + 1.hour), query_id: query_ids)
|
|
146
|
+
.where.not(codebase_location: nil)
|
|
147
|
+
.group(:query_id, :codebase_location)
|
|
148
|
+
.count
|
|
149
|
+
.select { |(_, location), _| application_path?(location) }
|
|
150
|
+
.group_by { |(query_id, _), _| query_id }
|
|
151
|
+
.transform_values do |entries|
|
|
152
|
+
entries.sort_by { |(_, location), count| [ -count, location ] }.first(CALL_SITES).map { |(_, location), _| location }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def application_path?(location)
|
|
157
|
+
location.present? && !location.start_with?("/") && !location.include?("..") && !location.match?(/\A[A-Za-z]:/)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# -- Jobs and exceptions ---------------------------------------------
|
|
161
|
+
|
|
162
|
+
def job_items
|
|
163
|
+
job_summaries = rows_of("RailsPulse::Job").select { |row| row.count.to_i.positive? }
|
|
164
|
+
jobs = Job.where(id: job_summaries.map(&:summarizable_id)).index_by(&:id)
|
|
165
|
+
job_summaries.filter_map do |row|
|
|
166
|
+
job = jobs[row.summarizable_id]
|
|
167
|
+
next unless job
|
|
168
|
+
|
|
169
|
+
item("job", name: job.name, queue_name: job.queue_name, **metrics(row, JOB_COUNTS))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Exceptions have no duration, so these items carry only a count.
|
|
174
|
+
def exception_items
|
|
175
|
+
summaries = rows_of("RailsPulse::ExceptionGroup")
|
|
176
|
+
overall, per_group = summaries.partition { |row| row.summarizable_id.zero? }
|
|
177
|
+
groups = ExceptionGroup.where(id: per_group.map(&:summarizable_id)).pluck(:id, :fingerprint).to_h
|
|
178
|
+
|
|
179
|
+
items = per_group.filter_map do |row|
|
|
180
|
+
fingerprint = groups[row.summarizable_id]
|
|
181
|
+
item("exception_group", fingerprint: fingerprint, count: row.count.to_i) if fingerprint
|
|
182
|
+
end
|
|
183
|
+
overall.each { |row| items << item("exceptions", count: row.count.to_i) }
|
|
184
|
+
items
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# Sends this installation's data to Rails Pulse Cloud by the sync
|
|
4
|
+
# contract. `hourly` runs after SummaryJob: it queues each newly
|
|
5
|
+
# summarized hour with the exception groups and deployments that changed,
|
|
6
|
+
# then sends what is due. `minutely` sends the health update for the
|
|
7
|
+
# minute just ended, queues deployments recorded or finished since the
|
|
8
|
+
# last run, and sends what is due, which is also how retries happen.
|
|
9
|
+
#
|
|
10
|
+
# Batches wait in BufferedBatch until Cloud accepts them; health updates
|
|
11
|
+
# are never buffered, because one that arrives late is no use. With
|
|
12
|
+
# config.cloud unset nothing here runs, and no failure here is raised to
|
|
13
|
+
# the caller: Cloud being down, or refusing the key, must never affect
|
|
14
|
+
# the host application or local tracking.
|
|
15
|
+
class Sync
|
|
16
|
+
# Hours queued in one run, so catching up after an outage is spread
|
|
17
|
+
# over several.
|
|
18
|
+
MAX_HOURS_PER_RUN = 24
|
|
19
|
+
MAX_BATCHES_PER_FLUSH = 50
|
|
20
|
+
|
|
21
|
+
BACKOFF_START = 1.minute
|
|
22
|
+
BACKOFF_MAX = 1.hour
|
|
23
|
+
DEFAULT_RETRY_AFTER = 1.minute
|
|
24
|
+
# After Cloud refuses the key, the plan or the application, buffering
|
|
25
|
+
# continues and sending is retried this often.
|
|
26
|
+
REFUSED_RETRY = 1.hour
|
|
27
|
+
UNSUPPORTED_RETRY = 1.day
|
|
28
|
+
|
|
29
|
+
DEPRECATION_HEADER = "Rails-Pulse-Contract-Deprecated".freeze
|
|
30
|
+
|
|
31
|
+
# The hours and record windows the next hourly run would queue,
|
|
32
|
+
# without queueing anything or creating the installation row.
|
|
33
|
+
Plan = Struct.new(:hours, :exception_groups, :deployments, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
def initialize(settings: RailsPulse.configuration.cloud, client: nil, now: Time.current, jitter: nil)
|
|
36
|
+
@settings = settings
|
|
37
|
+
@client = client || Client.new(settings)
|
|
38
|
+
@now = now
|
|
39
|
+
@jitter = jitter || ->(delay) { delay * (0.5 + (rand / 2)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled?
|
|
43
|
+
@settings.enabled? && tables_available?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def hourly
|
|
47
|
+
guarded("hourly sync") do
|
|
48
|
+
plan = plan_for(installation)
|
|
49
|
+
route_patterns = RoutePattern.new
|
|
50
|
+
plan.hours.each do |hour|
|
|
51
|
+
items = SummaryItems.new(hour, adapter: envelope[:database_adapter], route_patterns: route_patterns).items
|
|
52
|
+
queue(items) { installation.update!(last_hour_sent_at: hour) }
|
|
53
|
+
end
|
|
54
|
+
queue_records(plan, exception_groups: true)
|
|
55
|
+
flush
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def minutely
|
|
60
|
+
guarded("health update") do
|
|
61
|
+
send_health
|
|
62
|
+
queue_records(plan_for(installation), exception_groups: false)
|
|
63
|
+
flush
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# What the next hourly run would queue for `record`, which may be nil
|
|
68
|
+
# before the first sync.
|
|
69
|
+
def plan_for(record)
|
|
70
|
+
Plan.new(hours: unsent_hours(record&.last_hour_sent_at),
|
|
71
|
+
exception_groups: window_since(record&.exception_groups_sent_through),
|
|
72
|
+
deployments: window_since(record&.deployments_sent_through))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def envelope
|
|
76
|
+
@envelope ||= Batch.envelope(application: @settings.application, environment: @settings.environment,
|
|
77
|
+
installation_id: installation.installation_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def installation
|
|
83
|
+
@installation ||= Installation.current
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def guarded(what)
|
|
87
|
+
return :disabled unless enabled?
|
|
88
|
+
|
|
89
|
+
yield
|
|
90
|
+
:ok
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
RailsPulse.logger.error("Rails Pulse Cloud #{what} failed: #{e.class} - #{e.message}")
|
|
93
|
+
record_failure("#{what} failed: #{e.class}: #{e.message}")
|
|
94
|
+
:failed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The failure is already logged; if the database is what failed, it
|
|
98
|
+
# cannot be recorded there too.
|
|
99
|
+
def record_failure(message)
|
|
100
|
+
installation.record_error!(message, now: @now)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def tables_available?
|
|
106
|
+
Installation.table_exists? && BufferedBatch.table_exists?
|
|
107
|
+
rescue ActiveRecord::ActiveRecordError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -- Queueing ------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
# Summarized hours after the last one queued, oldest first. The first
|
|
114
|
+
# sync starts at the latest hour rather than replaying history.
|
|
115
|
+
def unsent_hours(last_sent)
|
|
116
|
+
heartbeats = Summary.overall_requests.for_period_type("hour")
|
|
117
|
+
latest = heartbeats.maximum(:period_start)
|
|
118
|
+
return [] unless latest
|
|
119
|
+
|
|
120
|
+
from = last_sent ? last_sent + 1.second : latest
|
|
121
|
+
heartbeats.where(period_start: from..latest).order(:period_start).limit(MAX_HOURS_PER_RUN)
|
|
122
|
+
.pluck(:period_start).map(&:in_time_zone)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Rows changed since the cursor and before now. The first sync covers
|
|
126
|
+
# the hourly summary retention, so Cloud can name what those hours count.
|
|
127
|
+
def window_since(cursor)
|
|
128
|
+
(cursor || (@now - RailsPulse.configuration.hourly_summary_retention))...@now
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def queue_records(plan, exception_groups:)
|
|
132
|
+
items = []
|
|
133
|
+
items += RecordItems.exception_groups_updated(plan.exception_groups) if exception_groups
|
|
134
|
+
items += RecordItems.deployments_updated(plan.deployments)
|
|
135
|
+
cursors = { deployments_sent_through: plan.deployments.end }
|
|
136
|
+
cursors[:exception_groups_sent_through] = plan.exception_groups.end if exception_groups
|
|
137
|
+
queue(items) { installation.update!(cursors) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Batches the items into the buffer and moves the cursor in the same
|
|
141
|
+
# transaction, so a crash between the two neither loses nor repeats them.
|
|
142
|
+
def queue(items)
|
|
143
|
+
RailsPulse::ApplicationRecord.transaction do
|
|
144
|
+
Batch.build(items, envelope: envelope).each { |batch| BufferedBatch.enqueue!(batch, now: @now) }
|
|
145
|
+
yield
|
|
146
|
+
end
|
|
147
|
+
dropped = BufferedBatch.prune!(@now)
|
|
148
|
+
RailsPulse.logger.warn("Rails Pulse Cloud buffer full: dropped #{dropped} oldest batch(es)") if dropped.positive?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# -- Sending -------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def flush
|
|
154
|
+
return if installation.paused?(@now)
|
|
155
|
+
|
|
156
|
+
# Read one at a time: a split adds batches that are due at once.
|
|
157
|
+
MAX_BATCHES_PER_FLUSH.times do
|
|
158
|
+
buffered = BufferedBatch.due(@now).first
|
|
159
|
+
break unless buffered && deliver(buffered) == :continue
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def deliver(buffered)
|
|
164
|
+
contents = buffered.contents.merge("sent_at" => @now.utc.iso8601)
|
|
165
|
+
response = @client.post(contents.to_json)
|
|
166
|
+
|
|
167
|
+
case outcome(response)
|
|
168
|
+
when :accepted
|
|
169
|
+
buffered.destroy!
|
|
170
|
+
installation.record_success!(deprecated_on: response.header(DEPRECATION_HEADER), now: @now)
|
|
171
|
+
:continue
|
|
172
|
+
when :malformed
|
|
173
|
+
buffered.destroy!
|
|
174
|
+
RailsPulse.logger.error("Rails Pulse Cloud refused batch #{buffered.batch_id} as malformed: #{response.body}")
|
|
175
|
+
installation.record_error!("Cloud refused a batch as malformed and it was dropped: #{response.message}", now: @now)
|
|
176
|
+
:continue
|
|
177
|
+
when :too_large
|
|
178
|
+
split(buffered, contents)
|
|
179
|
+
:continue
|
|
180
|
+
when :rate_limited
|
|
181
|
+
retry_after = response.header("Retry-After").to_i
|
|
182
|
+
buffered.update!(next_attempt_at: @now + (retry_after.positive? ? retry_after : DEFAULT_RETRY_AFTER),
|
|
183
|
+
last_error: response.message)
|
|
184
|
+
:stop
|
|
185
|
+
when :refused, :unsupported
|
|
186
|
+
pause(response)
|
|
187
|
+
:stop
|
|
188
|
+
else
|
|
189
|
+
back_off(buffered, response.message)
|
|
190
|
+
:stop
|
|
191
|
+
end
|
|
192
|
+
rescue Client::Unavailable => e
|
|
193
|
+
back_off(buffered, e.message)
|
|
194
|
+
:stop
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def send_health
|
|
198
|
+
return if installation.paused?(@now)
|
|
199
|
+
|
|
200
|
+
batch = Batch.new([ HealthItem.for_minute_before(@now) ], envelope: envelope, sent_at: @now)
|
|
201
|
+
response = @client.post(batch.to_json)
|
|
202
|
+
case outcome(response)
|
|
203
|
+
when :accepted
|
|
204
|
+
installation.record_success!(deprecated_on: response.header(DEPRECATION_HEADER), now: @now)
|
|
205
|
+
installation.update!(last_health_at: @now)
|
|
206
|
+
when :refused, :unsupported
|
|
207
|
+
pause(response)
|
|
208
|
+
else
|
|
209
|
+
installation.record_error!("Health update not sent: #{response.message}", now: @now)
|
|
210
|
+
end
|
|
211
|
+
rescue Client::Unavailable => e
|
|
212
|
+
installation.record_error!("Health update not sent: #{e.message}", now: @now)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def outcome(response)
|
|
216
|
+
case response.status
|
|
217
|
+
when 202 then :accepted
|
|
218
|
+
when 200 then :accepted
|
|
219
|
+
when 400 then :malformed
|
|
220
|
+
when 401, 402, 404 then :refused
|
|
221
|
+
when 413 then :too_large
|
|
222
|
+
when 422 then :unsupported
|
|
223
|
+
when 429 then :rate_limited
|
|
224
|
+
else :unavailable
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def pause(response)
|
|
229
|
+
if response.status == 422
|
|
230
|
+
installation.pause!(@now + UNSUPPORTED_RETRY,
|
|
231
|
+
"Rails Pulse Cloud does not accept this gem's contract version (#{response.message}). Upgrade the rails_pulse gem.",
|
|
232
|
+
now: @now)
|
|
233
|
+
else
|
|
234
|
+
installation.pause!(@now + REFUSED_RETRY, "#{refusal(response.status)}: #{response.message}", now: @now)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def refusal(status)
|
|
239
|
+
case status
|
|
240
|
+
when 401 then "Rails Pulse Cloud refused config.cloud.api_key"
|
|
241
|
+
when 402 then "The Rails Pulse Cloud subscription is not active"
|
|
242
|
+
else "Rails Pulse Cloud does not know the application #{@settings.application.inspect}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# 1 minute, doubling, up to an hour, with jitter so installations that
|
|
247
|
+
# failed together do not retry together.
|
|
248
|
+
def back_off(buffered, message)
|
|
249
|
+
attempts = buffered.attempts + 1
|
|
250
|
+
delay = [ BACKOFF_START * (2**(attempts - 1)), BACKOFF_MAX ].min
|
|
251
|
+
buffered.update!(attempts: attempts, next_attempt_at: @now + @jitter.call(delay.to_f).seconds, last_error: message)
|
|
252
|
+
installation.record_error!("Cloud unavailable, retrying: #{message}", now: @now)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Halves a batch Cloud found too large. Each half is a new batch with
|
|
256
|
+
# its own id: it carries different items, so it is not a resend.
|
|
257
|
+
def split(buffered, contents)
|
|
258
|
+
items = contents["items"]
|
|
259
|
+
if items.size <= 1
|
|
260
|
+
buffered.destroy!
|
|
261
|
+
installation.record_error!("Cloud refused a single item as too large and it was dropped", now: @now)
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
envelope = contents.except("batch_id", "sent_at", "items").transform_keys(&:to_sym)
|
|
266
|
+
RailsPulse::ApplicationRecord.transaction do
|
|
267
|
+
items.each_slice((items.size / 2.0).ceil) do |half|
|
|
268
|
+
BufferedBatch.enqueue!(Batch.new(half, envelope: envelope), now: @now)
|
|
269
|
+
end
|
|
270
|
+
buffered.destroy!
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|