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
@@ -71,6 +71,13 @@ module RailsPulse
71
71
  label: "Deployments",
72
72
  description: "Deploy markers shown on charts",
73
73
  time_column: :started_at
74
+ },
75
+ {
76
+ name: :rails_pulse_events,
77
+ model: "RailsPulse::Event",
78
+ label: "Events",
79
+ description: "What Pulse noticed: writer heartbeats every minute, pruned after a day; any other kind is kept for event_retention_period",
80
+ time_column: :occurred_at
74
81
  }
75
82
  ].freeze
76
83
 
@@ -137,6 +144,35 @@ module RailsPulse
137
144
  }
138
145
  end
139
146
 
147
+ # Built from live_processes/dropped_by_process rather than
148
+ # WriterHeartbeat.summary, which would recompute both internally.
149
+ def tracking
150
+ @tracking ||= begin
151
+ live = RailsPulse::WriterHeartbeat.live_processes
152
+ dropped_by_process = RailsPulse::WriterHeartbeat.dropped_by_process(window: 1.hour)
153
+ processes = live.map do |process|
154
+ {
155
+ label: process.process_label,
156
+ queue_depth: process.queue_depth,
157
+ queue_size: process.queue_size,
158
+ dropped_last_hour: dropped_by_process.fetch(process.process_label, 0),
159
+ last_seen_at: process.sampled_at
160
+ }
161
+ end
162
+
163
+ {
164
+ processes: processes,
165
+ live_count: processes.size,
166
+ queue_depth: live.sum(&:queue_depth),
167
+ queue_size: live.map(&:queue_size).max || 0,
168
+ dropped: dropped_by_process.values.sum,
169
+ last_sampled_at: RailsPulse::WriterHeartbeat.events.maximum(:occurred_at)
170
+ }
171
+ end
172
+ rescue ActiveRecord::ActiveRecordError
173
+ { processes: [], live_count: 0, queue_depth: 0, queue_size: 0, dropped: 0, last_sampled_at: nil }
174
+ end
175
+
140
176
  def dashboard_tables
141
177
  capped = tables.select { |table| table[:limit] }
142
178
  return tables.first(4) if capped.empty?
@@ -1,5 +1,7 @@
1
1
  module RailsPulse
2
2
  class Deployment < RailsPulse::ApplicationRecord
3
+ include HasMetadata
4
+
3
5
  self.table_name = "rails_pulse_deployments"
4
6
 
5
7
  # The create endpoint is reachable with a CI token, so every field a
@@ -52,13 +54,6 @@ module RailsPulse
52
54
  finished_at - started_at
53
55
  end
54
56
 
55
- def metadata_hash
56
- return {} if metadata.blank?
57
- JSON.parse(metadata)
58
- rescue JSON::ParserError
59
- {}
60
- end
61
-
62
57
  def to_chart_marker
63
58
  { timestamp: started_at.to_i * 1000, revision: revision, started_at: started_at.iso8601 }
64
59
  end
@@ -0,0 +1,40 @@
1
+ module RailsPulse
2
+ # What Rails Pulse noticed, as opposed to what it measured. One row per
3
+ # outcome or sample, tagged by `kind`; `subject` names what it is about,
4
+ # `value` carries its number and `metadata` the kind-specific detail as JSON.
5
+ #
6
+ # The one kind written today is writer_heartbeat (see WriterHeartbeat). The
7
+ # table is generic so a new kind needs no migration. Decision 0019.
8
+ #
9
+ # CleanupService deletes rows older than config.event_retention_period,
10
+ # except kinds listed in config.event_retention_exempt_kinds (rows a writer
11
+ # updates in place). Writer heartbeats are pruned after a day by the writer
12
+ # itself.
13
+ class Event < RailsPulse::ApplicationRecord
14
+ include HasMetadata
15
+
16
+ self.table_name = "rails_pulse_events"
17
+
18
+ validates :kind, :outcome, :occurred_at, presence: true
19
+
20
+ scope :of_kind, ->(*kinds) { where(kind: kinds.flatten.map(&:to_s)) }
21
+ scope :for_subject, ->(subject) { where(subject: subject) }
22
+ scope :since, ->(time) { where(occurred_at: time..) }
23
+ scope :recent, -> { order(occurred_at: :desc) }
24
+
25
+ def self.ransackable_attributes(_auth_object = nil)
26
+ %w[kind subject outcome value occurred_at message created_at updated_at]
27
+ end
28
+
29
+ def self.ransackable_associations(_auth_object = nil)
30
+ []
31
+ end
32
+
33
+ # table_exists?, rescued — for an upgrader who hasn't migrated yet
34
+ def self.table_available?
35
+ table_exists?
36
+ rescue ActiveRecord::ActiveRecordError
37
+ false
38
+ end
39
+ end
40
+ end
@@ -21,7 +21,7 @@ module RailsPulse
21
21
  end
22
22
 
23
23
  def to_breadcrumb
24
- occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
24
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
25
25
  end
26
26
  end
27
27
  end
@@ -16,10 +16,7 @@ module RailsPulse
16
16
  PER = 1_000
17
17
 
18
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
19
+ super
23
20
  end
24
21
 
25
22
  def to_metric_card
@@ -63,8 +60,8 @@ module RailsPulse
63
60
  .where(period_type: @period_type, period_start: range_start..now)
64
61
  .select(
65
62
  "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"
63
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
64
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
68
65
  )
69
66
  .take
70
67
 
@@ -9,10 +9,7 @@ module RailsPulse
9
9
  # you about the other.
10
10
  class OpenGroups < RailsPulse::Cards::Base
11
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
12
+ super
16
13
  end
17
14
 
18
15
  def to_metric_card
@@ -11,10 +11,7 @@ module RailsPulse
11
11
  SUMMARIZABLE_TYPE = "RailsPulse::ExceptionGroup".freeze
12
12
 
13
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
14
+ super
18
15
  end
19
16
 
20
17
  def to_metric_card
@@ -24,8 +21,8 @@ module RailsPulse
24
21
 
25
22
  metrics = base_query.select(
26
23
  "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"
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
29
26
  ).take
30
27
 
31
28
  total = metrics&.total_count.to_i
@@ -8,16 +8,15 @@ module RailsPulse
8
8
  # silently flattens toward zero the further back you look. Summaries are
9
9
  # retained at day granularity indefinitely.
10
10
  class OccurrenceVolume
11
- def initialize(start_time:, end_time:, period_type: "day")
12
- @start_time = start_time
13
- @end_time = end_time
11
+ def initialize(window:, period_type: "day")
12
+ @window = window
14
13
  @period_type = period_type
15
14
  end
16
15
 
17
16
  def to_chart_data
18
17
  totals = RailsPulse::Summary
19
18
  .overall_exceptions
20
- .where(period_type: @period_type, period_start: @start_time..@end_time)
19
+ .where(period_type: @period_type, period_start: @window.start_time..@window.end_time)
21
20
  .group(:period_start)
22
21
  .sum(:count)
23
22
 
@@ -45,7 +44,7 @@ module RailsPulse
45
44
  step = @period_type.to_s == "hour" ? 3600 : 86_400
46
45
 
47
46
  {}.tap do |padded|
48
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
47
+ (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
49
48
  padded[timestamp] = raw[timestamp] || 0
50
49
  end
51
50
  end
@@ -40,7 +40,7 @@ module RailsPulse
40
40
  after_commit :apply_to_job_caches, on: %i[create update], if: :finalized?
41
41
 
42
42
  def to_breadcrumb
43
- occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
43
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
44
44
  end
45
45
 
46
46
  def all_tags
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class FailureRate < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
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(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,15 +14,15 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "SUM(rails_pulse_summaries.count) AS total_count",
27
21
  "SUM(rails_pulse_summaries.error_count) AS total_errors",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
29
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.error_count ELSE 0 END) AS current_errors",
30
- "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",
31
- "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.error_count ELSE 0 END) AS previous_errors"
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
23
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS current_errors"),
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS previous_errors")
32
26
  ).take
33
27
 
34
28
  total_runs = metrics&.total_count.to_i
@@ -52,7 +46,7 @@ module RailsPulse
52
46
  period_type: @period_type,
53
47
  period_start: current_window_start..now
54
48
  )
55
- sparkline_query = sparkline_query.where(summarizable_id: @job.id) if @job
49
+ sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
56
50
 
57
51
  grouped_errors = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.error_count") }
58
52
  grouped_counts = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.count") }
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class P95Duration < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
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(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,15 +14,15 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "SUM(rails_pulse_summaries.p95_duration * rails_pulse_summaries.count) AS total_weighted_p95",
27
21
  "SUM(rails_pulse_summaries.count) AS total_runs",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS current_weighted_p95",
29
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_runs",
30
- "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.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS previous_weighted_p95",
31
- "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_runs"
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS current_weighted_p95"),
23
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_runs"),
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS previous_weighted_p95"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_runs")
32
26
  ).take
33
27
 
34
28
  total_runs = metrics&.total_runs.to_i
@@ -42,7 +36,7 @@ module RailsPulse
42
36
  current_p95 = weighted_average(current_weighted_p95, current_runs)
43
37
  previous_p95 = weighted_average(previous_weighted_p95, previous_runs)
44
38
 
45
- baseline = baseline_trend_for(@job, metric: :p95)
39
+ baseline = baseline_trend_for(@subject, metric: :p95)
46
40
 
47
41
  if baseline
48
42
  trend_icon, trend_amount, trend_caption = baseline
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class TotalRuns < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
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(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,12 +14,12 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "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"
21
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
29
23
  ).take
30
24
 
31
25
  total_runs = metrics&.total_count.to_i
@@ -33,7 +33,7 @@ module RailsPulse
33
33
  # Convert to final values (weighted averages) and pad missing data
34
34
  step = time_step
35
35
  daily_data = {}
36
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
36
+ (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
37
37
  if raw_data[timestamp]
38
38
  count = raw_data[timestamp][:total_count]
39
39
  daily_data[timestamp] = {
@@ -21,7 +21,7 @@ module RailsPulse
21
21
  end
22
22
 
23
23
  # Pad missing data with zeros using base class helper
24
- daily_data = pad_data_with_zeros(raw_data, @start_time, @end_time, time_step)
24
+ daily_data = pad_data_with_zeros(raw_data, @window.start_time, @window.end_time, time_step)
25
25
 
26
26
  # Build series data as [timestamp_ms, value] pairs for ECharts time axis
27
27
  series = [ {
@@ -25,7 +25,7 @@ module RailsPulse
25
25
  # Pad missing data with zeros
26
26
  step = time_step
27
27
  daily_data = {}
28
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
28
+ (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
29
29
  daily_data[timestamp] = raw_data[timestamp] || nil
30
30
  end
31
31
 
@@ -0,0 +1,29 @@
1
+ module RailsPulse
2
+ # Builds LIKE patterns whose `_`, `%` and escape characters match literally.
3
+ #
4
+ # Every pattern built here must be used with CLAUSE appended to the LIKE
5
+ # expression. SQLite has no default escape character, so `foo!_bar` without an
6
+ # explicit ESCAPE matches a literal `!` while the `_` still wildcards, and the
7
+ # comparison silently matches nothing. `!` is the escape character rather than
8
+ # `\` because MySQL and PostgreSQL disagree on how a backslash is quoted.
9
+ module LikePattern
10
+ ESCAPE_CHARACTER = "!".freeze
11
+
12
+ # Interpolated directly into SQL strings, so it must stay a literal.
13
+ CLAUSE = "ESCAPE '#{ESCAPE_CHARACTER}'".freeze
14
+
15
+ module_function
16
+
17
+ # @param value [#to_s] user-supplied text to match anywhere in a column
18
+ # @return [String] escaped pattern wrapped in leading and trailing wildcards
19
+ def containing(value)
20
+ "%#{escape(value)}%"
21
+ end
22
+
23
+ # @param value [#to_s] user-supplied text
24
+ # @return [String] the text with LIKE metacharacters escaped
25
+ def escape(value)
26
+ ActiveRecord::Base.sanitize_sql_like(value.to_s, ESCAPE_CHARACTER)
27
+ end
28
+ end
29
+ end
@@ -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,9 +2,8 @@ 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
@@ -19,7 +18,7 @@ module RailsPulse
19
18
  .where(
20
19
  summarizable_type: "RailsPulse::Query",
21
20
  period_type: @period_type,
22
- period_start: Time.at(@start_time)..Time.at(@end_time)
21
+ period_start: @window.start_time..@window.end_time
23
22
  )
24
23
 
25
24
  # Get route summaries (total request time)
@@ -28,7 +27,7 @@ module RailsPulse
28
27
  .where(
29
28
  summarizable_type: "RailsPulse::Route",
30
29
  period_type: @period_type,
31
- period_start: Time.at(@start_time)..Time.at(@end_time)
30
+ period_start: @window.start_time..@window.end_time
32
31
  )
33
32
 
34
33
  return nil if query_summaries.empty? || route_summaries.empty?
@@ -52,7 +51,7 @@ module RailsPulse
52
51
  # <25% = green (healthy), 25-40% = yellow (watch), >40% = red (bottleneck)
53
52
  bar_data = []
54
53
 
55
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
54
+ (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
56
55
  query_time = query_time_by_period[timestamp] || 0
57
56
  request_time = request_time_by_period[timestamp] || 0
58
57
  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, @window.start_time, @window.end_time, time_step)
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