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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
  15. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  16. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  17. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  18. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
  19. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
  20. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
  21. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  22. data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
  23. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  24. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  25. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  26. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  27. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  28. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  29. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  30. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  31. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  32. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  33. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  34. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  35. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  36. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  37. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  38. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  39. data/app/models/rails_pulse/cards/base.rb +20 -11
  40. data/app/models/rails_pulse/charts/base.rb +5 -18
  41. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
  42. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  43. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  44. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  45. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  46. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  47. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  48. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
  49. data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
  50. data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
  51. data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
  52. data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
  53. data/app/models/rails_pulse/deployment.rb +2 -7
  54. data/app/models/rails_pulse/event.rb +41 -0
  55. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  56. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  57. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  58. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  59. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
  60. data/app/models/rails_pulse/job_run.rb +1 -1
  61. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  62. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  63. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  64. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  65. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  67. data/app/models/rails_pulse/like_pattern.rb +35 -0
  68. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  69. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  70. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  71. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  72. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
  73. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  74. data/app/models/rails_pulse/request.rb +1 -1
  75. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  76. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  77. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  78. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  79. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  80. data/app/models/rails_pulse/summary.rb +5 -0
  81. data/app/models/rails_pulse/tables/base.rb +5 -6
  82. data/app/models/rails_pulse/time_range.rb +309 -0
  83. data/app/models/rails_pulse/time_window.rb +15 -0
  84. data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
  85. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
  86. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  87. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  88. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  89. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  90. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  91. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
  92. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  93. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  94. data/app/services/rails_pulse/cloud/client.rb +68 -0
  95. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  96. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  97. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  98. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  99. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  100. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  101. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  102. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  103. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  104. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  105. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  106. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  107. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  108. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  109. data/app/services/rails_pulse/period_insights.rb +131 -0
  110. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  111. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  112. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  113. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  114. data/app/services/rails_pulse/summary_service.rb +121 -133
  115. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  116. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  117. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  118. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  119. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  120. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  121. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  122. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  123. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  124. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  125. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  126. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  127. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  128. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  129. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  130. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  131. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  132. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  133. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  134. data/app/views/rails_pulse/storage/show.html.erb +39 -2
  135. data/config/routes.rb +23 -0
  136. data/db/rails_pulse_cloud_schema.rb +58 -0
  137. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  138. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  139. data/db/rails_pulse_schema.rb +20 -2
  140. data/exe/rails-pulse +7 -0
  141. data/lib/generators/rails_pulse/base_methods.rb +19 -0
  142. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  143. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  144. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  145. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  146. data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
  147. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  148. data/lib/rails_pulse/cleanup_service.rb +37 -20
  149. data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
  150. data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
  151. data/lib/rails_pulse/cli/base_command.rb +37 -0
  152. data/lib/rails_pulse/cli/client.rb +64 -0
  153. data/lib/rails_pulse/cli/config.rb +87 -0
  154. data/lib/rails_pulse/cli/configure.rb +75 -0
  155. data/lib/rails_pulse/cli/coverage.rb +91 -0
  156. data/lib/rails_pulse/cli/deployments.rb +54 -0
  157. data/lib/rails_pulse/cli/exceptions.rb +116 -0
  158. data/lib/rails_pulse/cli/formatter.rb +31 -0
  159. data/lib/rails_pulse/cli/insights.rb +70 -0
  160. data/lib/rails_pulse/cli/install.rb +145 -0
  161. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  162. data/lib/rails_pulse/cli/jobs.rb +71 -0
  163. data/lib/rails_pulse/cli/main.rb +76 -0
  164. data/lib/rails_pulse/cli/mcp.rb +47 -0
  165. data/lib/rails_pulse/cli/queries.rb +67 -0
  166. data/lib/rails_pulse/cli/requests.rb +57 -0
  167. data/lib/rails_pulse/cli/routes.rb +73 -0
  168. data/lib/rails_pulse/configuration.rb +100 -2
  169. data/lib/rails_pulse/current.rb +11 -0
  170. data/lib/rails_pulse/engine.rb +12 -3
  171. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  172. data/lib/rails_pulse/job_run_collector.rb +15 -15
  173. data/lib/rails_pulse/mcp/server.rb +78 -0
  174. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  175. data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
  176. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  177. data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
  178. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  179. data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
  180. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  181. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  182. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  183. data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
  184. data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
  185. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  186. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  187. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  188. data/lib/rails_pulse/statistics.rb +27 -0
  189. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  190. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  191. data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
  192. data/lib/rails_pulse/tracker.rb +76 -14
  193. data/lib/rails_pulse/version.rb +1 -1
  194. data/lib/rails_pulse.rb +0 -4
  195. data/lib/tasks/rails_pulse.rake +6 -0
  196. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  197. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  198. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  199. metadata +123 -24
  200. data/app/controllers/concerns/page_timings.rb +0 -10
  201. data/app/controllers/concerns/response_range_concern.rb +0 -45
  202. data/app/controllers/concerns/time_range_concern.rb +0 -152
  203. 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