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
@@ -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
- :deployment_api_token,
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 = 2.days
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
@@ -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 "request_store"
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
- Rails.autoloaders.main.inflector.inflect(ACRONYM_SAFE_INFLECTIONS) if Rails.respond_to?(:autoloaders)
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
- ASSIGNMENT = /\A\s*(#\s*)?config\.(\w+)\s*=/.freeze
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.uniq
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 = RequestStore.store[:rails_pulse_request_id]
11
- previous_operations = RequestStore.store[:rails_pulse_operations]
12
- previous_job_run_id = RequestStore.store[:rails_pulse_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
- RequestStore.store[:rails_pulse_request_id] = nil
32
- RequestStore.store[:rails_pulse_job_run_id] = job_run&.id
33
- RequestStore.store[:rails_pulse_operations] = []
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
- RequestStore.store[:rails_pulse_captured_exception] = error
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
- RequestStore.store[:rails_pulse_job_run_id] = previous_job_run_id
80
- RequestStore.store[:rails_pulse_operations] = previous_operations
81
- RequestStore.store[:rails_pulse_request_id] = previous_request_id
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 = RequestStore.store[:rails_pulse_operations] || []
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
- RequestStore.store[:rails_pulse_operations] = nil
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 = RequestStore.store[:skip_recording_rails_pulse_activity]
202
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
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
- RequestStore.store[:skip_recording_rails_pulse_activity] = previous
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