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