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
@@ -0,0 +1,28 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class JobRunSerializer
5
+ ERROR_MESSAGE_LIMIT = 500
6
+
7
+ def self.serialize(run)
8
+ {
9
+ id: run.id,
10
+ job_id: run.job_id,
11
+ job_name: run.job&.name,
12
+ queue_name: run.job&.queue_name,
13
+ run_id: run.run_id,
14
+ status: run.status,
15
+ occurred_at: run.occurred_at,
16
+ enqueued_at: run.enqueued_at,
17
+ duration: run.duration,
18
+ attempts: run.attempts,
19
+ adapter: run.adapter,
20
+ error_class: run.error_class,
21
+ error_message: run.error_message&.truncate(ERROR_MESSAGE_LIMIT),
22
+ tags: run.tags
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class JobSerializer
5
+ # Without `stats` the counters are the job's lifetime totals, cached on
6
+ # the row. With it they cover one window and come from summaries, so
7
+ # the two are kept in separate keys rather than one set of names
8
+ # meaning different things depending on the request.
9
+ def self.serialize(job, stats: nil)
10
+ {
11
+ id: job.id,
12
+ name: job.name,
13
+ queue_name: job.queue_name,
14
+ runs_count: job.runs_count,
15
+ failures_count: job.failures_count,
16
+ avg_duration: job.avg_duration,
17
+ p95_duration: job.p95_duration,
18
+ p99_duration: job.p99_duration,
19
+ failure_rate: job.failure_rate,
20
+ stats: stats
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class QuerySerializer
5
+ def self.serialize(query, stats: nil)
6
+ analysis = (query.n_plus_one_analysis || {}).transform_keys(&:to_s)
7
+
8
+ {
9
+ id: query.id,
10
+ normalized_sql: query.normalized_sql,
11
+ hashed_sql: query.hashed_sql,
12
+ analyzed_at: query.analyzed_at,
13
+ issues: query.issues,
14
+ suggestions: query.suggestions,
15
+ n_plus_one: {
16
+ likely: analysis["is_likely_n_plus_one"] == true,
17
+ confidence: analysis["confidence_score"]
18
+ },
19
+ stats: stats
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class RequestSerializer
5
+ def self.serialize(request)
6
+ {
7
+ id: request.id,
8
+ route_id: request.route_id,
9
+ occurred_at: request.occurred_at,
10
+ duration: request.duration,
11
+ status: request.status,
12
+ is_error: request.is_error,
13
+ request_uuid: request.request_uuid,
14
+ controller_action: request.controller_action,
15
+ response_size_bytes: request.response_size_bytes
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class RouteSerializer
5
+ def self.serialize(route, stats: nil)
6
+ {
7
+ id: route.id,
8
+ http_methods: route.http_methods_list,
9
+ path: route.path,
10
+ controller_action: route.controller_action,
11
+ tags: route.tag_list,
12
+ created_at: route.created_at,
13
+ stats: stats
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -55,7 +55,7 @@ module RailsPulse
55
55
 
56
56
  def capture
57
57
  return unless RailsPulse.configuration.track_exceptions
58
- return if RequestStore.store[:skip_recording_rails_pulse_activity]
58
+ return if RailsPulse::Current.skip_recording_rails_pulse_activity
59
59
 
60
60
  frames = parse_backtrace(@exception.backtrace || [])
61
61
  location = fingerprint_location(frames)
@@ -3,7 +3,7 @@ module RailsPulse
3
3
  # The result of measuring a recent window against a historical baseline.
4
4
  #
5
5
  # This is a value object on purpose. It is the shape callers outside this
6
- # namespace — dashboards today, findings and Pro tooling later — are expected
6
+ # namespace — dashboards today, findings and agent tooling later — are expected
7
7
  # to depend on, so it carries every number needed to explain the verdict
8
8
  # rather than just the verdict.
9
9
  class Comparison
@@ -0,0 +1,161 @@
1
+ module RailsPulse
2
+ class SummaryService
3
+ # Summary rows for a day, week or month, rolled up from the summaries of
4
+ # its child periods (hours for a day, days for a week or month) without
5
+ # reading raw rows.
6
+ #
7
+ # The database combines the children in one GROUP BY, so Ruby receives
8
+ # one row per summarizable however many child periods there are. Counts,
9
+ # totals, averages, min, max, status counts and the standard deviation
10
+ # combine exactly. P50/P95/P99 are the count-weighted average of the
11
+ # children's, the same weighting Tables::Base applies when a dashboard
12
+ # range spans several periods.
13
+ #
14
+ # Only plain SUM, MIN, MAX and arithmetic are used, so the query runs
15
+ # unchanged on SQLite (which has no STDDEV or, without its math
16
+ # extension, POWER), PostgreSQL and MySQL. Divisions happen in Ruby,
17
+ # where integer sums cannot truncate them.
18
+ #
19
+ # Rows carry the summarizable and its metrics; SummaryService adds the
20
+ # period columns.
21
+ class FromChildPeriods
22
+ TABLE = "rails_pulse_summaries".freeze
23
+ PERCENTILES = %i[p50_duration p95_duration p99_duration].freeze
24
+ STATUS_COLUMNS = %i[error_count success_count status_2xx status_3xx status_4xx status_5xx].freeze
25
+
26
+ AGGREGATES = {
27
+ count: "SUM(#{TABLE}.count)",
28
+ total_duration: "SUM(#{TABLE}.total_duration)",
29
+ min_duration: "MIN(#{TABLE}.min_duration)",
30
+ max_duration: "MAX(#{TABLE}.max_duration)",
31
+ **PERCENTILES.flat_map { |column|
32
+ [
33
+ [ :"#{column}_weighted", "SUM(#{TABLE}.#{column} * #{TABLE}.count)" ],
34
+ [ :"#{column}_weight", "SUM(CASE WHEN #{TABLE}.#{column} IS NOT NULL THEN #{TABLE}.count ELSE 0 END)" ]
35
+ ]
36
+ }.to_h,
37
+ # The three sums Statistics.pooled_stddev needs.
38
+ stddev_within_sum: "SUM((#{TABLE}.count - 1) * COALESCE(#{TABLE}.stddev_duration, 0) * COALESCE(#{TABLE}.stddev_duration, 0))",
39
+ weighted_mean_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration)",
40
+ weighted_square_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration * #{TABLE}.avg_duration)",
41
+ **STATUS_COLUMNS.to_h { |column| [ column, "SUM(#{TABLE}.#{column})" ] }
42
+ }.freeze
43
+
44
+ # `child_starts` are the boundaries the children must start on. Rows in
45
+ # the range that start elsewhere were written under a different
46
+ # aggregation time zone; they overlap these and would be counted twice.
47
+ def initialize(child_period_type, child_starts)
48
+ @child_period_type = child_period_type
49
+ @child_starts = child_starts
50
+ end
51
+
52
+ # Always exactly one row, even for an empty period (see SummaryService).
53
+ def request_rows
54
+ combined = combined_of("RailsPulse::Request")[0]
55
+
56
+ [ row("RailsPulse::Request", 0).merge(duration_metrics(combined), status_metrics(combined)) ]
57
+ end
58
+
59
+ def route_rows
60
+ combined_of("RailsPulse::Route").map do |route_id, combined|
61
+ row("RailsPulse::Route", route_id).merge(duration_metrics(combined), status_metrics(combined))
62
+ end
63
+ end
64
+
65
+ def query_rows
66
+ combined_of("RailsPulse::Query").map do |query_id, combined|
67
+ row("RailsPulse::Query", query_id).merge(duration_metrics(combined))
68
+ end
69
+ end
70
+
71
+ def job_rows
72
+ combined_by_job = combined_of("RailsPulse::Job")
73
+ return [] if combined_by_job.empty?
74
+
75
+ # A job whose row has gone (count-based cleanup) has nothing to
76
+ # summarize against.
77
+ known_job_ids = Job.where(id: combined_by_job.keys).pluck(:id).to_set
78
+
79
+ combined_by_job.filter_map do |job_id, combined|
80
+ next unless known_job_ids.include?(job_id)
81
+
82
+ row("RailsPulse::Job", job_id).merge(
83
+ duration_metrics(combined),
84
+ status_metrics(combined).slice(:error_count, :success_count)
85
+ )
86
+ end
87
+ end
88
+
89
+ # The children already carry the all-groups row (id 0), which sums like
90
+ # any other group.
91
+ def exception_rows
92
+ combined_of("RailsPulse::ExceptionGroup").map do |group_id, combined|
93
+ row("RailsPulse::ExceptionGroup", group_id).merge(count: combined[:count].to_i)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def row(summarizable_type, summarizable_id)
100
+ { summarizable_type: summarizable_type, summarizable_id: summarizable_id }
101
+ end
102
+
103
+ # { summarizable_type => { summarizable_id => { aggregate => value } } }.
104
+ # Empty children (an idle hour's count-0 heartbeat) are left out; they
105
+ # add nothing, and an idle period falls back to empty metrics.
106
+ def combined_children
107
+ @combined_children ||= Summary
108
+ .where(period_type: @child_period_type, period_start: @child_starts)
109
+ .where(count: 1..)
110
+ .group(:summarizable_type, :summarizable_id)
111
+ .pluck(:summarizable_type, :summarizable_id, *AGGREGATES.values.map { |sql| Arel.sql(sql) })
112
+ .each_with_object({}) do |(type, id, *values), by_type|
113
+ (by_type[type] ||= {})[id] = AGGREGATES.keys.zip(values).to_h
114
+ end
115
+ end
116
+
117
+ # { summarizable_id => combined } for one summarizable type.
118
+ def combined_of(summarizable_type)
119
+ combined_children.fetch(summarizable_type, {})
120
+ end
121
+
122
+ def duration_metrics(combined)
123
+ return Metrics.duration([]) unless combined
124
+
125
+ count = combined[:count].to_i
126
+ total = combined[:total_duration].to_f
127
+
128
+ {
129
+ count: count,
130
+ # Count-weighted mean of the children's averages: identical to
131
+ # total / count where every row has a duration (requests, routes,
132
+ # queries), and consistent with the children for jobs, whose count
133
+ # includes runs with no recorded duration (discarded runs) that
134
+ # total / count would dilute the average with.
135
+ avg_duration: combined[:weighted_mean_sum].to_f / count,
136
+ min_duration: combined[:min_duration]&.to_f,
137
+ max_duration: combined[:max_duration]&.to_f,
138
+ total_duration: total,
139
+ **PERCENTILES.to_h { |column| [ column, weighted_percentile(combined, column) ] },
140
+ stddev_duration: Statistics.pooled_stddev(
141
+ count: count,
142
+ within_sum: combined[:stddev_within_sum],
143
+ weighted_mean_sum: combined[:weighted_mean_sum],
144
+ weighted_square_sum: combined[:weighted_square_sum]
145
+ )
146
+ }
147
+ end
148
+
149
+ def weighted_percentile(combined, column)
150
+ weight = combined[:"#{column}_weight"].to_f
151
+ return nil if weight.zero?
152
+
153
+ combined[:"#{column}_weighted"].to_f / weight
154
+ end
155
+
156
+ def status_metrics(combined)
157
+ STATUS_COLUMNS.index_with { |column| combined ? combined[column].to_i : 0 }
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,115 @@
1
+ module RailsPulse
2
+ class SummaryService
3
+ # Summary rows for an hour, computed from raw requests, operations, job
4
+ # runs and exception occurrences.
5
+ #
6
+ # Each kind is read with one query, sorted by summarizable and then
7
+ # duration so every summarizable's durations arrive as one consecutive,
8
+ # already-sorted run; the overall request row and the per-route rows
9
+ # share a single read. Memory is bounded by one hour's rows, and the
10
+ # hour costs one query per kind however many routes, queries or jobs it
11
+ # saw. Percentiles are exact.
12
+ #
13
+ # Rows carry the summarizable and its metrics; SummaryService adds the
14
+ # period columns.
15
+ class FromRawRows
16
+ def initialize(time_range)
17
+ @time_range = time_range
18
+ end
19
+
20
+ # Always exactly one row, even for an empty hour (see SummaryService).
21
+ def request_rows
22
+ durations = request_data.map { |_, duration, _| duration }.compact.sort
23
+ statuses = request_data.map { |_, _, status| status }
24
+
25
+ [ row("RailsPulse::Request", 0).merge(Metrics.duration(durations), Metrics.status(statuses)) ]
26
+ end
27
+
28
+ def route_rows
29
+ request_data.slice_when { |previous, current| previous.first != current.first }.map do |rows|
30
+ durations = rows.map { |_, duration, _| duration }
31
+ statuses = rows.map { |_, _, status| status }
32
+
33
+ row("RailsPulse::Route", rows.first.first).merge(
34
+ Metrics.duration(durations),
35
+ Metrics.status(statuses),
36
+ count: rows.size
37
+ )
38
+ end
39
+ end
40
+
41
+ def query_rows
42
+ rows_by_summarizable(Operation.where(occurred_at: @time_range), :query_id).filter_map do |query_id, rows|
43
+ durations = rows.map(&:first).compact
44
+ next if durations.empty?
45
+
46
+ row("RailsPulse::Query", query_id).merge(Metrics.duration(durations))
47
+ end
48
+ end
49
+
50
+ def job_rows
51
+ scope = JobRun.where(occurred_at: @time_range).where(status: JobRun::FINAL_STATUSES)
52
+ runs_by_job = rows_by_summarizable(scope, :job_id, :status)
53
+ return [] if runs_by_job.empty?
54
+
55
+ # A run whose job row has gone (count-based cleanup) has nothing to
56
+ # summarize against.
57
+ known_job_ids = Job.where(id: runs_by_job.map(&:first)).pluck(:id).to_set
58
+
59
+ runs_by_job.filter_map do |job_id, runs|
60
+ next unless known_job_ids.include?(job_id)
61
+
62
+ durations = runs.map(&:first).compact.map(&:to_f)
63
+ next if durations.empty?
64
+
65
+ statuses = runs.map(&:second)
66
+ row("RailsPulse::Job", job_id).merge(
67
+ Metrics.duration(durations),
68
+ count: runs.size,
69
+ error_count: statuses.count { |s| s != "success" },
70
+ success_count: statuses.count { |s| s == "success" }
71
+ )
72
+ end
73
+ end
74
+
75
+ # Per-group counts plus an all-groups row (id 0), so the dashboard can
76
+ # chart total exception volume without loading one series per group.
77
+ def exception_rows
78
+ counts = ExceptionOccurrence.where(occurred_at: @time_range).group(:exception_group_id).count
79
+ return [] if counts.empty?
80
+
81
+ rows = counts.map { |group_id, occurrences| row("RailsPulse::ExceptionGroup", group_id).merge(count: occurrences) }
82
+ rows << row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
83
+ end
84
+
85
+ private
86
+
87
+ def row(summarizable_type, summarizable_id)
88
+ { summarizable_type: summarizable_type, summarizable_id: summarizable_id }
89
+ end
90
+
91
+ # One read serves the overall request row and every route's. route_id
92
+ # and duration are NOT NULL on requests, so ordering by (route_id,
93
+ # duration) hands each route its durations already sorted.
94
+ def request_data
95
+ @request_data ||= Request
96
+ .where(occurred_at: @time_range)
97
+ .order(:route_id, :duration)
98
+ .pluck(:route_id, :duration, :status)
99
+ end
100
+
101
+ # [[summarizable_id, [[duration, *extra_columns], ...]], ...], with
102
+ # each summarizable's rows in ascending duration order (NULL durations
103
+ # land at one end or the other depending on the database; callers
104
+ # compact them).
105
+ def rows_by_summarizable(scope, column, *extra_columns)
106
+ scope
107
+ .where.not(column => nil)
108
+ .order(column, :duration)
109
+ .pluck(column, :duration, *extra_columns)
110
+ .slice_when { |previous, current| previous.first != current.first }
111
+ .map { |rows| [ rows.first.first, rows.map { |row| row.drop(1) } ] }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,38 @@
1
+ module RailsPulse
2
+ class SummaryService
3
+ # The duration and status columns of a summary row, computed from raw
4
+ # values.
5
+ module Metrics
6
+ module_function
7
+
8
+ # `durations` must be sorted. Empty input yields count 0 and nil
9
+ # percentiles, which is what an idle period should record.
10
+ def duration(durations)
11
+ avg = durations.any? ? durations.sum.to_f / durations.size : 0
12
+
13
+ {
14
+ count: durations.size,
15
+ avg_duration: avg,
16
+ min_duration: durations.first,
17
+ max_duration: durations.last,
18
+ total_duration: durations.sum,
19
+ p50_duration: Statistics.calculate_percentile(durations, 0.5),
20
+ p95_duration: Statistics.calculate_percentile(durations, 0.95),
21
+ p99_duration: Statistics.calculate_percentile(durations, 0.99),
22
+ stddev_duration: Statistics.calculate_stddev(durations, avg)
23
+ }
24
+ end
25
+
26
+ def status(statuses)
27
+ {
28
+ error_count: statuses.count { |s| s >= 500 },
29
+ success_count: statuses.count { |s| s < 500 },
30
+ status_2xx: statuses.count { |s| s.between?(200, 299) },
31
+ status_3xx: statuses.count { |s| s.between?(300, 399) },
32
+ status_4xx: statuses.count { |s| s.between?(400, 499) },
33
+ status_5xx: statuses.count { |s| s >= 500 }
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end