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
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class AverageQueryTimes < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @query = query
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(query: nil, **kwargs)
6
+ super(subject: query, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -19,10 +13,10 @@ module RailsPulse
19
13
  metrics = base_query.select(
20
14
  "SUM(avg_duration * count) AS total_weighted_duration",
21
15
  "SUM(count) AS total_requests",
22
- "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS current_weighted_duration",
23
- "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_requests",
24
- "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS previous_weighted_duration",
25
- "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_requests"
16
+ window_sql("SUM(CASE WHEN period_start >= :current_start THEN avg_duration * count ELSE 0 END) AS current_weighted_duration"),
17
+ window_sql("SUM(CASE WHEN period_start >= :current_start THEN count ELSE 0 END) AS current_requests"),
18
+ window_sql("SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN avg_duration * count ELSE 0 END) AS previous_weighted_duration"),
19
+ window_sql("SUM(CASE WHEN period_start >= :range_start AND period_start < :current_start THEN count ELSE 0 END) AS previous_requests")
26
20
  ).take
27
21
 
28
22
  # Calculate metrics from single query result
@@ -2,15 +2,6 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class DatabaseLoad < RailsPulse::Cards::Base
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @disabled_tags = disabled_tags
7
- @show_non_tagged = show_non_tagged
8
- @period = period
9
- @period_type = period_type
10
- @start_time = start_time
11
- @end_time = end_time
12
- end
13
-
14
5
  def to_metric_card
15
6
  # Use base class time period helpers
16
7
  last_n_units = current_window_start
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class ExecutionRate < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @query = query
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(query: nil, **kwargs)
6
+ super(subject: query, **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
@@ -38,7 +32,7 @@ module RailsPulse
38
32
  period_type: @period_type,
39
33
  period_start: current_window_start..now
40
34
  )
41
- sparkline_query = sparkline_query.where(summarizable_id: @query.id) if @query
35
+ sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
42
36
 
43
37
  grouped_data = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
44
38
 
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class PercentileQueryTimes < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
- @query = query
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(query: nil, **kwargs)
6
+ super(subject: query, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -19,8 +13,8 @@ module RailsPulse
19
13
  metrics = base_query.select(
20
14
  "SUM(p95_duration * count) / NULLIF(SUM(count), 0) AS overall_p95",
21
15
  "SUM(count) AS total_count",
22
- "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",
23
- "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"
16
+ 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"),
17
+ 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")
24
18
  ).take
25
19
 
26
20
  # Calculate metrics from single query result
@@ -28,7 +22,7 @@ module RailsPulse
28
22
  current_period_p95 = metrics.current_p95 || 0
29
23
  previous_period_p95 = metrics.previous_p95 || 0
30
24
 
31
- baseline = baseline_trend_for(@query, metric: :p95)
25
+ baseline = baseline_trend_for(@subject, metric: :p95)
32
26
 
33
27
  if baseline
34
28
  trend_icon, trend_amount, trend_caption = baseline
@@ -44,7 +38,7 @@ module RailsPulse
44
38
  period_type: @period_type,
45
39
  period_start: current_window_start..now
46
40
  )
47
- sparkline_query = sparkline_query.where(summarizable_id: @query.id) if @query
41
+ sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
48
42
 
49
43
  weighted_sums = bucket_by_period(sparkline_query) { |relation| relation.sum("p95_duration * count") }
50
44
  period_counts = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
@@ -2,24 +2,21 @@ module RailsPulse
2
2
  module Queries
3
3
  module Charts
4
4
  class DatabaseLoad
5
- def initialize(start_time:, end_time:, period_type: :day, disabled_tags: [], show_non_tagged: true)
6
- @start_time = start_time
7
- @end_time = end_time
5
+ def initialize(window:, period_type: :day, disabled_tags: [], show_non_tagged: true)
6
+ @window = window
8
7
  @period_type = period_type
9
8
  @disabled_tags = disabled_tags
10
9
  @show_non_tagged = show_non_tagged
11
10
  end
12
11
 
13
12
  def to_chart_data
14
- step = @period_type.to_s == "hour" ? 3600 : 86400
15
-
16
13
  # Get query summaries (DB time)
17
14
  query_summaries = RailsPulse::Summary
18
15
  .with_tag_filters(@disabled_tags, @show_non_tagged)
19
16
  .where(
20
17
  summarizable_type: "RailsPulse::Query",
21
18
  period_type: @period_type,
22
- period_start: Time.at(@start_time)..Time.at(@end_time)
19
+ period_start: @window.start_time..@window.end_time
23
20
  )
24
21
 
25
22
  # Get route summaries (total request time)
@@ -28,7 +25,7 @@ module RailsPulse
28
25
  .where(
29
26
  summarizable_type: "RailsPulse::Route",
30
27
  period_type: @period_type,
31
- period_start: Time.at(@start_time)..Time.at(@end_time)
28
+ period_start: @window.start_time..@window.end_time
32
29
  )
33
30
 
34
31
  return nil if query_summaries.empty? || route_summaries.empty?
@@ -52,7 +49,7 @@ module RailsPulse
52
49
  # <25% = green (healthy), 25-40% = yellow (watch), >40% = red (bottleneck)
53
50
  bar_data = []
54
51
 
55
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
52
+ @window.bucket_timestamps(@period_type).each do |timestamp|
56
53
  query_time = query_time_by_period[timestamp] || 0
57
54
  request_time = request_time_by_period[timestamp] || 0
58
55
  percentage = request_time > 0 ? (query_time.to_f / request_time * 100).round(1) : 0
@@ -18,7 +18,7 @@ module RailsPulse
18
18
  end
19
19
 
20
20
  # Pad missing data with zeros using base class helper
21
- execution_data = pad_data_with_zeros(raw_data, @start_time, @end_time, time_step)
21
+ execution_data = pad_data_with_zeros(raw_data)
22
22
 
23
23
  # Build series data as [timestamp_ms, value] pairs for ECharts time axis
24
24
  series = [ {
@@ -66,7 +66,7 @@ module RailsPulse
66
66
  end
67
67
 
68
68
  def to_s
69
- occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
69
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
70
70
  end
71
71
 
72
72
  private
@@ -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.bucket_timestamps(@period_type).each 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)
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