rails_pulse 0.1.0 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -178
  3. data/Rakefile +75 -173
  4. data/app/assets/stylesheets/rails_pulse/application.css +0 -12
  5. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  6. data/app/controllers/concerns/response_range_concern.rb +6 -3
  7. data/app/controllers/concerns/time_range_concern.rb +5 -10
  8. data/app/controllers/concerns/zoom_range_concern.rb +1 -1
  9. data/app/controllers/rails_pulse/application_controller.rb +8 -4
  10. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  11. data/app/controllers/rails_pulse/queries_controller.rb +65 -50
  12. data/app/controllers/rails_pulse/requests_controller.rb +24 -12
  13. data/app/controllers/rails_pulse/routes_controller.rb +59 -24
  14. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  15. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  16. data/app/helpers/rails_pulse/chart_helper.rb +6 -2
  17. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  18. data/app/javascript/rails_pulse/controllers/index_controller.js +117 -33
  19. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  20. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  21. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  22. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  23. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  24. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +20 -9
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +19 -7
  26. data/app/models/rails_pulse/operation.rb +1 -1
  27. data/app/models/rails_pulse/queries/cards/average_query_times.rb +47 -23
  28. data/app/models/rails_pulse/queries/cards/execution_rate.rb +33 -26
  29. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +34 -45
  30. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  31. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  32. data/app/models/rails_pulse/query.rb +1 -0
  33. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  34. data/app/models/rails_pulse/route.rb +1 -6
  35. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -23
  36. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +38 -45
  37. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +34 -47
  38. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +30 -25
  39. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  40. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  41. data/app/models/rails_pulse/summary.rb +143 -0
  42. data/app/services/rails_pulse/summary_service.rb +199 -0
  43. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  44. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  45. data/app/views/rails_pulse/components/_metric_card.html.erb +10 -24
  46. data/app/views/rails_pulse/dashboard/index.html.erb +54 -36
  47. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  48. data/app/views/rails_pulse/queries/_table.html.erb +10 -12
  49. data/app/views/rails_pulse/queries/index.html.erb +41 -34
  50. data/app/views/rails_pulse/queries/show.html.erb +38 -31
  51. data/app/views/rails_pulse/requests/_operations.html.erb +32 -26
  52. data/app/views/rails_pulse/requests/_table.html.erb +1 -3
  53. data/app/views/rails_pulse/requests/index.html.erb +42 -34
  54. data/app/views/rails_pulse/routes/_table.html.erb +13 -13
  55. data/app/views/rails_pulse/routes/index.html.erb +43 -35
  56. data/app/views/rails_pulse/routes/show.html.erb +42 -35
  57. data/config/initializers/rails_pulse.rb +0 -12
  58. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  59. data/db/rails_pulse_schema.rb +121 -0
  60. data/lib/generators/rails_pulse/install_generator.rb +41 -4
  61. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  62. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  63. data/lib/rails_pulse/configuration.rb +6 -12
  64. data/lib/rails_pulse/engine.rb +0 -1
  65. data/lib/rails_pulse/version.rb +1 -1
  66. data/lib/tasks/rails_pulse.rake +58 -0
  67. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  68. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  69. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  70. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  71. data/public/rails-pulse-assets/search.svg +43 -0
  72. metadata +28 -12
  73. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  74. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  75. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  76. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  77. data/db/migrate/20250227235904_create_routes.rb +0 -12
  78. data/db/migrate/20250227235915_create_requests.rb +0 -19
  79. data/db/migrate/20250228000000_create_queries.rb +0 -14
  80. data/db/migrate/20250228000056_create_operations.rb +0 -24
  81. data/lib/rails_pulse/migration.rb +0 -29
@@ -1,11 +1,13 @@
1
1
  <%= render 'rails_pulse/components/breadcrumbs' %>
2
2
 
3
- <div class="row">
4
- <%= cached_component(component: "metric_card", id: "average_response_times", context: "requests", class: "grid-item block") %>
5
- <%= cached_component(component: "metric_card", id: "percentile_response_times", context: "requests", class: "grid-item block") %>
6
- <%= cached_component(component: "metric_card", id: "request_count_totals", context: "requests", class: "grid-item block") %>
7
- <%= cached_component(component: "metric_card", id: "error_rate_per_route", context: "requests", class: "grid-item block") %>
8
- </div>
3
+ <% unless turbo_frame_request? %>
4
+ <div class="row">
5
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_response_times_metric_card } %>
6
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @percentile_response_times_metric_card } %>
7
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @request_count_totals_metric_card } %>
8
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @error_rate_per_route_metric_card } %>
9
+ </div>
10
+ <% end %>
9
11
 
10
12
  <div
11
13
  class="row"
@@ -16,12 +18,12 @@
16
18
  <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', } do %>
17
19
  <%= search_form_for @ransack_query, url: requests_path, class: "flex items-center justify-between gap mb-4" do |form| %>
18
20
  <div class="flex items-center grow gap">
19
- <%= form.select :occurred_at_range,
21
+ <%= form.select :period_start_range,
20
22
  RailsPulse::RequestsController::TIME_RANGE_OPTIONS,
21
23
  { selected: @selected_time_range },
22
24
  { class: "input" }
23
25
  %>
24
- <%= form.select :duration,
26
+ <%= form.select :avg_duration,
25
27
  duration_options(:request),
26
28
  { selected: @selected_response_range },
27
29
  { class: "input" }
@@ -31,33 +33,39 @@
31
33
  </div>
32
34
  <% end %>
33
35
 
34
- <% if @chart_data.present? %>
35
- <div
36
- class="chart-container chart-container--slim"
37
- data-rails-pulse--index-target="chart"
38
- >
39
- <%= bar_chart(
40
- @chart_data,
41
- code: false,
42
- id: "average_response_times_chart",
43
- height: "100%",
44
- options: bar_chart_options(
45
- units: "ms",
46
- zoom: true,
47
- chart_start: 0,
48
- chart_end: @chart_data.length - 1,
49
- xaxis_formatter: @xaxis_formatter,
50
- tooltip_formatter: @tooltip_formatter,
51
- zoom_start: @zoom_start,
52
- zoom_end: @zoom_end,
53
- chart_data: @chart_data
54
- )
55
- ) %>
56
- </div>
57
- <% end %>
36
+ <% if @has_data %>
37
+ <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
38
+ <div
39
+ class="chart-container chart-container--slim"
40
+ data-rails-pulse--index-target="chart"
41
+ >
42
+ <%= bar_chart(
43
+ @chart_data,
44
+ code: false,
45
+ id: "average_response_times_chart",
46
+ height: "100%",
47
+ options: bar_chart_options(
48
+ units: "ms",
49
+ zoom: true,
50
+ chart_start: 0,
51
+ chart_end: @chart_data.length - 1,
52
+ xaxis_formatter: @xaxis_formatter,
53
+ tooltip_formatter: @tooltip_formatter,
54
+ zoom_start: @zoom_start,
55
+ zoom_end: @zoom_end,
56
+ chart_data: @chart_data
57
+ )
58
+ ) %>
59
+ </div>
60
+ <% end %>
58
61
 
59
- <%= turbo_frame_tag :requests_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
60
- <%= render 'rails_pulse/requests/table' %>
62
+ <%= turbo_frame_tag :requests_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
63
+ <%= render 'rails_pulse/requests/table' %>
64
+ <% end %>
65
+ <% else %>
66
+ <%= render 'rails_pulse/components/empty_state',
67
+ title: 'No request data found for the selected filters.',
68
+ description: 'Try adjusting your time range or filters to see results.' %>
61
69
  <% end %>
62
70
  <% end %>
63
71
  </div>
@@ -1,26 +1,26 @@
1
1
  <% columns = [
2
- { field: :path, label: 'Route', class: 'w-auto' },
3
- { field: :average_response_time_ms, label: 'Average Response Time', class: 'w-36' },
4
- { field: :max_response_time_ms, label: 'Max Response Time', class: 'w-32' },
5
- { field: :request_count, label: 'Requests', class: 'w-24' },
2
+ { field: :route_path, label: 'Route', class: 'w-auto' },
3
+ { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-36' },
4
+ { field: :max_duration_sort, label: 'Max Response Time', class: 'w-32' },
5
+ { field: :count_sort, label: 'Requests', class: 'w-24' },
6
6
  { field: :requests_per_minute, label: 'Requests Per Minute', class: 'w-28' },
7
7
  { field: :error_rate_percentage, label: 'Error Rate (%)', class: 'w-20' },
8
- { field: :status_indicator, label: 'Status', class: 'w-16' }
8
+ { field: :status_indicator, label: 'Status', class: 'w-16', sortable: false }
9
9
  ] %>
10
10
 
11
11
  <table class="table mbs-4">
12
12
  <%= render "rails_pulse/components/table_head", columns: columns %>
13
13
 
14
14
  <tbody>
15
- <% @table_data.each do |route| %>
15
+ <% @table_data.each do |summary| %>
16
16
  <tr>
17
- <td class="whitespace-nowrap"><%= link_to route.path_and_method, route, data: { turbo_frame: '_top' } %></td>
18
- <td class="whitespace-nowrap"><%= route.average_response_time_ms.to_i %> ms</td>
19
- <td class="whitespace-nowrap"><%= route.max_response_time_ms.to_i %> ms</td>
20
- <td class="whitespace-nowrap"><%= number_with_delimiter route.request_count %></td>
21
- <td class="whitespace-nowrap"><%= route.requests_per_minute < 1 ? '< 1' : route.requests_per_minute.round(2) %></td>
22
- <td class="whitespace-nowrap"><%= route.error_rate_percentage %>%</td>
23
- <td class="whitespace-nowrap text-center"><%= route_status_indicator(route.status_indicator) %></td>
17
+ <td class="whitespace-nowrap"><%= link_to "#{summary.path} #{summary.route_method}", route_path(summary.route_id), data: { turbo_frame: '_top' } %></td>
18
+ <td class="whitespace-nowrap"><%= summary.avg_duration.to_i %> ms</td>
19
+ <td class="whitespace-nowrap"><%= summary.max_duration.to_i %> ms</td>
20
+ <td class="whitespace-nowrap"><%= number_with_delimiter summary.count %></td>
21
+ <td class="whitespace-nowrap"><%= summary.count < 1 ? '< 1' : (summary.count / 60.0).round(2) %></td>
22
+ <td class="whitespace-nowrap"><%= ((summary.error_count.to_f / summary.count) * 100).round(2) %>%</td>
23
+ <td class="whitespace-nowrap text-center"><%= route_status_indicator(summary.avg_duration >= 500 ? 1 : 0) %></td>
24
24
  </tr>
25
25
  <% end %>
26
26
  </tbody>
@@ -1,11 +1,13 @@
1
1
  <%= render 'rails_pulse/components/breadcrumbs' %>
2
2
 
3
- <div class="row">
4
- <%= cached_component(component: "metric_card", id: "average_response_times", context: "routes", class: "grid-item block") %>
5
- <%= cached_component(component: "metric_card", id: "percentile_response_times", context: "routes", class: "grid-item block") %>
6
- <%= cached_component(component: "metric_card", id: "request_count_totals", context: "routes", class: "grid-item block") %>
7
- <%= cached_component(component: "metric_card", id: "error_rate_per_route", context: "routes", class: "grid-item block") %>
8
- </div>
3
+ <% unless turbo_frame_request? %>
4
+ <div class="row">
5
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
6
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @percentile_response_times_metric_card } %>
7
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @request_count_totals_metric_card } %>
8
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @error_rate_per_route_metric_card } %>
9
+ </div>
10
+ <% end %>
9
11
 
10
12
  <div
11
13
  class="row"
@@ -16,13 +18,13 @@
16
18
  <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', } do %>
17
19
  <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4" do |form| %>
18
20
  <div class="flex items-center grow gap">
19
- <%= form.search_field :path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
20
- <%= form.select :occurred_at_range,
21
+ <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
22
+ <%= form.select :period_start_range,
21
23
  RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
22
24
  { selected: @selected_time_range },
23
25
  { class: "input" }
24
26
  %>
25
- <%= form.select :duration,
27
+ <%= form.select :avg_duration,
26
28
  duration_options(:route),
27
29
  { selected: @selected_response_range },
28
30
  { class: "input" }
@@ -32,33 +34,39 @@
32
34
  </div>
33
35
  <% end %>
34
36
 
35
- <% if @chart_data.present? %>
36
- <div
37
- class="chart-container chart-container--slim"
38
- data-rails-pulse--index-target="chart"
39
- >
40
- <%= bar_chart(
41
- @chart_data,
42
- code: false,
43
- id: "average_response_times_chart",
44
- height: "100%",
45
- options: bar_chart_options(
46
- units: "ms",
47
- zoom: true,
48
- chart_start: 0,
49
- chart_end: @chart_data.length - 1,
50
- xaxis_formatter: @xaxis_formatter,
51
- tooltip_formatter: @tooltip_formatter,
52
- zoom_start: @zoom_start,
53
- zoom_end: @zoom_end,
54
- chart_data: @chart_data
55
- )
56
- ) %>
57
- </div>
58
- <% end %>
37
+ <% if @has_data %>
38
+ <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
39
+ <div
40
+ class="chart-container chart-container--slim"
41
+ data-rails-pulse--index-target="chart"
42
+ >
43
+ <%= bar_chart(
44
+ @chart_data,
45
+ code: false,
46
+ id: "average_response_times_chart",
47
+ height: "100%",
48
+ options: bar_chart_options(
49
+ units: "ms",
50
+ zoom: true,
51
+ chart_start: 0,
52
+ chart_end: @chart_data.length - 1,
53
+ xaxis_formatter: @xaxis_formatter,
54
+ tooltip_formatter: @tooltip_formatter,
55
+ zoom_start: @zoom_start,
56
+ zoom_end: @zoom_end,
57
+ chart_data: @chart_data
58
+ )
59
+ ) %>
60
+ </div>
61
+ <% end %>
59
62
 
60
- <%= turbo_frame_tag :routes_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
61
- <%= render 'rails_pulse/routes/table' %>
63
+ <%= turbo_frame_tag :routes_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
64
+ <%= render 'rails_pulse/routes/table' %>
65
+ <% end %>
66
+ <% else %>
67
+ <%= render 'rails_pulse/components/empty_state',
68
+ title: 'No route data found for the selected filters.',
69
+ description: 'Try adjusting your time range or filters to see results.' %>
62
70
  <% end %>
63
71
  <% end %>
64
72
  </div>
@@ -2,29 +2,30 @@
2
2
 
3
3
  <h1 class="text-2xl mbe-2"><%= @route.path_and_method %></h1>
4
4
 
5
- <div class="row">
6
- <%= cached_component(component: "metric_card", id: "average_response_times", context: "routes_#{@route.id}", class: "grid-item block") %>
7
- <%= cached_component(component: "metric_card", id: "percentile_response_times", context: "routes_#{@route.id}", class: "grid-item block") %>
8
- <%= cached_component(component: "metric_card", id: "request_count_totals", context: "routes_#{@route.id}", class: "grid-item block") %>
9
- <%= cached_component(component: "metric_card", id: "error_rate_per_route", context: "routes_#{@route.id}", class: "grid-item block") %>
10
- </div>
5
+ <% unless turbo_frame_request? %>
6
+ <div class="row">
7
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
8
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @percentile_response_times_metric_card } %>
9
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @request_count_totals_metric_card } %>
10
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @error_rate_per_route_metric_card } %>
11
+ </div>
12
+ <% end %>
11
13
 
12
14
  <div
13
15
  class="row"
14
16
  data-controller="rails-pulse--index"
15
17
  data-rails-pulse--index-chart-id-value="route_repsonses_chart"
16
- data-rails-pulse--index-occurred-at-param-value="occurred_at"
17
18
  >
18
19
  <div class="grid-item">
19
20
  <%= render 'rails_pulse/components/panel', { title: 'Route Reqeusts', } do %>
20
21
  <%= search_form_for @ransack_query, url: route_path(@route), class: "flex items-center justify-between gap mb-4" do |form| %>
21
22
  <div class="flex items-center grow gap">
22
- <%= form.select :occurred_at_range,
23
+ <%= form.select :period_start_range,
23
24
  RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
24
25
  { selected: @selected_time_range },
25
26
  { class: "input" }
26
27
  %>
27
- <%= form.select :duration,
28
+ <%= form.select :avg_duration,
28
29
  duration_options(:route),
29
30
  { selected: @selected_response_range },
30
31
  { class: "input" }
@@ -34,33 +35,39 @@
34
35
  </div>
35
36
  <% end %>
36
37
 
37
- <% if @chart_data.present? %>
38
- <div
39
- class="chart-container chart-container--slim"
40
- data-rails-pulse--index-target="chart"
41
- >
42
- <%= bar_chart(
43
- @chart_data,
44
- code: false,
45
- id: "route_repsonses_chart",
46
- height: "100%",
47
- options: bar_chart_options(
48
- units: "ms",
49
- zoom: true,
50
- chart_start: 0,
51
- chart_end: @chart_data.length - 1,
52
- xaxis_formatter: @xaxis_formatter,
53
- tooltip_formatter: @tooltip_formatter,
54
- zoom_start: @zoom_start,
55
- zoom_end: @zoom_end,
56
- chart_data: @chart_data
57
- )
58
- ) %>
59
- </div>
60
- <% end %>
38
+ <% if @has_data %>
39
+ <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
40
+ <div
41
+ class="chart-container chart-container--slim"
42
+ data-rails-pulse--index-target="chart"
43
+ >
44
+ <%= bar_chart(
45
+ @chart_data,
46
+ code: false,
47
+ id: "route_repsonses_chart",
48
+ height: "100%",
49
+ options: bar_chart_options(
50
+ units: "ms",
51
+ zoom: true,
52
+ chart_start: 0,
53
+ chart_end: @chart_data.length - 1,
54
+ xaxis_formatter: @xaxis_formatter,
55
+ tooltip_formatter: @tooltip_formatter,
56
+ zoom_start: @zoom_start,
57
+ zoom_end: @zoom_end,
58
+ chart_data: @chart_data
59
+ )
60
+ ) %>
61
+ </div>
62
+ <% end %>
61
63
 
62
- <%= turbo_frame_tag :index_table do %>
63
- <%= render 'rails_pulse/requests/table' %>
64
+ <%= turbo_frame_tag :index_table do %>
65
+ <%= render 'rails_pulse/requests/table' %>
66
+ <% end %>
67
+ <% else %>
68
+ <%= render 'rails_pulse/components/empty_state',
69
+ title: 'No route requests found for the selected filters.',
70
+ description: 'Try adjusting your time range or filters to see results.' %>
64
71
  <% end %>
65
72
  <% end %>
66
73
  </div>
@@ -71,18 +71,6 @@ RailsPulse.configure do |config|
71
71
  config.ignored_requests = []
72
72
  config.ignored_queries = []
73
73
 
74
- # ====================================================================================================
75
- # CACHING
76
- # ====================================================================================================
77
- # Configure metric card caching to improve performance of the Rails Pulse dashboard.
78
- # Caching reduces database load for expensive metric calculations.
79
-
80
- # Enable/disable metric card caching
81
- config.component_cache_enabled = true
82
-
83
- # How long to cache metric card results
84
- config.component_cache_duration = 1.hour
85
-
86
74
  # ====================================================================================================
87
75
  # DATABASE CONFIGURATION
88
76
  # ====================================================================================================
@@ -0,0 +1,54 @@
1
+ class CreateRailsPulseSummaries < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :rails_pulse_summaries do |t|
4
+ # Time fields
5
+ t.datetime :period_start, null: false
6
+ t.datetime :period_end, null: false
7
+ t.string :period_type, null: false # 'hour', 'day', 'week', 'month'
8
+
9
+ # Polymorphic association to handle both routes and queries
10
+ t.references :summarizable, polymorphic: true, null: false, index: true
11
+ # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
12
+ # and summarizable_id (route_id or query_id)
13
+
14
+ # Universal metrics
15
+ t.integer :count, default: 0, null: false
16
+ t.float :avg_duration
17
+ t.float :min_duration
18
+ t.float :max_duration
19
+ t.float :p50_duration
20
+ t.float :p95_duration
21
+ t.float :p99_duration
22
+ t.float :total_duration
23
+ t.float :stddev_duration
24
+
25
+ # Request/Route specific metrics
26
+ t.integer :error_count, default: 0
27
+ t.integer :success_count, default: 0
28
+ t.integer :status_2xx, default: 0
29
+ t.integer :status_3xx, default: 0
30
+ t.integer :status_4xx, default: 0
31
+ t.integer :status_5xx, default: 0
32
+
33
+ t.timestamps
34
+
35
+ # Unique constraint and indexes
36
+ t.index [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
37
+ unique: true,
38
+ name: 'idx_pulse_summaries_unique'
39
+ t.index [ :period_type, :period_start ]
40
+ t.index :created_at
41
+ end
42
+
43
+ # Add indexes to existing tables for efficient aggregation
44
+ add_index :rails_pulse_requests, [ :created_at, :route_id ],
45
+ name: 'idx_requests_for_aggregation'
46
+ add_index :rails_pulse_requests, :created_at,
47
+ name: 'idx_requests_created_at'
48
+
49
+ add_index :rails_pulse_operations, [ :created_at, :query_id ],
50
+ name: 'idx_operations_for_aggregation'
51
+ add_index :rails_pulse_operations, :created_at,
52
+ name: 'idx_operations_created_at'
53
+ end
54
+ end
@@ -0,0 +1,121 @@
1
+ # Rails Pulse Database Schema
2
+ # This file contains the complete schema for Rails Pulse tables
3
+ # Load with: rails db:schema:load:rails_pulse or db:prepare
4
+
5
+ RailsPulse::Schema = lambda do |connection|
6
+ # Skip if all tables already exist to prevent conflicts
7
+ required_tables = [:rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries]
8
+
9
+ if ENV["CI"] == "true"
10
+ existing_tables = required_tables.select { |table| connection.table_exists?(table) }
11
+ missing_tables = required_tables - existing_tables
12
+ puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any?
13
+ puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any?
14
+ end
15
+
16
+ return if required_tables.all? { |table| connection.table_exists?(table) }
17
+
18
+ connection.create_table :rails_pulse_routes do |t|
19
+ t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
20
+ t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
21
+ t.timestamps
22
+ end
23
+
24
+ connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
25
+
26
+ connection.create_table :rails_pulse_queries do |t|
27
+ t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
28
+ t.timestamps
29
+ end
30
+
31
+ connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191
32
+
33
+ connection.create_table :rails_pulse_requests do |t|
34
+ t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
35
+ t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
36
+ t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
37
+ t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
38
+ t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
39
+ t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
40
+ t.timestamp :occurred_at, null: false, comment: "When the request started"
41
+ t.timestamps
42
+ end
43
+
44
+ connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at"
45
+ connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid"
46
+ connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at"
47
+
48
+ connection.create_table :rails_pulse_operations do |t|
49
+ t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request"
50
+ t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query"
51
+ t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)"
52
+ t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)"
53
+ t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds"
54
+ t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)"
55
+ t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds"
56
+ t.timestamp :occurred_at, null: false, comment: "When the request started"
57
+ t.timestamps
58
+ end
59
+
60
+ connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type"
61
+ connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at"
62
+ connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
63
+ connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
64
+ connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
65
+
66
+ connection.create_table :rails_pulse_summaries do |t|
67
+ # Time fields
68
+ t.datetime :period_start, null: false, comment: "Start of the aggregation period"
69
+ t.datetime :period_end, null: false, comment: "End of the aggregation period"
70
+ t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
71
+
72
+ # Polymorphic association to handle both routes and queries
73
+ t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
74
+ # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
75
+ # and summarizable_id (route_id or query_id)
76
+
77
+ # Universal metrics
78
+ t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
79
+ t.float :avg_duration, comment: "Average duration in milliseconds"
80
+ t.float :min_duration, comment: "Minimum duration in milliseconds"
81
+ t.float :max_duration, comment: "Maximum duration in milliseconds"
82
+ t.float :p50_duration, comment: "50th percentile duration"
83
+ t.float :p95_duration, comment: "95th percentile duration"
84
+ t.float :p99_duration, comment: "99th percentile duration"
85
+ t.float :total_duration, comment: "Total duration in milliseconds"
86
+ t.float :stddev_duration, comment: "Standard deviation of duration"
87
+
88
+ # Request/Route specific metrics
89
+ t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
90
+ t.integer :success_count, default: 0, comment: "Number of successful responses"
91
+ t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
92
+ t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
93
+ t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
94
+ t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
95
+
96
+ t.timestamps
97
+ end
98
+
99
+ # Unique constraint and indexes for summaries
100
+ connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
101
+ unique: true,
102
+ name: "idx_pulse_summaries_unique"
103
+ connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
104
+ connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
105
+
106
+ # Add indexes to existing tables for efficient aggregation
107
+ connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
108
+ connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
109
+
110
+ connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
111
+ connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
112
+
113
+ if ENV["CI"] == "true"
114
+ created_tables = required_tables.select { |table| connection.table_exists?(table) }
115
+ puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}"
116
+ end
117
+ end
118
+
119
+ if defined?(RailsPulse::ApplicationRecord)
120
+ RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection)
121
+ end
@@ -3,15 +3,52 @@ module RailsPulse
3
3
  class InstallGenerator < Rails::Generators::Base
4
4
  source_root File.expand_path("templates", __dir__)
5
5
 
6
- desc "Copies Rails Pulse migrations to the application."
7
- def copy_migrations
8
- rake "rails_pulse:install:migrations"
6
+ desc "Install Rails Pulse with schema-based setup"
7
+
8
+ def copy_schema
9
+ copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb"
9
10
  end
10
11
 
11
- desc "Copies Rails Pulse example configuration file to the application."
12
12
  def copy_initializer
13
13
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
14
14
  end
15
+
16
+ def create_database_migration_paths
17
+ if separate_database?
18
+ create_file "db/rails_pulse_migrate/.keep"
19
+ end
20
+ end
21
+
22
+ def display_post_install_message
23
+ say <<~MESSAGE
24
+
25
+ Rails Pulse installation complete!
26
+
27
+ Next steps:
28
+ 1. Configure your database in config/database.yml (see README for examples)
29
+ 2. Run: rails db:prepare (creates database and loads schema)
30
+ 3. Restart your Rails server
31
+
32
+ For separate database setup, add to config/database.yml:
33
+ #{environment}:
34
+ rails_pulse:
35
+ <<: *default
36
+ database: storage/#{environment}_rails_pulse.sqlite3
37
+ migrations_paths: db/rails_pulse_migrate
38
+
39
+ MESSAGE
40
+ end
41
+
42
+ private
43
+
44
+ def separate_database?
45
+ # Could make this configurable via options
46
+ false
47
+ end
48
+
49
+ def environment
50
+ Rails.env.production? ? "production" : "development"
51
+ end
15
52
  end
16
53
  end
17
54
  end