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.
- checksums.yaml +4 -4
- data/README.md +74 -178
- data/Rakefile +75 -173
- data/app/assets/stylesheets/rails_pulse/application.css +0 -12
- data/app/controllers/concerns/chart_table_concern.rb +21 -4
- data/app/controllers/concerns/response_range_concern.rb +6 -3
- data/app/controllers/concerns/time_range_concern.rb +5 -10
- data/app/controllers/concerns/zoom_range_concern.rb +1 -1
- data/app/controllers/rails_pulse/application_controller.rb +8 -4
- data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
- data/app/controllers/rails_pulse/queries_controller.rb +65 -50
- data/app/controllers/rails_pulse/requests_controller.rb +24 -12
- data/app/controllers/rails_pulse/routes_controller.rb +59 -24
- data/app/helpers/rails_pulse/application_helper.rb +0 -1
- data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
- data/app/helpers/rails_pulse/chart_helper.rb +6 -2
- data/app/helpers/rails_pulse/status_helper.rb +10 -4
- data/app/javascript/rails_pulse/controllers/index_controller.js +117 -33
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
- data/app/jobs/rails_pulse/summary_job.rb +53 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +20 -9
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +19 -7
- data/app/models/rails_pulse/operation.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +47 -23
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +33 -26
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +34 -45
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
- data/app/models/rails_pulse/queries/tables/index.rb +74 -0
- data/app/models/rails_pulse/query.rb +1 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
- data/app/models/rails_pulse/route.rb +1 -6
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -23
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +38 -45
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +34 -47
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +30 -25
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
- data/app/models/rails_pulse/routes/tables/index.rb +57 -40
- data/app/models/rails_pulse/summary.rb +143 -0
- data/app/services/rails_pulse/summary_service.rb +199 -0
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +10 -24
- data/app/views/rails_pulse/dashboard/index.html.erb +54 -36
- data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +10 -12
- data/app/views/rails_pulse/queries/index.html.erb +41 -34
- data/app/views/rails_pulse/queries/show.html.erb +38 -31
- data/app/views/rails_pulse/requests/_operations.html.erb +32 -26
- data/app/views/rails_pulse/requests/_table.html.erb +1 -3
- data/app/views/rails_pulse/requests/index.html.erb +42 -34
- data/app/views/rails_pulse/routes/_table.html.erb +13 -13
- data/app/views/rails_pulse/routes/index.html.erb +43 -35
- data/app/views/rails_pulse/routes/show.html.erb +42 -35
- data/config/initializers/rails_pulse.rb +0 -12
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
- data/db/rails_pulse_schema.rb +121 -0
- data/lib/generators/rails_pulse/install_generator.rb +41 -4
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
- data/lib/rails_pulse/configuration.rb +6 -12
- data/lib/rails_pulse/engine.rb +0 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +58 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
- data/public/rails-pulse-assets/search.svg +43 -0
- metadata +28 -12
- data/app/controllers/rails_pulse/caches_controller.rb +0 -115
- data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
- data/app/models/rails_pulse/component_cache_key.rb +0 -33
- data/app/views/rails_pulse/caches/show.html.erb +0 -9
- data/db/migrate/20250227235904_create_routes.rb +0 -12
- data/db/migrate/20250227235915_create_requests.rb +0 -19
- data/db/migrate/20250228000000_create_queries.rb +0 -14
- data/db/migrate/20250228000056_create_operations.rb +0 -24
- data/lib/rails_pulse/migration.rb +0 -29
@@ -7,44 +7,49 @@ module RailsPulse
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def to_metric_card
|
10
|
-
|
11
|
-
|
12
|
-
else
|
13
|
-
RailsPulse::Request.all
|
14
|
-
end
|
10
|
+
last_7_days = 7.days.ago.beginning_of_day
|
11
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
15
12
|
|
16
|
-
|
13
|
+
# Single query to get all count metrics with conditional aggregation
|
14
|
+
base_query = RailsPulse::Summary.where(
|
15
|
+
summarizable_type: "RailsPulse::Route",
|
16
|
+
period_type: "day",
|
17
|
+
period_start: 2.weeks.ago.beginning_of_day..Time.current
|
18
|
+
)
|
19
|
+
base_query = base_query.where(summarizable_id: @route.id) if @route
|
17
20
|
|
18
|
-
|
19
|
-
|
21
|
+
metrics = base_query.select(
|
22
|
+
"SUM(count) AS total_count",
|
23
|
+
"SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS current_count",
|
24
|
+
"SUM(CASE WHEN period_start >= '#{previous_7_days.strftime('%Y-%m-%d %H:%M:%S')}' AND period_start < '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS previous_count"
|
25
|
+
).take
|
20
26
|
|
21
|
-
# Calculate
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
previous_period_count = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
|
27
|
+
# Calculate metrics from single query result
|
28
|
+
total_request_count = metrics.total_count || 0
|
29
|
+
current_period_count = metrics.current_count || 0
|
30
|
+
previous_period_count = metrics.previous_count || 0
|
26
31
|
|
27
32
|
percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
|
28
33
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
|
29
34
|
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
|
30
35
|
|
31
|
-
|
32
|
-
|
33
|
-
.
|
34
|
-
.
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
}
|
36
|
+
# Separate query for sparkline data - group by week using Rails
|
37
|
+
sparkline_data = base_query
|
38
|
+
.group_by_week(:period_start, time_zone: "UTC")
|
39
|
+
.sum(:count)
|
40
|
+
.each_with_object({}) do |(week_start, total_count), hash|
|
41
|
+
formatted_date = week_start.strftime("%b %-d")
|
42
|
+
value = total_count || 0
|
43
|
+
hash[formatted_date] = { value: value }
|
39
44
|
end
|
40
45
|
|
41
|
-
# Calculate average requests per minute
|
42
|
-
|
43
|
-
max_time = requests.maximum(:occurred_at)
|
44
|
-
total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
|
46
|
+
# Calculate average requests per minute over 2-week period
|
47
|
+
total_minutes = 2.weeks / 1.minute
|
45
48
|
average_requests_per_minute = total_request_count / total_minutes
|
46
49
|
|
47
50
|
{
|
51
|
+
id: "request_count_totals",
|
52
|
+
context: "routes",
|
48
53
|
title: "Request Count Total",
|
49
54
|
summary: "#{average_requests_per_minute.round(2)} / min",
|
50
55
|
line_chart_data: sparkline_data,
|
@@ -2,112 +2,35 @@ module RailsPulse
|
|
2
2
|
module Routes
|
3
3
|
module Charts
|
4
4
|
class AverageResponseTimes
|
5
|
-
def initialize(ransack_query:,
|
5
|
+
def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil)
|
6
6
|
@ransack_query = ransack_query
|
7
|
-
@
|
7
|
+
@period_type = period_type
|
8
8
|
@route = route
|
9
|
+
@start_time = start_time
|
10
|
+
@end_time = end_time
|
11
|
+
@start_duration = start_duration
|
9
12
|
end
|
10
13
|
|
11
14
|
def to_rails_chart
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
.average("rails_pulse_requests.duration")
|
30
|
-
end
|
31
|
-
|
32
|
-
# Create full time range and fill in missing periods
|
33
|
-
fill_missing_periods(actual_data)
|
34
|
-
end
|
35
|
-
|
36
|
-
private
|
37
|
-
|
38
|
-
def fill_missing_periods(actual_data)
|
39
|
-
# Extract actual time range from ransack query conditions
|
40
|
-
start_time, end_time = extract_time_range_from_ransack
|
41
|
-
|
42
|
-
# Create time range based on grouping type
|
43
|
-
case @group_by
|
44
|
-
when :group_by_hour
|
45
|
-
time_range = generate_hour_range(start_time, end_time)
|
46
|
-
else # :group_by_day
|
47
|
-
time_range = generate_day_range(start_time, end_time)
|
48
|
-
end
|
49
|
-
|
50
|
-
# Fill in all periods with zero values for missing periods
|
51
|
-
time_range.each_with_object({}) do |period, result|
|
52
|
-
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
53
|
-
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
54
|
-
|
55
|
-
normalized_occurred_at =
|
56
|
-
case @group_by
|
57
|
-
when :group_by_hour
|
58
|
-
occurred_at&.beginning_of_hour || occurred_at
|
59
|
-
when :group_by_day
|
60
|
-
occurred_at&.beginning_of_day || occurred_at
|
61
|
-
else
|
62
|
-
occurred_at
|
63
|
-
end
|
64
|
-
|
65
|
-
# Use actual data if available, otherwise default to 0
|
66
|
-
average_duration = actual_data[period] || 0
|
67
|
-
result[normalized_occurred_at.to_i] = {
|
68
|
-
value: average_duration.to_f
|
69
|
-
}
|
70
|
-
end
|
71
|
-
end
|
72
|
-
|
73
|
-
def generate_day_range(start_time, end_time)
|
74
|
-
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
75
|
-
end
|
76
|
-
|
77
|
-
def generate_hour_range(start_time, end_time)
|
78
|
-
current = start_time
|
79
|
-
hours = []
|
80
|
-
while current <= end_time
|
81
|
-
hours << current
|
82
|
-
current += 1.hour
|
83
|
-
end
|
84
|
-
hours
|
85
|
-
end
|
86
|
-
|
87
|
-
def extract_time_range_from_ransack
|
88
|
-
# Extract time range from ransack conditions
|
89
|
-
conditions = @ransack_query.conditions
|
90
|
-
|
91
|
-
if @route
|
92
|
-
# For specific route queries, look for occurred_at conditions
|
93
|
-
start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
|
94
|
-
end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
|
95
|
-
else
|
96
|
-
# For general route queries, look for requests_occurred_at conditions
|
97
|
-
start_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "gteq" }
|
98
|
-
end_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "lt" }
|
99
|
-
end
|
100
|
-
|
101
|
-
start_time = start_condition&.v || 2.weeks.ago
|
102
|
-
end_time = end_condition&.v || Time.current
|
103
|
-
|
104
|
-
# Normalize time boundaries based on grouping
|
105
|
-
case @group_by
|
106
|
-
when :group_by_hour
|
107
|
-
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
108
|
-
else
|
109
|
-
[ start_time.beginning_of_day, end_time.beginning_of_day ]
|
15
|
+
summaries = @ransack_query.result(distinct: false).where(
|
16
|
+
summarizable_type: "RailsPulse::Route",
|
17
|
+
period_type: @period_type
|
18
|
+
)
|
19
|
+
|
20
|
+
summaries = summaries.where(summarizable_id: @route.id) if @route
|
21
|
+
summaries = summaries
|
22
|
+
.group(:period_start)
|
23
|
+
.having("AVG(avg_duration) > ?", @start_duration || 0)
|
24
|
+
.average(:avg_duration)
|
25
|
+
.transform_keys(&:to_i)
|
26
|
+
|
27
|
+
# Pad missing data points with zeros
|
28
|
+
step = @period_type == :hour ? 1.hour : 1.day
|
29
|
+
data = {}
|
30
|
+
(@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
|
31
|
+
data[timestamp.to_i] = summaries[timestamp.to_i].to_f.round(2)
|
110
32
|
end
|
33
|
+
data
|
111
34
|
end
|
112
35
|
end
|
113
36
|
end
|
@@ -2,60 +2,77 @@ module RailsPulse
|
|
2
2
|
module Routes
|
3
3
|
module Tables
|
4
4
|
class Index
|
5
|
-
def initialize(ransack_query:, start_time:, params:)
|
5
|
+
def initialize(ransack_query:, period_type: nil, start_time:, params:)
|
6
6
|
@ransack_query = ransack_query
|
7
|
+
@period_type = period_type
|
7
8
|
@start_time = start_time
|
8
9
|
@params = params
|
9
10
|
end
|
10
11
|
|
11
12
|
def to_table
|
12
|
-
#
|
13
|
-
|
13
|
+
# Check if we have explicit ransack sorts
|
14
|
+
has_sorts = @ransack_query.sorts.any?
|
14
15
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
16
|
+
base_query = @ransack_query.result(distinct: false)
|
17
|
+
.joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
|
18
|
+
.where(
|
19
|
+
summarizable_type: "RailsPulse::Route",
|
20
|
+
period_type: @period_type
|
21
|
+
)
|
20
22
|
|
21
|
-
|
23
|
+
base_query = base_query.where(summarizable_id: @route.id) if @route
|
22
24
|
|
23
|
-
|
24
|
-
|
25
|
-
.group(
|
25
|
+
# Apply grouping and aggregation
|
26
|
+
grouped_query = base_query
|
27
|
+
.group(
|
28
|
+
"rails_pulse_summaries.summarizable_id",
|
29
|
+
"rails_pulse_summaries.summarizable_type",
|
30
|
+
"rails_pulse_routes.id",
|
31
|
+
"rails_pulse_routes.path",
|
32
|
+
"rails_pulse_routes.method"
|
33
|
+
)
|
26
34
|
.select(
|
27
|
-
"
|
28
|
-
"
|
29
|
-
"
|
30
|
-
"
|
31
|
-
"
|
32
|
-
"
|
33
|
-
"
|
34
|
-
"
|
35
|
+
"rails_pulse_summaries.summarizable_id",
|
36
|
+
"rails_pulse_summaries.summarizable_type",
|
37
|
+
"rails_pulse_routes.id as route_id",
|
38
|
+
"rails_pulse_routes.path",
|
39
|
+
"rails_pulse_routes.method as route_method",
|
40
|
+
"AVG(rails_pulse_summaries.avg_duration) as avg_duration",
|
41
|
+
"MAX(rails_pulse_summaries.max_duration) as max_duration",
|
42
|
+
"SUM(rails_pulse_summaries.count) as count",
|
43
|
+
"SUM(rails_pulse_summaries.error_count) as error_count",
|
44
|
+
"SUM(rails_pulse_summaries.success_count) as success_count"
|
35
45
|
)
|
36
|
-
end
|
37
|
-
|
38
|
-
private
|
39
46
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
47
|
+
# Apply sorting based on ransack sorts or use default
|
48
|
+
if has_sorts
|
49
|
+
# Apply custom sorting based on ransack parameters
|
50
|
+
sort = @ransack_query.sorts.first
|
51
|
+
direction = sort.dir == "desc" ? :desc : :asc
|
44
52
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
53
|
+
case sort.name
|
54
|
+
when "avg_duration_sort"
|
55
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
|
56
|
+
when "max_duration_sort"
|
57
|
+
grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
|
58
|
+
when "count_sort"
|
59
|
+
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
|
60
|
+
when "requests_per_minute"
|
61
|
+
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
|
62
|
+
when "error_rate_percentage"
|
63
|
+
grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
|
64
|
+
when "route_path"
|
65
|
+
grouped_query = grouped_query.order(Arel.sql("rails_pulse_routes.path").send(direction))
|
66
|
+
else
|
67
|
+
# Unknown sort field, fallback to default
|
68
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
|
69
|
+
end
|
70
|
+
else
|
71
|
+
# Apply default sort when no explicit sort is provided (matches controller default_table_sort)
|
72
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
|
73
|
+
end
|
50
74
|
|
51
|
-
|
52
|
-
CASE
|
53
|
-
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{critical} THEN 3
|
54
|
-
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{very_slow} THEN 2
|
55
|
-
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{slow} THEN 1
|
56
|
-
ELSE 0
|
57
|
-
END
|
58
|
-
SQL
|
75
|
+
grouped_query
|
59
76
|
end
|
60
77
|
end
|
61
78
|
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Summary < RailsPulse::ApplicationRecord
|
3
|
+
self.table_name = "rails_pulse_summaries"
|
4
|
+
|
5
|
+
PERIOD_TYPES = %w[hour day week month].freeze
|
6
|
+
|
7
|
+
# Polymorphic association
|
8
|
+
belongs_to :summarizable, polymorphic: true, optional: true # Optional for Request summaries
|
9
|
+
|
10
|
+
# Convenience associations for easier querying
|
11
|
+
belongs_to :route, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Route" }) },
|
12
|
+
foreign_key: "summarizable_id", class_name: "RailsPulse::Route", optional: true
|
13
|
+
belongs_to :query, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Query" }) },
|
14
|
+
foreign_key: "summarizable_id", class_name: "RailsPulse::Query", optional: true
|
15
|
+
|
16
|
+
# Validations
|
17
|
+
validates :period_type, inclusion: { in: PERIOD_TYPES }
|
18
|
+
validates :period_start, presence: true
|
19
|
+
validates :period_end, presence: true
|
20
|
+
|
21
|
+
# Scopes
|
22
|
+
scope :for_period_type, ->(type) { where(period_type: type) }
|
23
|
+
scope :for_date_range, ->(start_date, end_date) {
|
24
|
+
where(period_start: start_date..end_date)
|
25
|
+
}
|
26
|
+
scope :for_requests, -> { where(summarizable_type: "RailsPulse::Request") }
|
27
|
+
scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") }
|
28
|
+
scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") }
|
29
|
+
scope :recent, -> { order(period_start: :desc) }
|
30
|
+
|
31
|
+
# Special scope for overall request summaries
|
32
|
+
scope :overall_requests, -> {
|
33
|
+
where(summarizable_type: "RailsPulse::Request", summarizable_id: 0)
|
34
|
+
}
|
35
|
+
|
36
|
+
# Ransack configuration
|
37
|
+
def self.ransackable_attributes(auth_object = nil)
|
38
|
+
%w[
|
39
|
+
period_start period_end avg_duration max_duration count error_count
|
40
|
+
requests_per_minute error_rate_percentage route_path_cont
|
41
|
+
execution_count total_time_consumed normalized_sql
|
42
|
+
]
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.ransackable_associations(auth_object = nil)
|
46
|
+
%w[route query]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Custom ransackers for calculated fields (designed to work with GROUP BY queries)
|
50
|
+
ransacker :count do
|
51
|
+
Arel.sql("SUM(rails_pulse_summaries.count)") # Use SUM for proper grouping
|
52
|
+
end
|
53
|
+
|
54
|
+
ransacker :requests_per_minute do
|
55
|
+
Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
|
56
|
+
end
|
57
|
+
|
58
|
+
ransacker :error_rate_percentage do
|
59
|
+
Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)") # Use SUM for both
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# Ransacker for route path sorting (when joined with routes table)
|
64
|
+
ransacker :route_path do
|
65
|
+
Arel.sql("rails_pulse_routes.path")
|
66
|
+
end
|
67
|
+
|
68
|
+
# Ransacker for route path filtering using subquery (works without JOIN)
|
69
|
+
ransacker :route_path_cont do |parent|
|
70
|
+
Arel.sql(<<-SQL)
|
71
|
+
rails_pulse_summaries.summarizable_id IN (
|
72
|
+
SELECT id FROM rails_pulse_routes
|
73
|
+
WHERE rails_pulse_routes.path LIKE '%' || ? || '%'
|
74
|
+
)
|
75
|
+
SQL
|
76
|
+
end
|
77
|
+
|
78
|
+
# Sorting-specific ransackers for GROUP BY compatibility (used only in ORDER BY)
|
79
|
+
# These use different names to avoid conflicts with filtering
|
80
|
+
ransacker :avg_duration_sort do
|
81
|
+
Arel.sql("AVG(rails_pulse_summaries.avg_duration)")
|
82
|
+
end
|
83
|
+
|
84
|
+
ransacker :max_duration_sort do
|
85
|
+
Arel.sql("MAX(rails_pulse_summaries.max_duration)")
|
86
|
+
end
|
87
|
+
|
88
|
+
ransacker :count_sort do
|
89
|
+
Arel.sql("SUM(rails_pulse_summaries.count)")
|
90
|
+
end
|
91
|
+
|
92
|
+
ransacker :error_count_sort do
|
93
|
+
Arel.sql("SUM(rails_pulse_summaries.error_count)")
|
94
|
+
end
|
95
|
+
|
96
|
+
ransacker :success_count_sort do
|
97
|
+
Arel.sql("SUM(rails_pulse_summaries.success_count)")
|
98
|
+
end
|
99
|
+
|
100
|
+
ransacker :total_time_consumed_sort do
|
101
|
+
Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)")
|
102
|
+
end
|
103
|
+
|
104
|
+
# Alias execution_count_sort to count_sort for queries table compatibility
|
105
|
+
ransacker :execution_count_sort do
|
106
|
+
Arel.sql("SUM(rails_pulse_summaries.count)")
|
107
|
+
end
|
108
|
+
|
109
|
+
# Ransackers for queries table calculated fields
|
110
|
+
ransacker :execution_count do
|
111
|
+
Arel.sql("SUM(rails_pulse_summaries.count)") # Total executions
|
112
|
+
end
|
113
|
+
|
114
|
+
ransacker :total_time_consumed do
|
115
|
+
Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)") # Total time consumed
|
116
|
+
end
|
117
|
+
|
118
|
+
# Ransacker for query SQL sorting (when joined with queries table)
|
119
|
+
ransacker :normalized_sql do
|
120
|
+
Arel.sql("rails_pulse_queries.normalized_sql")
|
121
|
+
end
|
122
|
+
|
123
|
+
class << self
|
124
|
+
def calculate_period_end(period_type, start_time)
|
125
|
+
case period_type
|
126
|
+
when "hour" then start_time.end_of_hour
|
127
|
+
when "day" then start_time.end_of_day
|
128
|
+
when "week" then start_time.end_of_week
|
129
|
+
when "month" then start_time.end_of_month
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def normalize_period_start(period_type, time)
|
134
|
+
case period_type
|
135
|
+
when "hour" then time.beginning_of_hour
|
136
|
+
when "day" then time.beginning_of_day
|
137
|
+
when "week" then time.beginning_of_week
|
138
|
+
when "month" then time.beginning_of_month
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
|
2
|
+
module RailsPulse
|
3
|
+
class SummaryService
|
4
|
+
attr_reader :period_type, :start_time, :end_time
|
5
|
+
|
6
|
+
def initialize(period_type, start_time)
|
7
|
+
@period_type = period_type
|
8
|
+
@start_time = Summary.normalize_period_start(period_type, start_time)
|
9
|
+
@end_time = Summary.calculate_period_end(period_type, @start_time)
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform
|
13
|
+
Rails.logger.info "[RailsPulse] Starting #{period_type} summary for #{start_time}"
|
14
|
+
|
15
|
+
ActiveRecord::Base.transaction do
|
16
|
+
aggregate_requests # Overall system metrics
|
17
|
+
aggregate_routes # Per-route metrics
|
18
|
+
aggregate_queries # Per-query metrics
|
19
|
+
end
|
20
|
+
|
21
|
+
Rails.logger.info "[RailsPulse] Completed #{period_type} summary"
|
22
|
+
rescue => e
|
23
|
+
Rails.logger.error "[RailsPulse] Summary failed: #{e.message}"
|
24
|
+
raise
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def aggregate_requests
|
30
|
+
# Create a single summary for ALL requests in this period
|
31
|
+
requests = Request.where(occurred_at: start_time...end_time)
|
32
|
+
|
33
|
+
return if requests.empty?
|
34
|
+
|
35
|
+
# Get all durations and statuses for percentile calculations
|
36
|
+
request_data = requests.pluck(:duration, :status)
|
37
|
+
durations = request_data.map(&:first).compact.sort
|
38
|
+
statuses = request_data.map(&:second)
|
39
|
+
|
40
|
+
# Find or create the overall request summary
|
41
|
+
summary = Summary.find_or_initialize_by(
|
42
|
+
summarizable_type: "RailsPulse::Request",
|
43
|
+
summarizable_id: 0, # Use 0 as a special ID for overall summaries
|
44
|
+
period_type: period_type,
|
45
|
+
period_start: start_time
|
46
|
+
)
|
47
|
+
|
48
|
+
summary.assign_attributes(
|
49
|
+
period_end: end_time,
|
50
|
+
count: durations.size,
|
51
|
+
avg_duration: durations.any? ? durations.sum.to_f / durations.size : 0,
|
52
|
+
min_duration: durations.min,
|
53
|
+
max_duration: durations.max,
|
54
|
+
total_duration: durations.sum,
|
55
|
+
p50_duration: calculate_percentile(durations, 0.5),
|
56
|
+
p95_duration: calculate_percentile(durations, 0.95),
|
57
|
+
p99_duration: calculate_percentile(durations, 0.99),
|
58
|
+
stddev_duration: calculate_stddev(durations, durations.sum.to_f / durations.size),
|
59
|
+
error_count: statuses.count { |s| s >= 400 },
|
60
|
+
success_count: statuses.count { |s| s < 400 },
|
61
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
62
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
63
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
64
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
65
|
+
)
|
66
|
+
|
67
|
+
summary.save!
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def aggregate_routes
|
73
|
+
# Use ActiveRecord for cross-database compatibility
|
74
|
+
route_groups = Request
|
75
|
+
.where(occurred_at: start_time...end_time)
|
76
|
+
.where.not(route_id: nil)
|
77
|
+
.group(:route_id)
|
78
|
+
|
79
|
+
# Calculate basic aggregates
|
80
|
+
basic_stats = route_groups.pluck(
|
81
|
+
:route_id,
|
82
|
+
Arel.sql("COUNT(*) as request_count"),
|
83
|
+
Arel.sql("AVG(duration) as avg_duration"),
|
84
|
+
Arel.sql("MIN(duration) as min_duration"),
|
85
|
+
Arel.sql("MAX(duration) as max_duration"),
|
86
|
+
Arel.sql("SUM(duration) as total_duration")
|
87
|
+
)
|
88
|
+
|
89
|
+
basic_stats.each do |stats|
|
90
|
+
route_id = stats[0]
|
91
|
+
|
92
|
+
# Calculate percentiles and status counts separately for cross-DB compatibility
|
93
|
+
durations = Request
|
94
|
+
.where(occurred_at: start_time...end_time)
|
95
|
+
.where(route_id: route_id)
|
96
|
+
.pluck(:duration, :status)
|
97
|
+
|
98
|
+
sorted_durations = durations.map(&:first).compact.sort
|
99
|
+
statuses = durations.map(&:last)
|
100
|
+
|
101
|
+
summary = Summary.find_or_initialize_by(
|
102
|
+
summarizable_type: "RailsPulse::Route",
|
103
|
+
summarizable_id: route_id,
|
104
|
+
period_type: period_type,
|
105
|
+
period_start: start_time
|
106
|
+
)
|
107
|
+
|
108
|
+
summary.assign_attributes(
|
109
|
+
period_end: end_time,
|
110
|
+
count: stats[1],
|
111
|
+
avg_duration: stats[2],
|
112
|
+
min_duration: stats[3],
|
113
|
+
max_duration: stats[4],
|
114
|
+
total_duration: stats[5],
|
115
|
+
p50_duration: calculate_percentile(sorted_durations, 0.5),
|
116
|
+
p95_duration: calculate_percentile(sorted_durations, 0.95),
|
117
|
+
p99_duration: calculate_percentile(sorted_durations, 0.99),
|
118
|
+
stddev_duration: calculate_stddev(sorted_durations, stats[2]),
|
119
|
+
error_count: statuses.count { |s| s >= 400 },
|
120
|
+
success_count: statuses.count { |s| s < 400 },
|
121
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
122
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
123
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
124
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
125
|
+
)
|
126
|
+
|
127
|
+
summary.save!
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def aggregate_queries
|
132
|
+
query_groups = Operation
|
133
|
+
.where(occurred_at: start_time...end_time)
|
134
|
+
.where.not(query_id: nil)
|
135
|
+
.group(:query_id)
|
136
|
+
|
137
|
+
basic_stats = query_groups.pluck(
|
138
|
+
:query_id,
|
139
|
+
Arel.sql("COUNT(*) as execution_count"),
|
140
|
+
Arel.sql("AVG(duration) as avg_duration"),
|
141
|
+
Arel.sql("MIN(duration) as min_duration"),
|
142
|
+
Arel.sql("MAX(duration) as max_duration"),
|
143
|
+
Arel.sql("SUM(duration) as total_duration")
|
144
|
+
)
|
145
|
+
|
146
|
+
basic_stats.each do |stats|
|
147
|
+
query_id = stats[0]
|
148
|
+
|
149
|
+
# Calculate percentiles separately
|
150
|
+
durations = Operation
|
151
|
+
.where(occurred_at: start_time...end_time)
|
152
|
+
.where(query_id: query_id)
|
153
|
+
.pluck(:duration)
|
154
|
+
.compact
|
155
|
+
.sort
|
156
|
+
|
157
|
+
summary = Summary.find_or_initialize_by(
|
158
|
+
summarizable_type: "RailsPulse::Query",
|
159
|
+
summarizable_id: query_id,
|
160
|
+
period_type: period_type,
|
161
|
+
period_start: start_time
|
162
|
+
)
|
163
|
+
|
164
|
+
summary.assign_attributes(
|
165
|
+
period_end: end_time,
|
166
|
+
count: stats[1],
|
167
|
+
avg_duration: stats[2],
|
168
|
+
min_duration: stats[3],
|
169
|
+
max_duration: stats[4],
|
170
|
+
total_duration: stats[5],
|
171
|
+
p50_duration: calculate_percentile(durations, 0.5),
|
172
|
+
p95_duration: calculate_percentile(durations, 0.95),
|
173
|
+
p99_duration: calculate_percentile(durations, 0.99),
|
174
|
+
stddev_duration: calculate_stddev(durations, stats[2])
|
175
|
+
)
|
176
|
+
|
177
|
+
summary.save!
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def calculate_percentile(sorted_array, percentile)
|
182
|
+
return nil if sorted_array.empty?
|
183
|
+
|
184
|
+
k = (percentile * (sorted_array.length - 1)).floor
|
185
|
+
f = (percentile * (sorted_array.length - 1)) - k
|
186
|
+
|
187
|
+
return sorted_array[k] if f == 0 || k + 1 >= sorted_array.length
|
188
|
+
|
189
|
+
sorted_array[k] + (sorted_array[k + 1] - sorted_array[k]) * f
|
190
|
+
end
|
191
|
+
|
192
|
+
def calculate_stddev(values, mean)
|
193
|
+
return nil if values.empty? || values.size == 1
|
194
|
+
|
195
|
+
sum_of_squares = values.sum { |v| (v - mean) ** 2 }
|
196
|
+
Math.sqrt(sum_of_squares / (values.size - 1))
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|