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
@@ -0,0 +1,309 @@
1
+ module RailsPulse
2
+ # Resolves the dashboard's time range, chart-zoom/table window, and
3
+ # duration-threshold filter from request params and session state, in one
4
+ # pass. Returns a single frozen Result so every caller — controllers,
5
+ # views, cards, charts — reads the same object instead of each re-deriving
6
+ # its own answer.
7
+ #
8
+ # Priority order for the chart/page window (highest first):
9
+ # 1. Page-specific preset from the dropdown (params[:q][:period_start_range])
10
+ # 2. Page-specific custom range from the picker (params[:q][:custom_date_range])
11
+ # 3. Chart zoom / drill-down params (params[:q][:occurred_at_gteq/_lt])
12
+ # 4. Time range selector preference (session[:time_range_preference])
13
+ # 5. Global filters (session[:global_filters]["start_time"/"end_time"])
14
+ # 6. Default (default_key)
15
+ #
16
+ # Every input is parsed in Time.zone, matching how Summary rows are
17
+ # bucketed — parsing a custom-range string in any other zone would
18
+ # misinterpret the intended instant whenever that zone differs from
19
+ # Time.zone, silently resolving to the wrong window.
20
+ class TimeRange
21
+ Result = Struct.new(
22
+ :window, :table_window, :period_type, :selected_time_range,
23
+ :zoom_start, :zoom_end, :start_duration, :selected_response_range,
24
+ keyword_init: true
25
+ )
26
+
27
+ PRESET_START = {
28
+ "last_24_hours" => -> { 1.day.ago },
29
+ "last_7_days" => -> { 1.week.ago },
30
+ "last_14_days" => -> { 2.weeks.ago },
31
+ "last_30_days" => -> { 1.month.ago }
32
+ }.freeze
33
+
34
+ # Human-readable label for the zone daily/weekly/monthly summaries are
35
+ # bucketed in and custom ranges are rounded in (see class comment above)
36
+ # — the host app's config.time_zone, not the browser's zone. "UTC" or,
37
+ # e.g., "Eastern Time (US & Canada) (UTC-04:00)".
38
+ #
39
+ # This reads the Time.zone of the current dashboard request and assumes
40
+ # it is the zone SummaryJob ran under. That holds when both use
41
+ # config.time_zone (decision 0020). A host that overrides Time.zone per
42
+ # request for the dashboard as well would see a label that names the
43
+ # display zone while the daily buckets were still cut in the job's zone.
44
+ def self.aggregation_zone_label
45
+ return "UTC" if utc_zone?
46
+ "#{Time.zone.name} (UTC#{Time.zone.now.formatted_offset})"
47
+ end
48
+
49
+ # Compact form of aggregation_zone_label for chart headings and tooltip
50
+ # suffixes, where the full name would crowd out the data: "UTC", the
51
+ # zone's abbreviation when it has an alphabetic one ("EDT"), otherwise
52
+ # the offset ("UTC+05:45").
53
+ def self.aggregation_zone_short_label
54
+ return "UTC" if utc_zone?
55
+ abbreviation = Time.zone.now.zone
56
+ abbreviation.match?(/\A[A-Z]{2,5}\z/) ? abbreviation : "UTC#{Time.zone.now.formatted_offset}"
57
+ end
58
+
59
+ # IANA identifier for the same zone, for the chart JS (Intl.DateTimeFormat)
60
+ # to render daily labels in the zone summaries were actually bucketed in
61
+ # instead of the browser's zone.
62
+ def self.aggregation_zone_iana
63
+ Time.zone.tzinfo.name
64
+ end
65
+
66
+ def self.utc_zone?
67
+ Time.zone.utc_offset.zero? && Time.zone.now.zone == "UTC"
68
+ end
69
+ private_class_method :utc_zone?
70
+
71
+ def self.resolve(params:, session:, default_key: :last_24_hours, duration_range_type: :route)
72
+ new(params: params, session: session, default_key: default_key, duration_range_type: duration_range_type).resolve
73
+ end
74
+
75
+ def initialize(params:, session:, default_key:, duration_range_type:)
76
+ @params = params
77
+ @session = session
78
+ @default_key = default_key
79
+ @duration_range_type = duration_range_type
80
+ end
81
+
82
+ def resolve
83
+ start_time, end_time, selected_time_range = resolve_window
84
+ start_time, end_time, time_diff_hours = normalize(start_time, end_time)
85
+ period_type = time_diff_hours <= 25 ? "hour" : "day"
86
+
87
+ zoom_start, zoom_end, table_start, table_end = resolve_zoom(start_time.to_i, end_time.to_i)
88
+ start_duration, selected_response_range = resolve_duration
89
+
90
+ Result.new(
91
+ window: RailsPulse::TimeWindow.new(start_time, end_time),
92
+ table_window: RailsPulse::TimeWindow.new(table_start, table_end),
93
+ period_type: period_type,
94
+ selected_time_range: selected_time_range.to_s,
95
+ zoom_start: zoom_start,
96
+ zoom_end: zoom_end,
97
+ start_duration: start_duration,
98
+ selected_response_range: selected_response_range
99
+ ).freeze
100
+ end
101
+
102
+ private
103
+
104
+ attr_reader :params, :session, :default_key, :duration_range_type
105
+
106
+ # -- Window resolution (priorities 1-6) ----------------------------------
107
+
108
+ def resolve_window
109
+ from_page_preset || from_page_custom_range || from_chart_zoom ||
110
+ from_session_custom || from_session_preset || from_global_filters ||
111
+ default_window
112
+ end
113
+
114
+ def default_window
115
+ [ preset_start(default_key), Time.zone.now, default_key ]
116
+ end
117
+
118
+ def preset_start(key)
119
+ (PRESET_START[key.to_s] || PRESET_START["last_24_hours"]).call
120
+ end
121
+
122
+ # Priority 1: page-specific preset from the dropdown.
123
+ def from_page_preset
124
+ range = ransack_params[:period_start_range]
125
+ return nil unless range.present? && range.to_sym != :custom
126
+
127
+ [ preset_start(range), Time.zone.now, range ]
128
+ end
129
+
130
+ # Priority 2: page-specific custom datetime range from the picker (only
131
+ # when period_start_range is explicitly "custom").
132
+ def from_page_custom_range
133
+ return nil unless ransack_params[:period_start_range].present? &&
134
+ ransack_params[:period_start_range].to_sym == :custom &&
135
+ ransack_params[:custom_date_range].present? &&
136
+ ransack_params[:custom_date_range].include?(" to ")
137
+
138
+ dates = ransack_params[:custom_date_range].split(" to ")
139
+ custom_start = parse_time_param(dates[0].strip)
140
+ custom_end = parse_time_param(dates[1].strip)
141
+ return nil unless custom_start && custom_end
142
+
143
+ [ custom_start, custom_end, :custom ]
144
+ end
145
+
146
+ # Priority 3: custom time range from chart zoom.
147
+ def from_chart_zoom
148
+ return nil unless ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
149
+
150
+ zoom_start = parse_time_param(ransack_params[:occurred_at_gteq])
151
+ zoom_end = parse_time_param(ransack_params[:occurred_at_lt])
152
+ return nil unless zoom_start && zoom_end
153
+
154
+ [ zoom_start, zoom_end, :custom ]
155
+ end
156
+
157
+ # Priority 4a: custom range from the time range selector (session).
158
+ def from_session_custom
159
+ preference = session_time_range_preference
160
+ return nil unless RailsPulse::TimeRangePreference.custom?(preference)
161
+
162
+ start_time = parse_time_param(preference["start_time"]) || preset_start(default_key)
163
+ end_time = parse_time_param(preference["end_time"]) || Time.zone.now
164
+ [ start_time, end_time, :custom ]
165
+ end
166
+
167
+ # Priority 4b: preset from the time range selector (session).
168
+ def from_session_preset
169
+ preference = session_time_range_preference
170
+ return nil unless preference.is_a?(String) && preference.present?
171
+
172
+ [ preset_start(preference), Time.zone.now, preference.to_sym ]
173
+ end
174
+
175
+ # Priority 5: global filters (session).
176
+ def from_global_filters
177
+ filters = session_global_filters
178
+ return nil unless filters["start_time"].present? || filters["end_time"].present?
179
+
180
+ start_time = parse_time_param(filters["start_time"]) || preset_start(default_key)
181
+ end_time = parse_time_param(filters["end_time"]) || Time.zone.now
182
+ [ start_time, end_time, :custom ]
183
+ end
184
+
185
+ def ransack_params
186
+ q = params[:q]
187
+ q.respond_to?(:permit) ? q : ActionController::Parameters.new({})
188
+ end
189
+
190
+ def session_time_range_preference
191
+ RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
192
+ end
193
+
194
+ def session_global_filters
195
+ filters = session[:global_filters]
196
+ filters.is_a?(Hash) ? filters : {}
197
+ end
198
+
199
+ # Returns nil for anything that does not parse, so a hand-edited or stale
200
+ # value falls back to the default range instead of raising.
201
+ def parse_time_param(param)
202
+ case param
203
+ when Time, DateTime
204
+ param.in_time_zone
205
+ when String
206
+ return nil if param.blank?
207
+
208
+ Time.zone.parse(param)
209
+ when Numeric
210
+ Time.zone.at(param)
211
+ else
212
+ nil
213
+ end
214
+ rescue ArgumentError, TypeError, RangeError
215
+ nil
216
+ end
217
+
218
+ # Rounds to hour or day boundaries in Time.zone (summaries are always
219
+ # bucketed there) and returns the span in hours (float) alongside.
220
+ def normalize(start_time, end_time)
221
+ start_time = start_time.in_time_zone
222
+ end_time = end_time.in_time_zone
223
+ time_diff_hours = (end_time.to_i - start_time.to_i) / 3600.0
224
+
225
+ if time_diff_hours <= 25
226
+ [ start_time.beginning_of_hour, end_time.end_of_hour, time_diff_hours ]
227
+ else
228
+ [ start_time.beginning_of_day, end_time.end_of_day, time_diff_hours ]
229
+ end
230
+ end
231
+
232
+ # -- Zoom / table window --------------------------------------------------
233
+
234
+ def resolve_zoom(main_start, main_end)
235
+ selected_column_time = params[:selected_column_time]
236
+ zoom_start = params.delete(:zoom_start_time)
237
+ zoom_end = params.delete(:zoom_end_time)
238
+
239
+ if selected_column_time
240
+ table_start, table_end = normalize_column_time(selected_column_time.to_i, main_start, main_end)
241
+ # Column selection has highest precedence for the table only; the
242
+ # chart keeps showing the full range, so zoom_start/zoom_end are
243
+ # returned as extracted (not normalized) rather than derived here.
244
+ return [ zoom_start, zoom_end, table_start, table_end ]
245
+ end
246
+
247
+ zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i) if zoom_start && zoom_end
248
+
249
+ table_start = zoom_start || main_start
250
+ table_end = zoom_end || main_end
251
+
252
+ [ zoom_start, zoom_end, table_start, table_end ]
253
+ end
254
+
255
+ # column_time_ms is JS milliseconds; period boundary is based on the
256
+ # overall page range (main_start/main_end), same as the chart.
257
+ def normalize_column_time(column_time_ms, main_start, main_end)
258
+ column_time_seconds = column_time_ms / 1000
259
+ time_diff_hours = (main_end - main_start) / 3600.0
260
+ column_time_obj = Time.zone&.at(column_time_seconds) || Time.at(column_time_seconds)
261
+
262
+ if time_diff_hours <= 25
263
+ [ column_time_obj.beginning_of_hour.to_i, column_time_obj.end_of_hour.to_i ]
264
+ else
265
+ [ column_time_obj.beginning_of_day.to_i, column_time_obj.end_of_day.to_i ]
266
+ end
267
+ end
268
+
269
+ # start_ms/end_ms are JS milliseconds; period boundary is based on the
270
+ # zoomed span itself.
271
+ def normalize_zoom_times(start_ms, end_ms)
272
+ start_seconds = start_ms / 1000
273
+ end_seconds = end_ms / 1000
274
+ time_diff_hours = (end_seconds - start_seconds) / 3600.0
275
+ start_obj = Time.zone&.at(start_seconds) || Time.at(start_seconds)
276
+ end_obj = Time.zone&.at(end_seconds) || Time.at(end_seconds)
277
+
278
+ if time_diff_hours <= 25
279
+ [ start_obj.beginning_of_hour.to_i, end_obj.end_of_hour.to_i ]
280
+ else
281
+ [ start_obj.beginning_of_day.to_i, end_obj.end_of_day.to_i ]
282
+ end
283
+ end
284
+
285
+ # -- Duration threshold -----------------------------------------------------
286
+
287
+ def resolve_duration
288
+ thresholds = RailsPulse.configuration.public_send("#{duration_range_type}_thresholds")
289
+ duration_param = ransack_params[:avg_duration] || ransack_params[:duration] || ransack_params[:duration_gteq]
290
+
291
+ if duration_param.present?
292
+ [ threshold_for(thresholds, duration_param), duration_param ]
293
+ elsif (global_threshold = session_global_filters["performance_threshold"]).present?
294
+ [ threshold_for(thresholds, global_threshold), global_threshold.to_sym ]
295
+ else
296
+ [ 0, :all ]
297
+ end
298
+ end
299
+
300
+ def threshold_for(thresholds, key)
301
+ case key.to_sym
302
+ when :slow then thresholds[:slow]
303
+ when :very_slow then thresholds[:very_slow]
304
+ when :critical then thresholds[:critical]
305
+ else 0
306
+ end
307
+ end
308
+ end
309
+ end
@@ -45,6 +45,21 @@ module RailsPulse
45
45
  starts
46
46
  end
47
47
 
48
+ # The start of each chart bucket from the window's start, as epoch
49
+ # seconds: one calendar day or one hour apart in Time.zone. A day is 23
50
+ # or 25 hours across a daylight-saving change, so stepping a fixed 86,400
51
+ # seconds would drift off local midnight and miss every summary after it.
52
+ def bucket_timestamps(period_type)
53
+ step = period_type.to_s == "hour" ? 1.hour : 1.day
54
+ stamps = []
55
+ current = start_time
56
+ while current <= end_time
57
+ stamps << current.to_i
58
+ current += step
59
+ end
60
+ stamps
61
+ end
62
+
48
63
  # The window of the same length immediately preceding this one, for
49
64
  # period-over-period comparisons. `unit` is "day" or "hour".
50
65
  def previous(unit)
@@ -0,0 +1,90 @@
1
+ module RailsPulse
2
+ # The background writer's once-a-minute sample, stored as an Event of kind
3
+ # writer_heartbeat: `subject` is "host:pid", `value` the requests dropped
4
+ # since the previous sample, and `metadata` the queue depth, its configured
5
+ # capacity and the lifetime drop count. Stats are otherwise per process, so
6
+ # with eight Puma workers the dashboard could only ever see the one that
7
+ # happened to serve it; these rows let it, the Storage page and
8
+ # rails_pulse:status add every live writer up.
9
+ class WriterHeartbeat
10
+ KIND = "writer_heartbeat".freeze
11
+ # A writer that has not reported for this long is treated as gone.
12
+ LIVE_WINDOW = 3.minutes
13
+ RETENTION = 24.hours
14
+
15
+ Sample = Struct.new(:hostname, :pid, :queue_size, :queue_depth, :dropped_total, :sampled_at, keyword_init: true) do
16
+ def process_label
17
+ "#{hostname}:#{pid}"
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def events
23
+ RailsPulse::Event.of_kind(KIND)
24
+ end
25
+
26
+ # `host_label` is config.cloud.host_label: Rails Pulse Cloud names the
27
+ # host by it instead of the hostname, which can show internal addresses
28
+ # and changes with every container.
29
+ def record!(hostname:, pid:, queue_size:, queue_depth:, dropped:, dropped_total:, host_label: nil, sampled_at: Time.current)
30
+ RailsPulse::Event.insert_all([ {
31
+ kind: KIND,
32
+ subject: "#{hostname}:#{pid}",
33
+ outcome: "sampled",
34
+ value: dropped,
35
+ occurred_at: sampled_at,
36
+ metadata: { hostname: hostname, host_label: host_label, pid: pid, queue_size: queue_size, queue_depth: queue_depth,
37
+ dropped_total: dropped_total }.compact.to_json,
38
+ created_at: sampled_at,
39
+ updated_at: sampled_at
40
+ } ])
41
+ end
42
+
43
+ def prune!
44
+ events.where(occurred_at: ...RETENTION.ago).delete_all
45
+ end
46
+
47
+ # The latest sample from each writer that reported inside LIVE_WINDOW,
48
+ # newest first. A handful of rows per process, reduced in Ruby.
49
+ def live_processes
50
+ events.since(LIVE_WINDOW.ago).recent.to_a.uniq(&:subject).map { |event| sample_from(event) }
51
+ end
52
+
53
+ # { "host:pid" => requests dropped inside `window` }
54
+ def dropped_by_process(window: 1.hour)
55
+ events.since(window.ago).group(:subject).sum(:value).transform_values(&:to_i)
56
+ end
57
+
58
+ # What the dashboard, Storage page and status task show:
59
+ # processes live writers
60
+ # queue_depth requests waiting across them, from each one's latest sample
61
+ # queue_size the largest configured capacity among them
62
+ # dropped requests dropped inside `window`, across every process
63
+ # last_sampled_at most recent heartbeat, nil when there is none
64
+ def summary(window: 1.hour)
65
+ processes = live_processes
66
+ {
67
+ processes: processes.size,
68
+ queue_depth: processes.sum(&:queue_depth),
69
+ queue_size: processes.map(&:queue_size).max || 0,
70
+ dropped: events.since(window.ago).sum(:value).to_i,
71
+ last_sampled_at: events.maximum(:occurred_at)
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def sample_from(event)
78
+ meta = event.metadata_hash
79
+ Sample.new(
80
+ hostname: meta["hostname"],
81
+ pid: meta["pid"],
82
+ queue_size: meta["queue_size"].to_i,
83
+ queue_depth: meta["queue_depth"].to_i,
84
+ dropped_total: meta["dropped_total"].to_i,
85
+ sampled_at: event.occurred_at
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,23 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class DeploymentSerializer
5
+ # `comparison` is the deployment's DeploymentComparison, computed by
6
+ # the caller so a page of deployments is compared in one query.
7
+ def self.serialize(deployment, comparison: nil)
8
+ {
9
+ id: deployment.id,
10
+ revision: deployment.revision,
11
+ short_revision: deployment.short_revision,
12
+ started_at: deployment.started_at,
13
+ finished_at: deployment.finished_at,
14
+ duration_seconds: deployment.duration&.round(1),
15
+ in_progress: deployment.in_progress?,
16
+ metadata: deployment.metadata_hash,
17
+ comparison: comparison
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class ExceptionGroupSerializer
5
+ def self.serialize(group)
6
+ {
7
+ id: group.id,
8
+ fingerprint: group.fingerprint,
9
+ exception_class: group.exception_class,
10
+ location: group.location,
11
+ message: group.message,
12
+ status: group.status,
13
+ occurrence_count: group.occurrence_count,
14
+ first_seen_at: group.first_seen_at,
15
+ last_seen_at: group.last_seen_at,
16
+ resolved_at: group.resolved_at,
17
+ preserve: group.preserve
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class ExceptionOccurrenceSerializer
5
+ def self.serialize(occurrence)
6
+ {
7
+ id: occurrence.id,
8
+ exception_class: occurrence.exception_class,
9
+ message: occurrence.message,
10
+ occurred_at: occurrence.occurred_at,
11
+ request_method: occurrence.request_method,
12
+ request_url: occurrence.request_url,
13
+ request_params: occurrence.request_params.presence,
14
+ environment: occurrence.environment,
15
+ deploy_sha: occurrence.deploy_sha,
16
+ backtrace: Array(occurrence.backtrace)
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -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,22 @@
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
+ path: request.route&.path,
10
+ occurred_at: request.occurred_at,
11
+ duration: request.duration,
12
+ status: request.status,
13
+ is_error: request.is_error,
14
+ request_uuid: request.request_uuid,
15
+ controller_action: request.controller_action,
16
+ response_size_bytes: request.response_size_bytes
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ 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