rails_pulse 0.3.3 → 0.4.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -0,0 +1,116 @@
1
+ module RailsPulse
2
+ module Exceptions
3
+ module Cards
4
+ # Exceptions raised per 1,000 requests.
5
+ #
6
+ # Raw occurrence counts rise with traffic, so a busy week and a broken
7
+ # week look the same on the Occurrences card. Dividing by request volume
8
+ # separates the two: this is the only exception figure that can fall while
9
+ # occurrences climb, which is what "we shipped a fix but got busier" looks
10
+ # like.
11
+ #
12
+ # Both sides come from rails_pulse_summaries — the all-groups exception
13
+ # rollup over the overall request summary — so the ratio stays accurate
14
+ # after cleanup has pruned the underlying records.
15
+ class ExceptionRate < RailsPulse::Cards::Base
16
+ PER = 1_000
17
+
18
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
19
+ @disabled_tags = disabled_tags
20
+ @show_non_tagged = show_non_tagged
21
+ @period = period
22
+ @period_type = period_type
23
+ end
24
+
25
+ def to_metric_card
26
+ exceptions = window_totals(RailsPulse::Summary.overall_exceptions)
27
+ requests = window_totals(RailsPulse::Summary.overall_requests)
28
+
29
+ rate = rate_for(exceptions[:total], requests[:total])
30
+
31
+ # More exceptions per request is worse, so the arrow reads the
32
+ # opposite way round from a throughput card — the strip already
33
+ # colours trending-up as negative.
34
+ if show_trend?
35
+ trend_icon, trend_amount = trend_for(
36
+ rate_for(exceptions[:current], requests[:current]),
37
+ rate_for(exceptions[:previous], requests[:previous])
38
+ )
39
+ end
40
+
41
+ {
42
+ id: "exceptions_rate",
43
+ chart_color: RailsPulse::ChartColors::DEFAULT,
44
+ context: "exceptions",
45
+ title: "Exception Rate",
46
+ summary: "#{rate} per #{format_number(PER)}",
47
+ chart_data: sparkline_data,
48
+ trend_icon: trend_icon,
49
+ trend_amount: trend_amount,
50
+ trend_text: (show_trend? ? comparison_period_text : nil),
51
+ period_stat: period_stat(exceptions[:total], requests[:total]),
52
+ help_heading: "Exception Rate",
53
+ help_text: "Exceptions raised per 1,000 requests over the period. Occurrence counts on their own rise and fall with traffic; this figure does not, so it is the one to watch when you want to know whether the application itself got worse. Reads the all-groups exception rollup against the overall request summary. Exceptions raised inside background jobs are counted in the numerator but have no request to divide by, so a job-heavy application will read high."
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ # Total, current-window and previous-window counts in one pass, so the
60
+ # rate can be compared across windows without four separate queries.
61
+ def window_totals(scope)
62
+ row = scope
63
+ .where(period_type: @period_type, period_start: range_start..now)
64
+ .select(
65
+ "SUM(rails_pulse_summaries.count) AS total_count",
66
+ "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
67
+ "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
68
+ )
69
+ .take
70
+
71
+ {
72
+ total: row&.total_count.to_i,
73
+ current: row&.current_count.to_i,
74
+ previous: row&.previous_count.to_i
75
+ }
76
+ end
77
+
78
+ # No requests in a period is "nothing to divide by", not "a rate of
79
+ # zero" — but zero is the honest reading for a card that answers "how
80
+ # often did this happen", and it keeps the sparkline continuous.
81
+ def rate_for(exceptions, requests)
82
+ return 0.0 if requests.to_i.zero?
83
+
84
+ (exceptions.to_f / requests * PER).round(1)
85
+ end
86
+
87
+ def sparkline_data
88
+ exceptions = grouped_counts(RailsPulse::Summary.overall_exceptions)
89
+ requests = grouped_counts(RailsPulse::Summary.overall_requests)
90
+
91
+ sparkline_from(
92
+ requests.keys.union(exceptions.keys).index_with do |bucket|
93
+ rate_for(exceptions[bucket], requests[bucket])
94
+ end
95
+ )
96
+ end
97
+
98
+ def grouped_counts(scope)
99
+ scoped = scope.where(period_type: @period_type, period_start: current_window_start..now)
100
+
101
+ if period_type_hours?
102
+ scoped.group_by_hour(:period_start).sum("rails_pulse_summaries.count")
103
+ else
104
+ scoped.group_by_date(:period_start).sum("rails_pulse_summaries.count")
105
+ end
106
+ end
107
+
108
+ def period_stat(exceptions, requests)
109
+ return period_date_range if requests.zero?
110
+
111
+ "#{format_number(exceptions)} raised · #{format_number(requests)} requests"
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,49 @@
1
+ module RailsPulse
2
+ module Exceptions
3
+ module Cards
4
+ # How many distinct exception sites are currently open.
5
+ #
6
+ # Occurrence volume and open-group count answer different questions: one
7
+ # loud exception firing ten thousand times and a hundred different sites
8
+ # each firing once are both worth knowing about, and neither number tells
9
+ # you about the other.
10
+ class OpenGroups < RailsPulse::Cards::Base
11
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
12
+ @disabled_tags = disabled_tags
13
+ @show_non_tagged = show_non_tagged
14
+ @period = period
15
+ @period_type = period_type
16
+ end
17
+
18
+ def to_metric_card
19
+ open_count = RailsPulse::ExceptionGroup.where(status: "open").count
20
+
21
+ # New groups are the ones that did not exist before this window — a
22
+ # site that has just started failing, rather than a known one.
23
+ new_in_window = RailsPulse::ExceptionGroup
24
+ .where(status: "open")
25
+ .where(first_seen_at: current_window_start..now)
26
+ .count
27
+
28
+ grouped = RailsPulse::ExceptionGroup
29
+ .where(first_seen_at: current_window_start..now)
30
+ .group_by_date(:first_seen_at)
31
+ .count
32
+
33
+ {
34
+ id: "exceptions_open_groups",
35
+ chart_color: RailsPulse::ChartColors::DEFAULT,
36
+ context: "exceptions",
37
+ title: "Open Groups",
38
+ summary: "#{format_number(open_count)} open",
39
+ chart_data: sparkline_from(grouped),
40
+ trend_text: nil,
41
+ period_stat: "#{format_number(new_in_window)} new this period",
42
+ help_heading: "Open Exception Groups",
43
+ help_text: "Distinct exception sites currently marked open — one per exception class and code location, not one per raise. Resolved and ignored groups are excluded. The period figure counts sites seen for the first time in this window, which is usually where a new bug shows up."
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,62 @@
1
+ module RailsPulse
2
+ module Exceptions
3
+ module Cards
4
+ # How often exceptions fired, and whether that is going up.
5
+ #
6
+ # This is the card that makes "which exceptions are increasing?" a
7
+ # question the product can answer at a glance. It reads the all-groups
8
+ # rollup summary rather than ExceptionGroup#occurrence_count, which is a
9
+ # lifetime counter and never goes down.
10
+ class TotalOccurrences < RailsPulse::Cards::Base
11
+ SUMMARIZABLE_TYPE = "RailsPulse::ExceptionGroup".freeze
12
+
13
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
14
+ @disabled_tags = disabled_tags
15
+ @show_non_tagged = show_non_tagged
16
+ @period = period
17
+ @period_type = period_type
18
+ end
19
+
20
+ def to_metric_card
21
+ base_query = RailsPulse::Summary
22
+ .overall_exceptions
23
+ .where(period_type: @period_type, period_start: range_start..now)
24
+
25
+ metrics = base_query.select(
26
+ "SUM(rails_pulse_summaries.count) AS total_count",
27
+ "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
28
+ "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
29
+ ).take
30
+
31
+ total = metrics&.total_count.to_i
32
+ current = metrics&.current_count.to_i
33
+ previous = metrics&.previous_count.to_i
34
+
35
+ # More exceptions is worse, so the trend arrow means the opposite of
36
+ # what it means on a throughput card.
37
+ trend_icon, trend_amount = trend_for(current, previous) if show_trend?
38
+
39
+ grouped = base_query
40
+ .where(period_start: current_window_start..now)
41
+ .group_by_date(:period_start)
42
+ .sum("rails_pulse_summaries.count")
43
+
44
+ {
45
+ id: "exceptions_total_occurrences",
46
+ chart_color: RailsPulse::ChartColors::DEFAULT,
47
+ context: "exceptions",
48
+ title: "Occurrences",
49
+ summary: "#{format_number(total)} raised",
50
+ chart_data: sparkline_from(grouped),
51
+ trend_icon: trend_icon,
52
+ trend_amount: trend_amount,
53
+ trend_text: (show_trend? ? comparison_period_text : nil),
54
+ period_stat: period_date_range,
55
+ help_heading: "Exception Occurrences",
56
+ help_text: "How many exceptions your application raised over the period, across every group. Read from retained summaries, so the history stays accurate even after individual occurrence records have been cleaned up. A rising trend is the signal to look at which groups are growing."
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ module RailsPulse
2
+ module Exceptions
3
+ module Charts
4
+ # Exception frequency over time, read from the all-groups rollup summary.
5
+ #
6
+ # Reads summaries rather than counting occurrence rows directly: occurrence
7
+ # rows are pruned by retention, so counting them would show a chart that
8
+ # silently flattens toward zero the further back you look. Summaries are
9
+ # retained at day granularity indefinitely.
10
+ class OccurrenceVolume
11
+ def initialize(start_time:, end_time:, period_type: "day")
12
+ @start_time = start_time
13
+ @end_time = end_time
14
+ @period_type = period_type
15
+ end
16
+
17
+ def to_chart_data
18
+ totals = RailsPulse::Summary
19
+ .overall_exceptions
20
+ .where(period_type: @period_type, period_start: @start_time..@end_time)
21
+ .group(:period_start)
22
+ .sum(:count)
23
+
24
+ return nil if totals.empty?
25
+
26
+ raw = totals.transform_keys { |period_start| period_start.to_i }
27
+ padded = pad_with_zeros(raw)
28
+
29
+ {
30
+ series: [ {
31
+ name: "Occurrences",
32
+ data: padded.map { |timestamp, value| [ timestamp * 1000, value ] },
33
+ type: "bar",
34
+ color: RailsPulse::ChartColors::DEFAULT
35
+ } ]
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ # A period with no exceptions produces no summary row, which is the
42
+ # answer "zero" rather than "no data" — leaving the gap unfilled would
43
+ # draw a chart that skips quiet days and misrepresents the trend.
44
+ def pad_with_zeros(raw)
45
+ step = @period_type.to_s == "hour" ? 3600 : 86_400
46
+
47
+ {}.tap do |padded|
48
+ (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
49
+ padded[timestamp] = raw[timestamp] || 0
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,216 @@
1
+ module RailsPulse
2
+ module Exceptions
3
+ # Builds the plain-text "Raw Data" block shown on the exception pages and
4
+ # copied to the clipboard for pasting into an LLM.
5
+ #
6
+ # Markdown rather than JSON (as the query page uses) because the payload is
7
+ # mostly backtrace and source snippets, which JSON string-escapes into
8
+ # something neither a human nor a model reads well.
9
+ #
10
+ # Runs its own aggregate queries off the group's occurrences association so
11
+ # the views and controllers stay thin and this stays independently testable.
12
+ class RawData
13
+ include RailsPulse::BacktraceHelper
14
+
15
+ # Caps keep the payload pasteable.
16
+ MAX_EXPANDED_FRAMES = 10
17
+ MAX_ENDPOINTS = 10
18
+ MAX_FRAMES = 50
19
+ SOURCE_RADIUS = 3
20
+ RECENT_WINDOW = 24.hours
21
+ PRIOR_WINDOW = 7.days
22
+
23
+ def initialize(group, occurrence: nil, aggregates: true)
24
+ @group = group
25
+ @occurrence = occurrence
26
+ @aggregates = aggregates
27
+ end
28
+
29
+ def to_s
30
+ sections.compact_blank.join("\n\n") << "\n"
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :group, :occurrence
36
+
37
+ def sections
38
+ [
39
+ heading,
40
+ summary_section,
41
+ frequency_section,
42
+ deploy_section,
43
+ request_section,
44
+ params_section,
45
+ backtrace_section,
46
+ source_section
47
+ ]
48
+ end
49
+
50
+ def aggregates?
51
+ @aggregates
52
+ end
53
+
54
+ def heading
55
+ "# Exception: #{group.exception_class}"
56
+ end
57
+
58
+ def summary_section
59
+ list = [
60
+ [ "Class", group.exception_class ],
61
+ [ "Message", message ],
62
+ [ "Location", group.location.presence || "unknown" ],
63
+ [ "Status", group.status ],
64
+ [ "Preserved from cleanup", group.preserve? ? "yes" : "no" ],
65
+ [ "Fingerprint", group.fingerprint ]
66
+ ]
67
+
68
+ section("Summary", bullets(list))
69
+ end
70
+
71
+ # The group message is sanitized at capture; occurrence messages are too
72
+ # (see ExceptionMessageSanitizer), but fall back to the group's if blank.
73
+ def message
74
+ (occurrence&.message.presence || group.message.presence || "—")
75
+ end
76
+
77
+ def frequency_section
78
+ list = [
79
+ [ "First seen", timestamp(group.first_seen_at) ],
80
+ [ "Last seen", timestamp(group.last_seen_at) ],
81
+ [ "Total occurrences (lifetime)", group.occurrence_count ]
82
+ ]
83
+
84
+ if aggregates?
85
+ list << [ "Stored occurrences", "#{stored_count} (older rows may have been pruned by retention)" ]
86
+ list << [ "Last 24 hours", recent_count ]
87
+ list << [ "Preceding 7 days", prior_count ]
88
+ end
89
+
90
+ section("Frequency", bullets(list))
91
+ end
92
+
93
+ def deploy_section
94
+ return unless aggregates?
95
+
96
+ shas = occurrences.where.not(deploy_sha: nil).distinct.pluck(:deploy_sha)
97
+ return if shas.empty?
98
+
99
+ first_sha = occurrences.order(:occurred_at).pick(:deploy_sha)
100
+
101
+ list = [ [ "Deploy SHAs seen", shas.join(", ") ] ]
102
+ list << [ "Earliest stored occurrence deployed at", first_sha ] if first_sha.present?
103
+
104
+ section("Deploys", bullets(list))
105
+ end
106
+
107
+ def request_section
108
+ lines = []
109
+
110
+ if aggregates?
111
+ environments = occurrences.where.not(environment: nil).distinct.pluck(:environment)
112
+ lines << "- Environments: #{environments.join(', ')}" if environments.any?
113
+
114
+ endpoints = occurrences.where.not(request_url: nil)
115
+ .distinct.limit(MAX_ENDPOINTS)
116
+ .pluck(:request_method, :request_url)
117
+ if endpoints.any?
118
+ lines << "- Endpoints:"
119
+ endpoints.each { |method, url| lines << " - #{method.presence || '?'} #{url}" }
120
+ end
121
+ elsif occurrence
122
+ lines << "- Environment: #{occurrence.environment}" if occurrence.environment.present?
123
+ if occurrence.request_url.present?
124
+ lines << "- Endpoint: #{occurrence.request_method.presence || '?'} #{occurrence.request_url}"
125
+ end
126
+ lines << "- Deploy SHA: #{occurrence.deploy_sha}" if occurrence.deploy_sha.present?
127
+ lines << "- Occurred at: #{timestamp(occurrence.occurred_at)}"
128
+ end
129
+
130
+ section("Request context", lines.join("\n")) if lines.any?
131
+ end
132
+
133
+ # Already run through ActiveSupport::ParameterFilter at capture time.
134
+ def params_section
135
+ params = occurrence&.request_params
136
+ return if params.blank?
137
+
138
+ section("Request params (filtered)", fenced(JSON.pretty_generate(params), "json"))
139
+ end
140
+
141
+ def backtrace_section
142
+ return if frames.empty?
143
+
144
+ lines = frames.each_with_index.map do |frame, index|
145
+ origin = app_frame?(frame) ? "app" : "gem"
146
+ "#{(index + 1).to_s.rjust(2)}. [#{origin}] #{frame_display_path(frame)}:#{frame['line']}:in `#{frame['method']}'"
147
+ end
148
+
149
+ title = "Backtrace"
150
+ title += " (occurrence at #{timestamp(occurrence.occurred_at)})" if occurrence&.occurred_at
151
+
152
+ section(title, fenced(lines.join("\n")))
153
+ end
154
+
155
+ # Source is read from disk for app frames only, and BacktraceHelper
156
+ # sandboxes reads to Rails.root.
157
+ def source_section
158
+ expanded = frames.select { |frame| app_frame?(frame) }
159
+ .first(MAX_EXPANDED_FRAMES)
160
+ .filter_map do |frame|
161
+ lines = frame_source_lines(frame, radius: SOURCE_RADIUS)
162
+ next if lines.blank?
163
+
164
+ body = lines.map do |lineno, content|
165
+ marker = lineno == frame["line"].to_i ? ">" : " "
166
+ "#{marker} #{lineno.to_s.rjust(4)} | #{content}"
167
+ end.join("\n")
168
+
169
+ "### #{frame_display_path(frame)}:#{frame['line']} — `#{frame['method']}`\n\n#{fenced(body, 'ruby')}"
170
+ end
171
+
172
+ return if expanded.empty?
173
+
174
+ section("Source", expanded.join("\n\n"))
175
+ end
176
+
177
+ def frames
178
+ @frames ||= Array(occurrence&.backtrace).first(MAX_FRAMES).select { |frame| frame.is_a?(Hash) }
179
+ end
180
+
181
+ def occurrences
182
+ group.occurrences
183
+ end
184
+
185
+ def stored_count
186
+ @stored_count ||= occurrences.count
187
+ end
188
+
189
+ def recent_count
190
+ @recent_count ||= occurrences.where(occurred_at: RECENT_WINDOW.ago..).count
191
+ end
192
+
193
+ def prior_count
194
+ @prior_count ||= occurrences.where(occurred_at: (RECENT_WINDOW + PRIOR_WINDOW).ago...RECENT_WINDOW.ago).count
195
+ end
196
+
197
+ def timestamp(time)
198
+ return "—" if time.blank?
199
+ time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
200
+ end
201
+
202
+ def bullets(pairs)
203
+ pairs.map { |label, value| "- #{label}: #{value}" }.join("\n")
204
+ end
205
+
206
+ def fenced(body, language = "")
207
+ "```#{language}\n#{body}\n```"
208
+ end
209
+
210
+ def section(title, body)
211
+ return if body.blank?
212
+ "## #{title}\n\n#{body}"
213
+ end
214
+ end
215
+ end
216
+ end
@@ -40,7 +40,13 @@ module RailsPulse
40
40
  current_p95 = weighted_average(current_weighted_p95, current_runs)
41
41
  previous_p95 = weighted_average(previous_weighted_p95, previous_runs)
42
42
 
43
- trend_icon, trend_amount = trend_for(current_p95, previous_p95) if show_trend?
43
+ baseline = baseline_trend_for(@job, metric: :p95)
44
+
45
+ if baseline
46
+ trend_icon, trend_amount, trend_caption = baseline
47
+ elsif show_trend?
48
+ trend_icon, trend_amount = trend_for(current_p95, previous_p95)
49
+ end
44
50
 
45
51
  grouped_weighted = base_query
46
52
  .group_by_date(:period_start)
@@ -61,7 +67,7 @@ module RailsPulse
61
67
  chart_data: sparkline_data,
62
68
  trend_icon: trend_icon,
63
69
  trend_amount: trend_amount,
64
- trend_text: (show_trend? ? comparison_period_text : nil),
70
+ trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
65
71
  period_stat: total_runs > 0 ? "Across #{format_number(total_runs)} runs" : period_date_range,
66
72
  help_heading: "P95 Duration",
67
73
  help_text: "The 95th percentile execution time — 95% of jobs complete faster than this. Weighted by job volume across all classes. A rising P95 indicates jobs are becoming slower, which may cause queue backlog."
@@ -83,6 +83,9 @@ module RailsPulse
83
83
  end
84
84
  end
85
85
  op[:label] = op[:label]&.truncate(255)
86
+ # actual_sql is used for normalization above but should not be persisted
87
+ # unless the user has opted in — it may contain unparameterized PII.
88
+ op[:actual_sql] = nil unless RailsPulse.configuration.capture_actual_sql
86
89
  op
87
90
  end
88
91
  # insert_all! requires every row to have identical keys.
@@ -109,20 +112,26 @@ module RailsPulse
109
112
  Arel.sql("COUNT(rails_pulse_operations.id)")
110
113
  end
111
114
 
112
- ransacker :occurred_at, formatter: ->(val) {
113
- # Handle different time formats for database compatibility
115
+ # Handle different time formats for database compatibility. An
116
+ # unparseable value becomes nil, which Ransack treats as "no filter",
117
+ # rather than a 500 from a hand-edited query string.
118
+ OCCURRED_AT_FORMATTER = lambda do |val|
114
119
  case val
115
120
  when Time, DateTime, ActiveSupport::TimeWithZone
116
121
  val.utc.iso8601
117
122
  when String
118
- Time.zone.parse(val).utc.iso8601
123
+ Time.zone.parse(val)&.utc&.iso8601
119
124
  when Integer
120
125
  Time.at(val).utc.iso8601
121
126
  else
122
127
  # Fallback: try to parse as integer timestamp
123
128
  Time.at(val.to_i).utc.iso8601
124
129
  end
125
- } do |parent|
130
+ rescue ArgumentError, TypeError
131
+ nil
132
+ end
133
+
134
+ ransacker :occurred_at, formatter: OCCURRED_AT_FORMATTER do |parent|
126
135
  parent.table[:occurred_at]
127
136
  end
128
137
 
@@ -26,8 +26,13 @@ module RailsPulse
26
26
  current_period_p95 = metrics.current_p95 || 0
27
27
  previous_period_p95 = metrics.previous_p95 || 0
28
28
 
29
- # Use base class trend calculation
30
- trend_icon, trend_amount = trend_for(current_period_p95, previous_period_p95) if show_trend?
29
+ baseline = baseline_trend_for(@query, metric: :p95)
30
+
31
+ if baseline
32
+ trend_icon, trend_amount, trend_caption = baseline
33
+ elsif show_trend?
34
+ trend_icon, trend_amount = trend_for(current_period_p95, previous_period_p95)
35
+ end
31
36
 
32
37
  # Create a query for sparkline data using only the current period
33
38
  sparkline_query = RailsPulse::Summary
@@ -65,7 +70,7 @@ module RailsPulse
65
70
  chart_data: sparkline_data,
66
71
  trend_icon: trend_icon,
67
72
  trend_amount: trend_amount,
68
- trend_text: (show_trend? ? comparison_period_text : nil),
73
+ trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
69
74
  period_stat: metrics.total_count.to_i > 0 ? "Across #{format_number(metrics.total_count.to_i)} queries" : period_date_range,
70
75
  help_heading: "P95 Query Time",
71
76
  help_text: "The 95th percentile database query duration — 95% of queries complete faster than this. Weighted by execution frequency across all queries. Slow queries are often caused by missing indexes or N+1 patterns."
@@ -21,7 +21,7 @@ module RailsPulse
21
21
  before_validation :set_request_uuid, on: :create
22
22
 
23
23
  def self.ransackable_attributes(auth_object = nil)
24
- %w[id route_id occurred_at duration status status_category status_indicator route_path response_size_bytes]
24
+ %w[id route_id occurred_at duration status status_category status_indicator route_path http_method controller_action response_size_bytes]
25
25
  end
26
26
 
27
27
  def self.ransackable_associations(auth_object = nil)
@@ -36,6 +36,10 @@ module RailsPulse
36
36
  Arel.sql("rails_pulse_routes.path")
37
37
  end
38
38
 
39
+ ransacker :http_method do |parent|
40
+ parent.table[:method]
41
+ end
42
+
39
43
  ransacker :status_category do |parent|
40
44
  # Returns the first digit of the status code (2, 3, 4, or 5)
41
45
  # Use FLOOR instead of CAST for cross-database compatibility