rails_pulse 0.4.2 → 0.5.0.pre.1

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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -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 +130 -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 +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class ErrorRates < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @route = route
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(route: nil, **kwargs)
6
+ super(subject: route, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,8 +14,8 @@ module RailsPulse
20
14
  "SUM(error_count) AS total_errors",
21
15
  "SUM(status_4xx) AS total_4xx",
22
16
  "SUM(count) AS total_requests",
23
- "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN error_count + status_4xx ELSE 0 END) AS current_total_errors",
24
- "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN error_count + status_4xx ELSE 0 END) AS previous_total_errors"
17
+ window_sql("SUM(CASE WHEN period_start >= :current_start THEN error_count + status_4xx ELSE 0 END) AS current_total_errors"),
18
+ window_sql("SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN error_count + status_4xx ELSE 0 END) AS previous_total_errors")
25
19
  ).take
26
20
 
27
21
  total_errors = metrics.total_errors || 0
@@ -35,7 +29,7 @@ module RailsPulse
35
29
  server_rate = has_data ? (total_errors.to_f / total_requests * 100).round(2) : 0
36
30
  client_rate = has_data ? (total_4xx.to_f / total_requests * 100).round(2) : 0
37
31
 
38
- baseline = baseline_trend_for(@route, metric: :error_rate)
32
+ baseline = baseline_trend_for(@subject, metric: :error_rate)
39
33
 
40
34
  if baseline
41
35
  trend_icon, trend_amount, trend_caption = baseline
@@ -72,10 +66,6 @@ module RailsPulse
72
66
 
73
67
  private
74
68
 
75
- def subject_id
76
- @route&.id
77
- end
78
-
79
69
  def sparkline_from_error_rates(errors_by_period, client_errors_by_period, counts_by_period)
80
70
  if @period_type == "hour"
81
71
  sparkline_hours.each_with_object({}) do |current_time, result|
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class PercentileResponseTimes < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @route = route
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(route: nil, **kwargs)
6
+ super(subject: route, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -18,8 +12,8 @@ module RailsPulse
18
12
 
19
13
  metrics = base_query.select(
20
14
  "SUM(p95_duration * count) / NULLIF(SUM(count), 0) AS overall_p95",
21
- "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN p95_duration * count ELSE 0 END) / NULLIF(SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END), 0) AS current_p95",
22
- "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN p95_duration * count ELSE 0 END) / NULLIF(SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END), 0) AS previous_p95",
15
+ window_sql("SUM(CASE WHEN period_start >= :current_start THEN p95_duration * count ELSE 0 END) / NULLIF(SUM(CASE WHEN period_start >= :current_start THEN count ELSE 0 END), 0) AS current_p95"),
16
+ window_sql("SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN p95_duration * count ELSE 0 END) / NULLIF(SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN count ELSE 0 END), 0) AS previous_p95"),
23
17
  "SUM(count) AS total_count"
24
18
  ).take
25
19
 
@@ -32,7 +26,7 @@ module RailsPulse
32
26
 
33
27
  # Prefer a comparison against this route's own history; fall back to
34
28
  # period-over-period on the index, where there is no single subject.
35
- baseline = baseline_trend_for(@route, metric: :p95)
29
+ baseline = baseline_trend_for(@subject, metric: :p95)
36
30
 
37
31
  if baseline
38
32
  trend_icon, trend_amount, trend_caption = baseline
@@ -69,12 +63,6 @@ module RailsPulse
69
63
  help_text: "The 95th percentile response time — 95% of requests are faster than this. Weighted by request volume across all routes. A rising P95 indicates increasing slowness affecting your users."
70
64
  }
71
65
  end
72
-
73
- private
74
-
75
- def subject_id
76
- @route&.id
77
- end
78
66
  end
79
67
  end
80
68
  end
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class RequestCountTotals < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @route = route
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(route: nil, **kwargs)
6
+ super(subject: route, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -18,8 +12,8 @@ module RailsPulse
18
12
 
19
13
  metrics = base_query.select(
20
14
  "SUM(count) AS total_count",
21
- "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
22
- "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
15
+ window_sql("SUM(CASE WHEN period_start >= :current_start THEN count ELSE 0 END) AS current_count"),
16
+ window_sql("SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN count ELSE 0 END) AS previous_count")
23
17
  ).take
24
18
 
25
19
  # Calculate metrics from single query result
@@ -42,7 +36,7 @@ module RailsPulse
42
36
  period_type: @period_type,
43
37
  period_start: current_window_start..now
44
38
  )
45
- sparkline_query = sparkline_query.where(summarizable_id: @route.id) if @route
39
+ sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
46
40
 
47
41
  grouped_data = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
48
42
 
@@ -83,12 +77,6 @@ module RailsPulse
83
77
  help_text: "Total HTTP requests served over the last 14 days, expressed as an average rate. Use this to understand traffic patterns and capacity planning needs."
84
78
  }
85
79
  end
86
-
87
- private
88
-
89
- def subject_id
90
- @route&.id
91
- end
92
80
  end
93
81
  end
94
82
  end
@@ -30,7 +30,7 @@ module RailsPulse
30
30
  # Pad missing data with defaults
31
31
  default_value = { error_rate: nil, client_error_rate: nil }
32
32
  daily_data = {}
33
- (@start_time.to_i..@end_time.to_i).step(time_step) do |timestamp|
33
+ (@window.start_time.to_i..@window.end_time.to_i).step(time_step) do |timestamp|
34
34
  daily_data[timestamp] = raw_data[timestamp] || default_value
35
35
  end
36
36
 
@@ -22,7 +22,7 @@ module RailsPulse
22
22
  end
23
23
 
24
24
  # Pad missing data with zeros
25
- daily_data = pad_data_with_zeros(raw_data, @start_time, @end_time, time_step)
25
+ daily_data = pad_data_with_zeros(raw_data, @window.start_time, @window.end_time, time_step)
26
26
 
27
27
  # Build series data as [timestamp_ms, value] pairs for ECharts time axis
28
28
  series = [ {
@@ -171,7 +171,11 @@ module RailsPulse
171
171
  end
172
172
 
173
173
  class << self
174
+ # A Date is read as midnight in Time.zone, the aggregation zone, so
175
+ # boundaries are always times: Date#end_of_week is the last day itself,
176
+ # and a period ending there would drop that whole day.
174
177
  def calculate_period_end(period_type, start_time)
178
+ start_time = start_time.in_time_zone if start_time.is_a?(Date)
175
179
  case period_type
176
180
  when "hour" then start_time.end_of_hour
177
181
  when "day" then start_time.end_of_day
@@ -181,6 +185,7 @@ module RailsPulse
181
185
  end
182
186
 
183
187
  def normalize_period_start(period_type, time)
188
+ time = time.in_time_zone if time.is_a?(Date)
184
189
  case period_type
185
190
  when "hour" then time.beginning_of_hour
186
191
  when "day" then time.beginning_of_day
@@ -4,10 +4,9 @@ module RailsPulse
4
4
  WEIGHTED_P95 = "SUM(rails_pulse_summaries.p95_duration * rails_pulse_summaries.count) / NULLIF(SUM(rails_pulse_summaries.count), 0)"
5
5
  WEIGHTED_P99 = "SUM(rails_pulse_summaries.p99_duration * rails_pulse_summaries.count) / NULLIF(SUM(rails_pulse_summaries.count), 0)"
6
6
 
7
- def initialize(ransack_query:, period_type: nil, start_time:, params:, disabled_tags: [], show_non_tagged: true)
7
+ def initialize(ransack_query:, period_type: nil, params:, disabled_tags: [], show_non_tagged: true)
8
8
  @ransack_query = ransack_query
9
9
  @period_type = period_type
10
- @start_time = start_time
11
10
  @params = params
12
11
  @disabled_tags = disabled_tags
13
12
  @show_non_tagged = show_non_tagged
@@ -41,11 +40,11 @@ module RailsPulse
41
40
  def apply_tag_filters(query)
42
41
  actual_disabled_tags = @disabled_tags.reject { |tag| tag == "non_tagged" }
43
42
 
44
- # Explicit ESCAPE: SQLite has no default escape character, so without
45
- # it an underscore in a tag is never matched literally there.
46
43
  actual_disabled_tags.each do |tag|
47
- sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, RailsPulse::Taggable::LIKE_ESCAPE)
48
- query = query.where.not("#{model_table}.tags LIKE ? ESCAPE '!'", "%#{sanitized_tag}%")
44
+ query = query.where.not(
45
+ "#{model_table}.tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}",
46
+ RailsPulse::LikePattern.containing(tag)
47
+ )
49
48
  end
50
49
 
51
50
  unless @show_non_tagged
@@ -0,0 +1,309 @@
1
+ module RailsPulse
2
+ # Resolves the dashboard's time range, chart-zoom/table window, and
3
+ # duration-threshold filter from request params and session state, in one
4
+ # pass. Returns a single frozen Result so every caller — controllers,
5
+ # views, cards, charts — reads the same object instead of each re-deriving
6
+ # its own answer.
7
+ #
8
+ # Priority order for the chart/page window (highest first):
9
+ # 1. Page-specific preset from the dropdown (params[:q][:period_start_range])
10
+ # 2. Page-specific custom range from the picker (params[:q][:custom_date_range])
11
+ # 3. Chart zoom / drill-down params (params[:q][:occurred_at_gteq/_lt])
12
+ # 4. Time range selector preference (session[:time_range_preference])
13
+ # 5. Global filters (session[:global_filters]["start_time"/"end_time"])
14
+ # 6. Default (default_key)
15
+ #
16
+ # Every input is parsed in Time.zone, matching how Summary rows are
17
+ # bucketed — parsing a custom-range string in any other zone would
18
+ # misinterpret the intended instant whenever that zone differs from
19
+ # Time.zone, silently resolving to the wrong window.
20
+ class TimeRange
21
+ Result = Struct.new(
22
+ :window, :table_window, :period_type, :selected_time_range,
23
+ :zoom_start, :zoom_end, :start_duration, :selected_response_range,
24
+ keyword_init: true
25
+ )
26
+
27
+ PRESET_START = {
28
+ "last_24_hours" => -> { 1.day.ago },
29
+ "last_7_days" => -> { 1.week.ago },
30
+ "last_14_days" => -> { 2.weeks.ago },
31
+ "last_30_days" => -> { 1.month.ago }
32
+ }.freeze
33
+
34
+ # Human-readable label for the zone daily/weekly/monthly summaries are
35
+ # bucketed in and custom ranges are rounded in (see class comment above)
36
+ # — the host app's config.time_zone, not the browser's zone. "UTC" or,
37
+ # e.g., "Eastern Time (US & Canada) (UTC-04:00)".
38
+ #
39
+ # This reads the Time.zone of the current dashboard request and assumes
40
+ # it is the zone SummaryJob ran under. That holds when both use
41
+ # config.time_zone (decision 0020). A host that overrides Time.zone per
42
+ # request for the dashboard as well would see a label that names the
43
+ # display zone while the daily buckets were still cut in the job's zone.
44
+ def self.aggregation_zone_label
45
+ return "UTC" if utc_zone?
46
+ "#{Time.zone.name} (UTC#{Time.zone.now.formatted_offset})"
47
+ end
48
+
49
+ # Compact form of aggregation_zone_label for chart headings and tooltip
50
+ # suffixes, where the full name would crowd out the data: "UTC", the
51
+ # zone's abbreviation when it has an alphabetic one ("EDT"), otherwise
52
+ # the offset ("UTC+05:45").
53
+ def self.aggregation_zone_short_label
54
+ return "UTC" if utc_zone?
55
+ abbreviation = Time.zone.now.zone
56
+ abbreviation.match?(/\A[A-Z]{2,5}\z/) ? abbreviation : "UTC#{Time.zone.now.formatted_offset}"
57
+ end
58
+
59
+ # IANA identifier for the same zone, for the chart JS (Intl.DateTimeFormat)
60
+ # to render daily labels in the zone summaries were actually bucketed in
61
+ # instead of the browser's zone.
62
+ def self.aggregation_zone_iana
63
+ Time.zone.tzinfo.name
64
+ end
65
+
66
+ def self.utc_zone?
67
+ Time.zone.utc_offset.zero? && Time.zone.now.zone == "UTC"
68
+ end
69
+ private_class_method :utc_zone?
70
+
71
+ def self.resolve(params:, session:, default_key: :last_24_hours, duration_range_type: :route)
72
+ new(params: params, session: session, default_key: default_key, duration_range_type: duration_range_type).resolve
73
+ end
74
+
75
+ def initialize(params:, session:, default_key:, duration_range_type:)
76
+ @params = params
77
+ @session = session
78
+ @default_key = default_key
79
+ @duration_range_type = duration_range_type
80
+ end
81
+
82
+ def resolve
83
+ start_time, end_time, selected_time_range = resolve_window
84
+ start_time, end_time, time_diff_hours = normalize(start_time, end_time)
85
+ period_type = time_diff_hours <= 25 ? "hour" : "day"
86
+
87
+ zoom_start, zoom_end, table_start, table_end = resolve_zoom(start_time.to_i, end_time.to_i)
88
+ start_duration, selected_response_range = resolve_duration
89
+
90
+ Result.new(
91
+ window: RailsPulse::TimeWindow.new(start_time, end_time),
92
+ table_window: RailsPulse::TimeWindow.new(table_start, table_end),
93
+ period_type: period_type,
94
+ selected_time_range: selected_time_range.to_s,
95
+ zoom_start: zoom_start,
96
+ zoom_end: zoom_end,
97
+ start_duration: start_duration,
98
+ selected_response_range: selected_response_range
99
+ ).freeze
100
+ end
101
+
102
+ private
103
+
104
+ attr_reader :params, :session, :default_key, :duration_range_type
105
+
106
+ # -- Window resolution (priorities 1-6) ----------------------------------
107
+
108
+ def resolve_window
109
+ from_page_preset || from_page_custom_range || from_chart_zoom ||
110
+ from_session_custom || from_session_preset || from_global_filters ||
111
+ default_window
112
+ end
113
+
114
+ def default_window
115
+ [ preset_start(default_key), Time.zone.now, default_key ]
116
+ end
117
+
118
+ def preset_start(key)
119
+ (PRESET_START[key.to_s] || PRESET_START["last_24_hours"]).call
120
+ end
121
+
122
+ # Priority 1: page-specific preset from the dropdown.
123
+ def from_page_preset
124
+ range = ransack_params[:period_start_range]
125
+ return nil unless range.present? && range.to_sym != :custom
126
+
127
+ [ preset_start(range), Time.zone.now, range ]
128
+ end
129
+
130
+ # Priority 2: page-specific custom datetime range from the picker (only
131
+ # when period_start_range is explicitly "custom").
132
+ def from_page_custom_range
133
+ return nil unless ransack_params[:period_start_range].present? &&
134
+ ransack_params[:period_start_range].to_sym == :custom &&
135
+ ransack_params[:custom_date_range].present? &&
136
+ ransack_params[:custom_date_range].include?(" to ")
137
+
138
+ dates = ransack_params[:custom_date_range].split(" to ")
139
+ custom_start = parse_time_param(dates[0].strip)
140
+ custom_end = parse_time_param(dates[1].strip)
141
+ return nil unless custom_start && custom_end
142
+
143
+ [ custom_start, custom_end, :custom ]
144
+ end
145
+
146
+ # Priority 3: custom time range from chart zoom.
147
+ def from_chart_zoom
148
+ return nil unless ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
149
+
150
+ zoom_start = parse_time_param(ransack_params[:occurred_at_gteq])
151
+ zoom_end = parse_time_param(ransack_params[:occurred_at_lt])
152
+ return nil unless zoom_start && zoom_end
153
+
154
+ [ zoom_start, zoom_end, :custom ]
155
+ end
156
+
157
+ # Priority 4a: custom range from the time range selector (session).
158
+ def from_session_custom
159
+ preference = session_time_range_preference
160
+ return nil unless RailsPulse::TimeRangePreference.custom?(preference)
161
+
162
+ start_time = parse_time_param(preference["start_time"]) || preset_start(default_key)
163
+ end_time = parse_time_param(preference["end_time"]) || Time.zone.now
164
+ [ start_time, end_time, :custom ]
165
+ end
166
+
167
+ # Priority 4b: preset from the time range selector (session).
168
+ def from_session_preset
169
+ preference = session_time_range_preference
170
+ return nil unless preference.is_a?(String) && preference.present?
171
+
172
+ [ preset_start(preference), Time.zone.now, preference.to_sym ]
173
+ end
174
+
175
+ # Priority 5: global filters (session).
176
+ def from_global_filters
177
+ filters = session_global_filters
178
+ return nil unless filters["start_time"].present? || filters["end_time"].present?
179
+
180
+ start_time = parse_time_param(filters["start_time"]) || preset_start(default_key)
181
+ end_time = parse_time_param(filters["end_time"]) || Time.zone.now
182
+ [ start_time, end_time, :custom ]
183
+ end
184
+
185
+ def ransack_params
186
+ q = params[:q]
187
+ q.respond_to?(:permit) ? q : ActionController::Parameters.new({})
188
+ end
189
+
190
+ def session_time_range_preference
191
+ RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
192
+ end
193
+
194
+ def session_global_filters
195
+ filters = session[:global_filters]
196
+ filters.is_a?(Hash) ? filters : {}
197
+ end
198
+
199
+ # Returns nil for anything that does not parse, so a hand-edited or stale
200
+ # value falls back to the default range instead of raising.
201
+ def parse_time_param(param)
202
+ case param
203
+ when Time, DateTime
204
+ param.in_time_zone
205
+ when String
206
+ return nil if param.blank?
207
+
208
+ Time.zone.parse(param)
209
+ when Numeric
210
+ Time.zone.at(param)
211
+ else
212
+ nil
213
+ end
214
+ rescue ArgumentError, TypeError, RangeError
215
+ nil
216
+ end
217
+
218
+ # Rounds to hour or day boundaries in Time.zone (summaries are always
219
+ # bucketed there) and returns the span in hours (float) alongside.
220
+ def normalize(start_time, end_time)
221
+ start_time = start_time.in_time_zone
222
+ end_time = end_time.in_time_zone
223
+ time_diff_hours = (end_time.to_i - start_time.to_i) / 3600.0
224
+
225
+ if time_diff_hours <= 25
226
+ [ start_time.beginning_of_hour, end_time.end_of_hour, time_diff_hours ]
227
+ else
228
+ [ start_time.beginning_of_day, end_time.end_of_day, time_diff_hours ]
229
+ end
230
+ end
231
+
232
+ # -- Zoom / table window --------------------------------------------------
233
+
234
+ def resolve_zoom(main_start, main_end)
235
+ selected_column_time = params[:selected_column_time]
236
+ zoom_start = params.delete(:zoom_start_time)
237
+ zoom_end = params.delete(:zoom_end_time)
238
+
239
+ if selected_column_time
240
+ table_start, table_end = normalize_column_time(selected_column_time.to_i, main_start, main_end)
241
+ # Column selection has highest precedence for the table only; the
242
+ # chart keeps showing the full range, so zoom_start/zoom_end are
243
+ # returned as extracted (not normalized) rather than derived here.
244
+ return [ zoom_start, zoom_end, table_start, table_end ]
245
+ end
246
+
247
+ zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i) if zoom_start && zoom_end
248
+
249
+ table_start = zoom_start || main_start
250
+ table_end = zoom_end || main_end
251
+
252
+ [ zoom_start, zoom_end, table_start, table_end ]
253
+ end
254
+
255
+ # column_time_ms is JS milliseconds; period boundary is based on the
256
+ # overall page range (main_start/main_end), same as the chart.
257
+ def normalize_column_time(column_time_ms, main_start, main_end)
258
+ column_time_seconds = column_time_ms / 1000
259
+ time_diff_hours = (main_end - main_start) / 3600.0
260
+ column_time_obj = Time.zone&.at(column_time_seconds) || Time.at(column_time_seconds)
261
+
262
+ if time_diff_hours <= 25
263
+ [ column_time_obj.beginning_of_hour.to_i, column_time_obj.end_of_hour.to_i ]
264
+ else
265
+ [ column_time_obj.beginning_of_day.to_i, column_time_obj.end_of_day.to_i ]
266
+ end
267
+ end
268
+
269
+ # start_ms/end_ms are JS milliseconds; period boundary is based on the
270
+ # zoomed span itself.
271
+ def normalize_zoom_times(start_ms, end_ms)
272
+ start_seconds = start_ms / 1000
273
+ end_seconds = end_ms / 1000
274
+ time_diff_hours = (end_seconds - start_seconds) / 3600.0
275
+ start_obj = Time.zone&.at(start_seconds) || Time.at(start_seconds)
276
+ end_obj = Time.zone&.at(end_seconds) || Time.at(end_seconds)
277
+
278
+ if time_diff_hours <= 25
279
+ [ start_obj.beginning_of_hour.to_i, end_obj.end_of_hour.to_i ]
280
+ else
281
+ [ start_obj.beginning_of_day.to_i, end_obj.end_of_day.to_i ]
282
+ end
283
+ end
284
+
285
+ # -- Duration threshold -----------------------------------------------------
286
+
287
+ def resolve_duration
288
+ thresholds = RailsPulse.configuration.public_send("#{duration_range_type}_thresholds")
289
+ duration_param = ransack_params[:avg_duration] || ransack_params[:duration] || ransack_params[:duration_gteq]
290
+
291
+ if duration_param.present?
292
+ [ threshold_for(thresholds, duration_param), duration_param ]
293
+ elsif (global_threshold = session_global_filters["performance_threshold"]).present?
294
+ [ threshold_for(thresholds, global_threshold), global_threshold.to_sym ]
295
+ else
296
+ [ 0, :all ]
297
+ end
298
+ end
299
+
300
+ def threshold_for(thresholds, key)
301
+ case key.to_sym
302
+ when :slow then thresholds[:slow]
303
+ when :very_slow then thresholds[:very_slow]
304
+ when :critical then thresholds[:critical]
305
+ else 0
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,86 @@
1
+ module RailsPulse
2
+ # The background writer's once-a-minute sample, stored as an Event of kind
3
+ # writer_heartbeat: `subject` is "host:pid", `value` the requests dropped
4
+ # since the previous sample, and `metadata` the queue depth, its configured
5
+ # capacity and the lifetime drop count. Stats are otherwise per process, so
6
+ # with eight Puma workers the dashboard could only ever see the one that
7
+ # happened to serve it; these rows let it, the Storage page and
8
+ # rails_pulse:status add every live writer up.
9
+ class WriterHeartbeat
10
+ KIND = "writer_heartbeat".freeze
11
+ # A writer that has not reported for this long is treated as gone.
12
+ LIVE_WINDOW = 3.minutes
13
+ RETENTION = 24.hours
14
+
15
+ Sample = Struct.new(:hostname, :pid, :queue_size, :queue_depth, :dropped_total, :sampled_at, keyword_init: true) do
16
+ def process_label
17
+ "#{hostname}:#{pid}"
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def events
23
+ RailsPulse::Event.of_kind(KIND)
24
+ end
25
+
26
+ def record!(hostname:, pid:, queue_size:, queue_depth:, dropped:, dropped_total:, sampled_at: Time.current)
27
+ RailsPulse::Event.insert_all([ {
28
+ kind: KIND,
29
+ subject: "#{hostname}:#{pid}",
30
+ outcome: "sampled",
31
+ value: dropped,
32
+ occurred_at: sampled_at,
33
+ metadata: { hostname: hostname, pid: pid, queue_size: queue_size, queue_depth: queue_depth, dropped_total: dropped_total }.to_json,
34
+ created_at: sampled_at,
35
+ updated_at: sampled_at
36
+ } ])
37
+ end
38
+
39
+ def prune!
40
+ events.where(occurred_at: ...RETENTION.ago).delete_all
41
+ end
42
+
43
+ # The latest sample from each writer that reported inside LIVE_WINDOW,
44
+ # newest first. A handful of rows per process, reduced in Ruby.
45
+ def live_processes
46
+ events.since(LIVE_WINDOW.ago).recent.to_a.uniq(&:subject).map { |event| sample_from(event) }
47
+ end
48
+
49
+ # { "host:pid" => requests dropped inside `window` }
50
+ def dropped_by_process(window: 1.hour)
51
+ events.since(window.ago).group(:subject).sum(:value).transform_values(&:to_i)
52
+ end
53
+
54
+ # What the dashboard, Storage page and status task show:
55
+ # processes live writers
56
+ # queue_depth requests waiting across them, from each one's latest sample
57
+ # queue_size the largest configured capacity among them
58
+ # dropped requests dropped inside `window`, across every process
59
+ # last_sampled_at most recent heartbeat, nil when there is none
60
+ def summary(window: 1.hour)
61
+ processes = live_processes
62
+ {
63
+ processes: processes.size,
64
+ queue_depth: processes.sum(&:queue_depth),
65
+ queue_size: processes.map(&:queue_size).max || 0,
66
+ dropped: events.since(window.ago).sum(:value).to_i,
67
+ last_sampled_at: events.maximum(:occurred_at)
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def sample_from(event)
74
+ meta = event.metadata_hash
75
+ Sample.new(
76
+ hostname: meta["hostname"],
77
+ pid: meta["pid"],
78
+ queue_size: meta["queue_size"].to_i,
79
+ queue_depth: meta["queue_depth"].to_i,
80
+ dropped_total: meta["dropped_total"].to_i,
81
+ sampled_at: event.occurred_at
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,20 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class DeploymentSerializer
5
+ def self.serialize(deployment)
6
+ {
7
+ id: deployment.id,
8
+ revision: deployment.revision,
9
+ short_revision: deployment.short_revision,
10
+ started_at: deployment.started_at,
11
+ finished_at: deployment.finished_at,
12
+ duration_seconds: deployment.duration&.round(1),
13
+ in_progress: deployment.in_progress?,
14
+ metadata: deployment.metadata_hash
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class ExceptionGroupSerializer
5
+ def self.serialize(group)
6
+ {
7
+ id: group.id,
8
+ fingerprint: group.fingerprint,
9
+ exception_class: group.exception_class,
10
+ location: group.location,
11
+ message: group.message,
12
+ status: group.status,
13
+ occurrence_count: group.occurrence_count,
14
+ first_seen_at: group.first_seen_at,
15
+ last_seen_at: group.last_seen_at,
16
+ resolved_at: group.resolved_at,
17
+ preserve: group.preserve
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class ExceptionOccurrenceSerializer
5
+ def self.serialize(occurrence)
6
+ {
7
+ id: occurrence.id,
8
+ exception_class: occurrence.exception_class,
9
+ message: occurrence.message,
10
+ occurred_at: occurrence.occurred_at,
11
+ request_method: occurrence.request_method,
12
+ request_url: occurrence.request_url,
13
+ request_params: occurrence.request_params.presence,
14
+ environment: occurrence.environment,
15
+ deploy_sha: occurrence.deploy_sha,
16
+ backtrace: Array(occurrence.backtrace)
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end