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
@@ -14,6 +14,9 @@
14
14
  <div class="flex flex-col">
15
15
  <h3 class="font-semibold leading-none mbe-2 uppercase text-sm">Storage</h3>
16
16
  <p class="text-sm text-subtle">How full Rails Pulse tables are relative to <code>max_table_records</code>, plus cleanup health. Open the full page for every table, retention details, and database size.</p>
17
+ <% if @storage_status.estimated? %>
18
+ <p class="text-sm text-subtle mbs-2">Counting rows exactly means scanning every table, so the dashboard uses the database's own row estimates<% unless @storage_status.counts_available? %>, which SQLite does not keep<% end %>. The Storage page counts exactly.</p>
19
+ <% end %>
17
20
  </div>
18
21
  </div>
19
22
  </div>
@@ -23,7 +26,9 @@
23
26
  </div>
24
27
  </div>
25
28
 
29
+ <% approx = @storage_status.estimated? ? "~" : "" %>
26
30
  <div class="storage-panel-stats">
31
+ <% if @storage_status.counts_available? %>
27
32
  <div>
28
33
  <p class="font-semibold uppercase text-xs text-subtle mbe-1">Highest fill</p>
29
34
  <p class="text-lg font-bold"><% if @storage_status.overview[:hottest_percent] %><%= @storage_status.overview[:hottest_percent].round %>%<% else %>--<% end %></p>
@@ -31,9 +36,10 @@
31
36
  </div>
32
37
  <div>
33
38
  <p class="font-semibold uppercase text-xs text-subtle mbe-1">Records</p>
34
- <p class="text-lg font-bold"><%= number_with_delimiter(@storage_status.overview[:total_records]) %></p>
39
+ <p class="text-lg font-bold"><%= approx %><%= number_with_delimiter(@storage_status.overview[:total_records]) %></p>
35
40
  <p class="text-xs text-subtle">Across <%= @storage_status.tables.size %> tables</p>
36
41
  </div>
42
+ <% end %>
37
43
  <div>
38
44
  <p class="font-semibold uppercase text-xs text-subtle mbe-1">Disk used</p>
39
45
  <p class="text-lg font-bold"><%= @storage_status.overview[:display_bytes] ? human_readable_bytes(@storage_status.overview[:display_bytes]) : "—" %></p>
@@ -46,6 +52,7 @@
46
52
  </div>
47
53
  </div>
48
54
 
55
+ <% if @storage_status.counts_available? %>
49
56
  <div class="storage-panel-tables">
50
57
  <% @storage_status.dashboard_tables.each do |table| %>
51
58
  <div class="storage-panel-row">
@@ -55,13 +62,16 @@
55
62
  </div>
56
63
  <span class="storage-panel-row__count text-xs text-subtle">
57
64
  <% if table[:limit] %>
58
- <%= number_with_delimiter(table[:count]) %> / <%= number_with_delimiter(table[:limit]) %>
65
+ <%= approx %><%= number_with_delimiter(table[:count]) %> / <%= number_with_delimiter(table[:limit]) %>
59
66
  · <%= table[:percent_label] %>
60
67
  <% else %>
61
- <%= number_with_delimiter(table[:count]) %>
68
+ <%= approx %><%= number_with_delimiter(table[:count]) %>
62
69
  <% end %>
63
70
  </span>
64
71
  </div>
65
72
  <% end %>
66
73
  </div>
74
+ <% else %>
75
+ <p class="text-sm text-subtle">Record counts need a full scan of each table on SQLite, so the dashboard leaves them out. <%= link_to "Open Storage", storage_path %> to count them.</p>
76
+ <% end %>
67
77
  </div>
@@ -29,10 +29,24 @@
29
29
  critical_label: "critical",
30
30
  link_url: exceptions_path %>
31
31
  <% end %>
32
+ <%# Nil until a background writer has reported a heartbeat. Shown only
33
+ when a writer is backlogged or dropping requests. %>
34
+ <% if @health_summary[:tracking] %>
35
+ <%= render "rails_pulse/dashboard/health_badge",
36
+ name: "Tracking",
37
+ health: @health_summary[:tracking],
38
+ healthy_label: "writing",
39
+ slow_label: "backlogged",
40
+ critical_label: "dropping",
41
+ hide_when_healthy: true,
42
+ link_url: storage_path %>
43
+ <% end %>
44
+ <%# Shown only when storage is under warning or critical pressure. %>
32
45
  <%= render "rails_pulse/dashboard/health_badge",
33
46
  name: "Storage",
34
47
  health: @health_summary[:storage],
35
48
  critical_label: "critical",
49
+ hide_when_healthy: true,
36
50
  link_url: storage_path %>
37
51
  </div>
38
52
  </div>
@@ -130,7 +144,7 @@
130
144
  </span>
131
145
  <% end %>
132
146
  <span class="text-xs text-subtle mis-2">Ranked by severity ·
133
- <%= humanize_time_range(@selected_time_range) %></span>
147
+ <%= humanize_time_range(@time_range.selected_time_range) %></span>
134
148
  </div>
135
149
  <div
136
150
  data-controller="rails-pulse--popover"
@@ -179,7 +193,7 @@
179
193
  </span>
180
194
  <p class="font-semibold text-sm text-positive">All clear</p>
181
195
  <p class="text-xs text-subtle">No routes, queries, or jobs need attention in the
182
- <%= humanize_time_range(@selected_time_range) %>.</p>
196
+ <%= humanize_time_range(@time_range.selected_time_range) %>.</p>
183
197
  </div>
184
198
  <% else %>
185
199
  <div class="flex flex-col">
@@ -56,6 +56,7 @@
56
56
  </div>
57
57
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
58
58
  </div>
59
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
59
60
  </div>
60
61
 
61
62
  <!-- Duration Chart -->
@@ -29,7 +29,7 @@
29
29
  %>
30
30
  <%= form.select :avg_duration,
31
31
  duration_options(:job),
32
- { selected: @selected_response_range },
32
+ { selected: @time_range&.selected_response_range },
33
33
  { class: "input" }
34
34
  %>
35
35
  <%= if params.has_key?(:q) || params[:queue_name].present?
@@ -56,6 +56,7 @@
56
56
  </div>
57
57
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
58
58
  </div>
59
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
59
60
  </div>
60
61
 
61
62
  <!-- Query Performance Chart -->
@@ -26,7 +26,7 @@
26
26
  >
27
27
  <%= form.select :avg_duration,
28
28
  duration_options(:query),
29
- { selected: @selected_response_range },
29
+ { selected: @time_range&.selected_response_range },
30
30
  { class: "input" } %>
31
31
  <%= if params.has_key?(:q)
32
32
  link_to "Reset",
@@ -24,7 +24,7 @@
24
24
  >
25
25
  <%= form.select :duration,
26
26
  duration_options(:query),
27
- { selected: @selected_response_range },
27
+ { selected: @time_range&.selected_response_range },
28
28
  { class: "input" }
29
29
  %>
30
30
  <%= link_to "Reset", query_path(@query), class: "btn btn--plain", style: "text-decoration: none; color: var(--color-text-muted);" if params.has_key?(:q) %>
@@ -23,7 +23,7 @@
23
23
 
24
24
  <%= form.select :duration_gteq,
25
25
  duration_options(:request),
26
- { selected: @selected_response_range, prompt: "Min duration" },
26
+ { selected: @time_range&.selected_response_range, prompt: "Min duration" },
27
27
  { class: "input" }
28
28
  %>
29
29
 
@@ -82,6 +82,7 @@
82
82
  </div>
83
83
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
84
84
  </div>
85
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
85
86
  </div>
86
87
  <!-- Response Time Chart -->
87
88
  <div
@@ -34,7 +34,7 @@
34
34
  class: "input" %>
35
35
  <%= form.select :avg_duration,
36
36
  duration_options(:route),
37
- { selected: @selected_response_range },
37
+ { selected: @time_range&.selected_response_range },
38
38
  { class: "input" } %>
39
39
  <%= if params.has_key?(:q)
40
40
  link_to "Reset",
@@ -26,7 +26,7 @@
26
26
  >
27
27
  <%= form.select :avg_duration,
28
28
  duration_options(:route),
29
- { selected: @selected_response_range },
29
+ { selected: @time_range&.selected_response_range },
30
30
  { class: "input" }
31
31
  %>
32
32
  <%= form.select :http_method_eq,
@@ -0,0 +1,4 @@
1
+ <%# Zone every chart on the page is bucketed and labelled in. The short
2
+ label is always visible; the title carries the full zone name and, when
3
+ a controller resolved one, the exact window being shown. %>
4
+ <span class="zone-badge" title="<%= time_range_zone_tooltip %>"><%= RailsPulse::TimeRange.aggregation_zone_short_label %></span>
@@ -75,7 +75,7 @@
75
75
  <%= render "rails_pulse/components/panel",
76
76
  title: "Cleanup",
77
77
  help_heading: "How cleanup works",
78
- help_text: "Time-based cleanup deletes records older than full_retention_period, but only after that period has been summarised. Count-based cleanup then trims each table down to max_table_records. Schedule RailsPulse::CleanupJob daily." do %>
78
+ help_text: "Time-based cleanup deletes records older than full_retention_period. Count-based cleanup then trims each table down to max_table_records, taking requests and operations only from periods already summarised. Schedule RailsPulse::CleanupJob daily." do %>
79
79
  <dl class="descriptive-list mbs-4">
80
80
  <dt>Automatic cleanup</dt>
81
81
  <dd><%= @status.cleanup[:enabled] ? "Enabled" : "Disabled (archiving_enabled = false)" %></dd>
@@ -87,7 +87,7 @@
87
87
  <%= human_readable_occurred_at(@status.cleanup[:last_summary_at]) %>
88
88
  <span class="text-subtle">(<%= time_ago_in_words(@status.cleanup[:last_summary_at]) %>)</span>
89
89
  <% else %>
90
- Never — cleanup will not delete unsummarised requests
90
+ Never — count-based cleanup will not trim unsummarised requests
91
91
  <% end %>
92
92
  </dd>
93
93
  <dt>Count caps</dt>
@@ -120,4 +120,41 @@
120
120
  <% end %>
121
121
  </dl>
122
122
  <% end %>
123
+
124
+ <%= render "rails_pulse/components/panel",
125
+ title: "Tracking writers",
126
+ help_heading: "The background writer",
127
+ help_text: "Each process queues tracked requests for one writer thread, which reports a heartbeat every minute. When a queue is full the newest request is dropped rather than slowing the app; dropped requests mean the charts undercount traffic. Raise async_queue_size or fix database latency." do %>
128
+ <% tracking = @status.tracking %>
129
+ <dl class="descriptive-list mbs-4">
130
+ <dt>Live writers</dt>
131
+ <dd><%= tracking[:live_count] %></dd>
132
+ <dt>Queue depth</dt>
133
+ <dd><%= number_with_delimiter(tracking[:queue_depth]) %><% if tracking[:queue_size].to_i.positive? %> <span class="text-subtle">/ <%= number_with_delimiter(tracking[:queue_size]) %> per process</span><% end %></dd>
134
+ <dt>Dropped in the last hour</dt>
135
+ <dd class="<%= tracking[:dropped].to_i.positive? ? "text-negative font-semibold" : "" %>"><%= number_with_delimiter(tracking[:dropped]) %></dd>
136
+ <dt>Last heartbeat</dt>
137
+ <dd>
138
+ <% if tracking[:last_sampled_at] %>
139
+ <%= human_readable_occurred_at(tracking[:last_sampled_at]) %>
140
+ <span class="text-subtle">(<%= time_ago_in_words(tracking[:last_sampled_at]) %>)</span>
141
+ <% else %>
142
+ Never — heartbeats start when a process with <code>async</code> on tracks its first request
143
+ <% end %>
144
+ </dd>
145
+ <% tracking[:processes].each do |process| %>
146
+ <dt><code><%= process[:label] %></code></dt>
147
+ <dd>
148
+ <%= number_with_delimiter(process[:queue_depth]) %> / <%= number_with_delimiter(process[:queue_size]) %> queued
149
+ <% if process[:dropped_last_hour].positive? %>
150
+ <span class="badge storage-table__percent storage-table__percent--critical"><%= number_with_delimiter(process[:dropped_last_hour]) %> dropped</span>
151
+ <% else %>
152
+ <span class="badge storage-table__percent storage-table__percent--uncapped">0 dropped</span>
153
+ <% end %>
154
+ <br>
155
+ <span class="text-subtle">last heartbeat <%= time_ago_in_words(process[:last_seen_at]) %></span>
156
+ </dd>
157
+ <% end %>
158
+ </dl>
159
+ <% end %>
123
160
  </div>
data/config/routes.rb CHANGED
@@ -37,6 +37,29 @@ RailsPulse::Engine.routes.draw do
37
37
  end
38
38
  end
39
39
 
40
+ # Read-only JSON API for the rails-pulse CLI, the MCP server and CI, all
41
+ # authenticated by config.api_token (app/controllers/rails_pulse/api/v1).
42
+ scope path: "api/v1", module: "api/v1", as: :api_v1 do
43
+ resources :routes, only: :index
44
+ resources :requests, only: :index
45
+ resources :queries, only: :index
46
+ resources :jobs, only: :index
47
+ resources :job_runs, only: :index
48
+ resources :deployments, only: :index
49
+ resources :exceptions, only: %i[index show]
50
+
51
+ # Whether the data is there, rather than a question about the data.
52
+ get "coverage", to: "coverage#show", as: :coverage
53
+
54
+ # What needs attention over one summary period, and whether the
55
+ # thresholds fit it.
56
+ get "insights", to: "insights#show", as: :insights
57
+
58
+ # Which application, environment and version answered, so results gathered
59
+ # against staging are not read as production.
60
+ get "capabilities", to: "capabilities#show", as: :capabilities
61
+ end
62
+
40
63
  # CSP compliance testing (development/test only)
41
64
  if Rails.env.local?
42
65
  get "csp_test", to: "csp_test#show", as: :csp_test
@@ -0,0 +1,58 @@
1
+ # Rails Pulse Cloud Database Schema
2
+ # The tables Rails Pulse Cloud's sync needs, kept apart from
3
+ # db/rails_pulse_schema.rb so an install that does not use Cloud never has
4
+ # them. Installed by `rails generate rails_pulse:install_cloud`; loaded by its
5
+ # migration, and by db:prepare on a separate Rails Pulse database.
6
+
7
+ RailsPulse::CloudSchema = lambda do |connection|
8
+ required_tables = [ :rails_pulse_cloud_installations, :rails_pulse_cloud_batches ]
9
+
10
+ missing_tables = required_tables.reject { |table| connection.table_exists?(table) }
11
+ if missing_tables.empty?
12
+ puts "[RailsPulse::CloudSchema] All Rails Pulse Cloud tables already exist. Skipping schema load."
13
+ return
14
+ end
15
+
16
+ puts "[RailsPulse::CloudSchema] Creating missing tables: #{missing_tables.join(', ')}"
17
+
18
+ unless connection.table_exists?(:rails_pulse_cloud_installations)
19
+ connection.create_table :rails_pulse_cloud_installations do |t|
20
+ t.string :installation_id, limit: 36, null: false, comment: "UUID naming this Rails Pulse database to Rails Pulse Cloud, generated once"
21
+ t.datetime :last_hour_sent_at, comment: "Start of the newest hour whose summaries were queued for Cloud"
22
+ t.datetime :exception_groups_sent_through, comment: "Exception groups updated before this were queued for Cloud"
23
+ t.datetime :deployments_sent_through, comment: "Deployments updated before this were queued for Cloud"
24
+ t.datetime :last_success_at, comment: "When Cloud last accepted a batch or health update"
25
+ t.datetime :last_health_at, comment: "When the last health update was sent"
26
+ t.text :last_error, comment: "The most recent failure, for rails_pulse:status"
27
+ t.datetime :last_error_at
28
+ t.datetime :paused_until, comment: "No sending before this, after Cloud refused the key, plan, application or contract"
29
+ t.text :pause_reason, comment: "Cloud's message for the pause"
30
+ t.string :contract_deprecated_on, comment: "Date from Cloud's Rails-Pulse-Contract-Deprecated header"
31
+ t.timestamps
32
+ end
33
+
34
+ connection.add_index :rails_pulse_cloud_installations, :installation_id, unique: true,
35
+ name: "index_rp_cloud_installations_on_installation_id"
36
+ end
37
+
38
+ unless connection.table_exists?(:rails_pulse_cloud_batches)
39
+ connection.create_table :rails_pulse_cloud_batches do |t|
40
+ t.string :batch_id, limit: 36, null: false, comment: "The batch's UUID, resent unchanged on every retry"
41
+ t.binary :payload, limit: 16.megabytes, null: false, comment: "The batch as gzipped JSON"
42
+ t.integer :byte_size, null: false, comment: "Size of the gzipped payload, for the buffer limit"
43
+ t.integer :item_count, null: false
44
+ t.integer :attempts, null: false, default: 0
45
+ t.datetime :next_attempt_at, null: false, comment: "Not sent before this; set by backoff and Retry-After"
46
+ t.text :last_error
47
+ t.timestamps
48
+ end
49
+
50
+ connection.add_index :rails_pulse_cloud_batches, :batch_id, unique: true, name: "index_rp_cloud_batches_on_batch_id"
51
+ connection.add_index :rails_pulse_cloud_batches, :next_attempt_at, name: "index_rp_cloud_batches_on_next_attempt_at"
52
+ connection.add_index :rails_pulse_cloud_batches, :created_at, name: "index_rp_cloud_batches_on_created_at"
53
+ end
54
+ end unless defined?(RailsPulse::CloudSchema)
55
+
56
+ if defined?(RailsPulse::ApplicationRecord)
57
+ RailsPulse::CloudSchema.call(RailsPulse::ApplicationRecord.connection)
58
+ end
@@ -32,7 +32,7 @@ class CreateRailsPulseExceptions < ActiveRecord::Migration[7.0]
32
32
  t.string :request_url, comment: "Nullable — web requests only"
33
33
  t.string :request_method, comment: "GET, POST, etc."
34
34
  t.string :environment, comment: "production, staging, etc."
35
- t.string :deploy_sha, comment: "Captured now even though Pro uses it — cannot backfill later"
35
+ t.string :deploy_sha, comment: "Captured at occurrence time — cannot backfill later"
36
36
  t.text :request_params, comment: "JSON hash of filtered request params — web requests only"
37
37
  t.datetime :occurred_at, null: false
38
38
  t.timestamps
@@ -0,0 +1,21 @@
1
+ class CreateRailsPulseEvents < ActiveRecord::Migration[7.0]
2
+ def change
3
+ return if table_exists?(:rails_pulse_events)
4
+
5
+ create_table :rails_pulse_events do |t|
6
+ t.string :kind, null: false, comment: "What noticed it, e.g. writer_heartbeat"
7
+ t.string :subject, comment: "Who it is about: host:pid, rule name, job name, exception class"
8
+ t.string :outcome, null: false, comment: "sampled, triggered, clean, insufficient_data, ran"
9
+ t.decimal :value, precision: 15, scale: 6, comment: "The number behind it: requests dropped since the last sample, or the triggered metric value"
10
+ t.datetime :occurred_at, null: false
11
+ t.text :message
12
+ t.text :metadata, comment: "JSON with the kind-specific detail"
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :rails_pulse_events, [ :kind, :occurred_at ],
17
+ name: "index_rp_events_on_kind_and_occurred_at"
18
+ add_index :rails_pulse_events, [ :kind, :subject, :occurred_at ],
19
+ name: "index_rp_events_on_kind_subject_and_occurred_at"
20
+ end
21
+ end
@@ -7,7 +7,7 @@ require "rails_pulse/route_indexes" unless defined?(RailsPulse::RouteIndexes)
7
7
  RailsPulse::Schema = lambda do |connection|
8
8
  adapter = connection.adapter_name.downcase
9
9
  # Skip if all tables already exist to prevent conflicts
10
- required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences ]
10
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences, :rails_pulse_events ]
11
11
 
12
12
  # Check which tables already exist
13
13
  existing_tables = required_tables.select { |table| connection.table_exists?(table) }
@@ -249,7 +249,7 @@ RailsPulse::Schema = lambda do |connection|
249
249
  t.string :request_url, comment: "Nullable — web requests only"
250
250
  t.string :request_method, comment: "GET, POST, etc."
251
251
  t.string :environment, comment: "production, staging, etc."
252
- t.string :deploy_sha, comment: "Captured now even though Pro uses it — cannot backfill later"
252
+ t.string :deploy_sha, comment: "Captured at occurrence time — cannot backfill later"
253
253
  t.text :request_params, comment: "JSON hash of filtered request params — web requests only"
254
254
  t.datetime :occurred_at, null: false
255
255
  t.timestamps
@@ -259,6 +259,24 @@ RailsPulse::Schema = lambda do |connection|
259
259
  connection.add_index :rails_pulse_exception_occurrences, :exception_group_id, name: "index_rp_exception_occurrences_on_group_id"
260
260
  end
261
261
 
262
+ unless connection.table_exists?(:rails_pulse_events)
263
+ connection.create_table :rails_pulse_events do |t|
264
+ t.string :kind, null: false, comment: "What noticed it, e.g. writer_heartbeat"
265
+ t.string :subject, comment: "Who it is about: host:pid, rule name, job name, exception class"
266
+ t.string :outcome, null: false, comment: "sampled, triggered, clean, insufficient_data, ran"
267
+ t.decimal :value, precision: 15, scale: 6, comment: "The number behind it: requests dropped since the last sample, or the triggered metric value"
268
+ t.datetime :occurred_at, null: false
269
+ t.text :message
270
+ t.text :metadata, comment: "JSON with the kind-specific detail"
271
+ t.timestamps
272
+ end
273
+
274
+ connection.add_index :rails_pulse_events, [ :kind, :occurred_at ],
275
+ name: "index_rp_events_on_kind_and_occurred_at"
276
+ connection.add_index :rails_pulse_events, [ :kind, :subject, :occurred_at ],
277
+ name: "index_rp_events_on_kind_subject_and_occurred_at"
278
+ end
279
+
262
280
  # Add indexes to existing tables for efficient aggregation
263
281
  unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
264
282
  connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
data/exe/rails-pulse ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs outside the Rails app: talks to it over the JSON API, so only the CLI
5
+ # is loaded here, never the engine.
6
+ require "rails_pulse/cli/main"
7
+ RailsPulse::CLI::Main.start(ARGV)
@@ -15,6 +15,7 @@ module RailsPulse
15
15
  rails_pulse_deployments
16
16
  rails_pulse_exception_groups
17
17
  rails_pulse_exception_occurrences
18
+ rails_pulse_events
18
19
  ].freeze
19
20
 
20
21
  # Generate next migration number for timestamped migrations
@@ -45,6 +46,24 @@ module RailsPulse
45
46
  false
46
47
  end
47
48
 
49
+ # True when config/database.yml names a rails_pulse database in any
50
+ # environment.
51
+ def has_separate_database_config?
52
+ config_path = File.join(root_path, "config/database.yml")
53
+ return false unless File.exist?(config_path)
54
+
55
+ require "yaml"
56
+ require "erb"
57
+ # Process ERB before YAML parsing — database.yml files commonly use ERB
58
+ # for environment-specific values. YAML.safe_load alone raises SyntaxError
59
+ # on ERB tags.
60
+ yaml_content = ERB.new(File.read(config_path)).result
61
+ db_config = YAML.safe_load(yaml_content, aliases: true)
62
+ db_config.values.any? { |env| env.is_a?(Hash) && env.key?("rails_pulse") }
63
+ rescue
64
+ false
65
+ end
66
+
48
67
  # Get root path (destination_root in tests, Rails.root in production)
49
68
  def root_path
50
69
  respond_to?(:destination_root) ? destination_root : Rails.root
@@ -0,0 +1,84 @@
1
+ require_relative "base_methods"
2
+
3
+ module RailsPulse
4
+ module Generators
5
+ # Adds the tables Rails Pulse Cloud's sync needs to an existing Rails
6
+ # Pulse install. Kept out of rails_pulse:install so an app that never
7
+ # uses Cloud never has them: like the main install, it copies a schema
8
+ # file (db/rails_pulse_cloud_schema.rb) and a migration that loads it.
9
+ class InstallCloudGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+ include BaseMethods
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Install the Rails Pulse Cloud tables into an existing Rails Pulse setup"
16
+
17
+ class_option :database, type: :string, default: "detect",
18
+ desc: "Database setup: 'single', 'separate', or 'detect' (default)"
19
+
20
+ def check_rails_pulse_installed
21
+ return if File.exist?(File.join(root_path, "db/rails_pulse_schema.rb"))
22
+
23
+ say "Rails Pulse not detected. Run 'rails generate rails_pulse:install' first.", :red
24
+ exit 1
25
+ end
26
+
27
+ def copy_schema
28
+ copy_file "db/rails_pulse_cloud_schema.rb", "db/rails_pulse_cloud_schema.rb"
29
+ end
30
+
31
+ # The migration goes where Rails Pulse's own migrations run. On a
32
+ # separate database that is db/rails_pulse_migrate: the database
33
+ # already exists, so db:migrate:rails_pulse has to create the tables,
34
+ # and a fresh one built by db:prepare loads the schema file instead.
35
+ def copy_migration
36
+ migration_template(
37
+ "migrations/install_rails_pulse_cloud_tables.rb",
38
+ "#{migration_dir}/install_rails_pulse_cloud_tables.rb"
39
+ )
40
+ end
41
+
42
+ def display_post_install_message
43
+ say <<~MESSAGE
44
+
45
+ Rails Pulse Cloud tables ready to install (#{separate_database? ? 'separate' : 'single'} database setup).
46
+
47
+ Next steps:
48
+ 1. Run: #{separate_database? ? 'rails db:migrate:rails_pulse' : 'rails db:migrate'}
49
+ 2. Set these in config/initializers/rails_pulse.rb (the key comes from your
50
+ Application's settings in Rails Pulse Cloud; application is its slug):
51
+
52
+ config.cloud.api_key = ENV["RAILS_PULSE_CLOUD_API_KEY"]
53
+ config.cloud.application = "your-app"
54
+
55
+ 3. Schedule the health update every minute, for example in config/recurring.yml:
56
+
57
+ rails_pulse_cloud_health:
58
+ class: RailsPulse::CloudHealthJob
59
+ schedule: every minute
60
+
61
+ The hourly sync needs no schedule: RailsPulse::SummaryJob starts it.
62
+ 4. Check what will be sent: rails rails_pulse:cloud:preview
63
+ 5. Restart your Rails server and workers
64
+
65
+ The schema file db/rails_pulse_cloud_schema.rb is the source of truth for
66
+ these tables, and rails_pulse:upgrade keeps it current.
67
+
68
+ MESSAGE
69
+ end
70
+
71
+ private
72
+
73
+ def separate_database?
74
+ return options[:database] == "separate" unless options[:database] == "detect"
75
+
76
+ has_separate_database_config?
77
+ end
78
+
79
+ def migration_dir
80
+ separate_database? ? "db/rails_pulse_migrate" : "db/migrate"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # Rails Pulse Cloud Database Schema
2
+ # The tables Rails Pulse Cloud's sync needs, kept apart from
3
+ # db/rails_pulse_schema.rb so an install that does not use Cloud never has
4
+ # them. Installed by `rails generate rails_pulse:install_cloud`; loaded by its
5
+ # migration, and by db:prepare on a separate Rails Pulse database.
6
+
7
+ RailsPulse::CloudSchema = lambda do |connection|
8
+ required_tables = [ :rails_pulse_cloud_installations, :rails_pulse_cloud_batches ]
9
+
10
+ missing_tables = required_tables.reject { |table| connection.table_exists?(table) }
11
+ if missing_tables.empty?
12
+ puts "[RailsPulse::CloudSchema] All Rails Pulse Cloud tables already exist. Skipping schema load."
13
+ return
14
+ end
15
+
16
+ puts "[RailsPulse::CloudSchema] Creating missing tables: #{missing_tables.join(', ')}"
17
+
18
+ unless connection.table_exists?(:rails_pulse_cloud_installations)
19
+ connection.create_table :rails_pulse_cloud_installations do |t|
20
+ t.string :installation_id, limit: 36, null: false, comment: "UUID naming this Rails Pulse database to Rails Pulse Cloud, generated once"
21
+ t.datetime :last_hour_sent_at, comment: "Start of the newest hour whose summaries were queued for Cloud"
22
+ t.datetime :exception_groups_sent_through, comment: "Exception groups updated before this were queued for Cloud"
23
+ t.datetime :deployments_sent_through, comment: "Deployments updated before this were queued for Cloud"
24
+ t.datetime :last_success_at, comment: "When Cloud last accepted a batch or health update"
25
+ t.datetime :last_health_at, comment: "When the last health update was sent"
26
+ t.text :last_error, comment: "The most recent failure, for rails_pulse:status"
27
+ t.datetime :last_error_at
28
+ t.datetime :paused_until, comment: "No sending before this, after Cloud refused the key, plan, application or contract"
29
+ t.text :pause_reason, comment: "Cloud's message for the pause"
30
+ t.string :contract_deprecated_on, comment: "Date from Cloud's Rails-Pulse-Contract-Deprecated header"
31
+ t.timestamps
32
+ end
33
+
34
+ connection.add_index :rails_pulse_cloud_installations, :installation_id, unique: true,
35
+ name: "index_rp_cloud_installations_on_installation_id"
36
+ end
37
+
38
+ unless connection.table_exists?(:rails_pulse_cloud_batches)
39
+ connection.create_table :rails_pulse_cloud_batches do |t|
40
+ t.string :batch_id, limit: 36, null: false, comment: "The batch's UUID, resent unchanged on every retry"
41
+ t.binary :payload, limit: 16.megabytes, null: false, comment: "The batch as gzipped JSON"
42
+ t.integer :byte_size, null: false, comment: "Size of the gzipped payload, for the buffer limit"
43
+ t.integer :item_count, null: false
44
+ t.integer :attempts, null: false, default: 0
45
+ t.datetime :next_attempt_at, null: false, comment: "Not sent before this; set by backoff and Retry-After"
46
+ t.text :last_error
47
+ t.timestamps
48
+ end
49
+
50
+ connection.add_index :rails_pulse_cloud_batches, :batch_id, unique: true, name: "index_rp_cloud_batches_on_batch_id"
51
+ connection.add_index :rails_pulse_cloud_batches, :next_attempt_at, name: "index_rp_cloud_batches_on_next_attempt_at"
52
+ connection.add_index :rails_pulse_cloud_batches, :created_at, name: "index_rp_cloud_batches_on_created_at"
53
+ end
54
+ end unless defined?(RailsPulse::CloudSchema)
55
+
56
+ if defined?(RailsPulse::ApplicationRecord)
57
+ RailsPulse::CloudSchema.call(RailsPulse::ApplicationRecord.connection)
58
+ end