rails_pulse 0.1.1 → 0.1.3
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 +79 -177
- data/Rakefile +77 -2
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -17
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- 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 +32 -1
- data/app/controllers/rails_pulse/application_controller.rb +13 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
- data/app/controllers/rails_pulse/queries_controller.rb +111 -51
- data/app/controllers/rails_pulse/requests_controller.rb +37 -12
- data/app/controllers/rails_pulse/routes_controller.rb +98 -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 +21 -9
- data/app/helpers/rails_pulse/status_helper.rb +10 -4
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- 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 +18 -7
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
- data/app/models/rails_pulse/operation.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
- 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 +47 -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 -25
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
- 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/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/services/rails_pulse/summary_service.rb +199 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +4 -6
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
- data/app/views/rails_pulse/queries/_table.html.erb +11 -13
- data/app/views/rails_pulse/queries/index.html.erb +32 -28
- data/app/views/rails_pulse/queries/show.html.erb +45 -34
- data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
- data/app/views/rails_pulse/requests/_table.html.erb +3 -3
- data/app/views/rails_pulse/requests/index.html.erb +33 -28
- data/app/views/rails_pulse/routes/_table.html.erb +14 -14
- data/app/views/rails_pulse/routes/index.html.erb +34 -29
- data/app/views/rails_pulse/routes/show.html.erb +43 -36
- data/config/initializers/rails_pulse.rb +0 -12
- data/config/routes.rb +5 -1
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +130 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +94 -4
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
- data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/configuration.rb +0 -11
- data/lib/rails_pulse/engine.rb +0 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +77 -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 +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- data/public/rails-pulse-assets/search.svg +43 -0
- metadata +48 -14
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/controllers/rails_pulse/caches_controller.rb +0 -115
- data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
- 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
@@ -2,109 +2,35 @@ module RailsPulse
|
|
2
2
|
module Queries
|
3
3
|
module Charts
|
4
4
|
class AverageQueryTimes
|
5
|
-
def initialize(ransack_query:,
|
5
|
+
def initialize(ransack_query:, period_type: nil, query: nil, start_time: nil, end_time: nil, start_duration: nil)
|
6
6
|
@ransack_query = ransack_query
|
7
|
-
@
|
7
|
+
@period_type = period_type
|
8
8
|
@query = query
|
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
|
-
# Create full time range and fill in missing periods
|
30
|
-
fill_missing_periods(actual_data)
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def fill_missing_periods(actual_data)
|
36
|
-
# Extract actual time range from ransack query conditions
|
37
|
-
start_time, end_time = extract_time_range_from_ransack
|
38
|
-
|
39
|
-
# Create time range based on grouping type
|
40
|
-
case @group_by
|
41
|
-
when :group_by_hour
|
42
|
-
time_range = generate_hour_range(start_time, end_time)
|
43
|
-
else # :group_by_day
|
44
|
-
time_range = generate_day_range(start_time, end_time)
|
45
|
-
end
|
46
|
-
|
47
|
-
# Fill in all periods with zero values for missing periods
|
48
|
-
time_range.each_with_object({}) do |period, result|
|
49
|
-
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
50
|
-
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
51
|
-
|
52
|
-
normalized_occurred_at =
|
53
|
-
case @group_by
|
54
|
-
when :group_by_hour
|
55
|
-
occurred_at&.beginning_of_hour || occurred_at
|
56
|
-
when :group_by_day
|
57
|
-
occurred_at&.beginning_of_day || occurred_at
|
58
|
-
else
|
59
|
-
occurred_at
|
60
|
-
end
|
61
|
-
|
62
|
-
# Use actual data if available, otherwise default to 0
|
63
|
-
average_duration = actual_data[period] || 0
|
64
|
-
result[normalized_occurred_at.to_i] = {
|
65
|
-
value: average_duration.to_f
|
66
|
-
}
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
def generate_day_range(start_time, end_time)
|
71
|
-
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
72
|
-
end
|
73
|
-
|
74
|
-
def generate_hour_range(start_time, end_time)
|
75
|
-
current = start_time
|
76
|
-
hours = []
|
77
|
-
while current <= end_time
|
78
|
-
hours << current
|
79
|
-
current += 1.hour
|
80
|
-
end
|
81
|
-
hours
|
82
|
-
end
|
83
|
-
|
84
|
-
def extract_time_range_from_ransack
|
85
|
-
# Extract time range from ransack conditions
|
86
|
-
conditions = @ransack_query.conditions
|
87
|
-
|
88
|
-
if @query
|
89
|
-
# For specific query, look for occurred_at conditions
|
90
|
-
start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
|
91
|
-
end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
|
92
|
-
else
|
93
|
-
# For general operations, look for rails_pulse_operations_occurred_at conditions
|
94
|
-
start_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "gteq" }
|
95
|
-
end_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "lt" }
|
96
|
-
end
|
97
|
-
|
98
|
-
start_time = start_condition&.v || 2.weeks.ago
|
99
|
-
end_time = end_condition&.v || Time.current
|
100
|
-
|
101
|
-
# Normalize time boundaries based on grouping
|
102
|
-
case @group_by
|
103
|
-
when :group_by_hour
|
104
|
-
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
105
|
-
else
|
106
|
-
[ start_time.beginning_of_day, end_time.beginning_of_day ]
|
15
|
+
summaries = @ransack_query.result(distinct: false).where(
|
16
|
+
summarizable_type: "RailsPulse::Query",
|
17
|
+
period_type: @period_type
|
18
|
+
)
|
19
|
+
|
20
|
+
summaries = summaries.where(summarizable_id: @query.id) if @query
|
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)
|
107
32
|
end
|
33
|
+
data
|
108
34
|
end
|
109
35
|
end
|
110
36
|
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Queries
|
3
|
+
module Tables
|
4
|
+
class Index
|
5
|
+
def initialize(ransack_query:, period_type: nil, start_time:, params:, query: nil)
|
6
|
+
@ransack_query = ransack_query
|
7
|
+
@period_type = period_type
|
8
|
+
@start_time = start_time
|
9
|
+
@params = params
|
10
|
+
@query = query
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_table
|
14
|
+
# Check if we have explicit ransack sorts
|
15
|
+
has_sorts = @ransack_query.sorts.any?
|
16
|
+
|
17
|
+
base_query = @ransack_query.result(distinct: false)
|
18
|
+
.joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
|
19
|
+
.where(
|
20
|
+
summarizable_type: "RailsPulse::Query",
|
21
|
+
period_type: @period_type
|
22
|
+
)
|
23
|
+
|
24
|
+
base_query = base_query.where(summarizable_id: @query.id) if @query
|
25
|
+
|
26
|
+
# Apply grouping and aggregation
|
27
|
+
grouped_query = base_query
|
28
|
+
.group(
|
29
|
+
"rails_pulse_summaries.summarizable_id",
|
30
|
+
"rails_pulse_summaries.summarizable_type",
|
31
|
+
"rails_pulse_queries.id",
|
32
|
+
"rails_pulse_queries.normalized_sql"
|
33
|
+
)
|
34
|
+
.select(
|
35
|
+
"rails_pulse_summaries.summarizable_id",
|
36
|
+
"rails_pulse_summaries.summarizable_type",
|
37
|
+
"rails_pulse_queries.id as query_id",
|
38
|
+
"rails_pulse_queries.normalized_sql",
|
39
|
+
"AVG(rails_pulse_summaries.avg_duration) as avg_duration",
|
40
|
+
"MAX(rails_pulse_summaries.max_duration) as max_duration",
|
41
|
+
"SUM(rails_pulse_summaries.count) as execution_count",
|
42
|
+
"SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration) as total_time_consumed"
|
43
|
+
)
|
44
|
+
|
45
|
+
# Apply sorting based on ransack sorts or use default
|
46
|
+
if has_sorts
|
47
|
+
# Apply custom sorting based on ransack parameters
|
48
|
+
sort = @ransack_query.sorts.first
|
49
|
+
direction = sort.dir == "desc" ? :desc : :asc
|
50
|
+
|
51
|
+
case sort.name
|
52
|
+
when "avg_duration_sort"
|
53
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
|
54
|
+
when "execution_count_sort"
|
55
|
+
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
|
56
|
+
when "total_time_consumed_sort"
|
57
|
+
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)").send(direction))
|
58
|
+
when "normalized_sql"
|
59
|
+
grouped_query = grouped_query.order(Arel.sql("rails_pulse_queries.normalized_sql").send(direction))
|
60
|
+
else
|
61
|
+
# Unknown sort field, fallback to default
|
62
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
|
63
|
+
end
|
64
|
+
else
|
65
|
+
# Apply default sort when no explicit sort is provided
|
66
|
+
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
|
67
|
+
end
|
68
|
+
|
69
|
+
grouped_query
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -4,10 +4,18 @@ module RailsPulse
|
|
4
4
|
|
5
5
|
# Associations
|
6
6
|
has_many :operations, class_name: "RailsPulse::Operation", inverse_of: :query
|
7
|
+
has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
|
7
8
|
|
8
9
|
# Validations
|
9
10
|
validates :normalized_sql, presence: true, uniqueness: true
|
10
11
|
|
12
|
+
# JSON serialization for analysis columns
|
13
|
+
serialize :issues, type: Array, coder: JSON
|
14
|
+
serialize :metadata, type: Hash, coder: JSON
|
15
|
+
serialize :query_stats, type: Hash, coder: JSON
|
16
|
+
serialize :backtrace_analysis, type: Hash, coder: JSON
|
17
|
+
serialize :suggestions, type: Array, coder: JSON
|
18
|
+
|
11
19
|
def self.ransackable_attributes(auth_object = nil)
|
12
20
|
%w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
|
13
21
|
end
|
@@ -51,6 +59,45 @@ module RailsPulse
|
|
51
59
|
Arel.sql("MAX(rails_pulse_operations.occurred_at)")
|
52
60
|
end
|
53
61
|
|
62
|
+
# Analysis helper methods
|
63
|
+
def analyzed?
|
64
|
+
analyzed_at.present?
|
65
|
+
end
|
66
|
+
|
67
|
+
def has_recent_operations?
|
68
|
+
operations.where("occurred_at > ?", 48.hours.ago).exists?
|
69
|
+
end
|
70
|
+
|
71
|
+
def needs_reanalysis?
|
72
|
+
return true unless analyzed?
|
73
|
+
|
74
|
+
# Check if there are new operations since analysis
|
75
|
+
last_operation_time = operations.maximum(:occurred_at)
|
76
|
+
return false unless last_operation_time
|
77
|
+
|
78
|
+
last_operation_time > analyzed_at
|
79
|
+
end
|
80
|
+
|
81
|
+
def analysis_status
|
82
|
+
return "not_analyzed" unless analyzed?
|
83
|
+
return "needs_update" if needs_reanalysis?
|
84
|
+
"current"
|
85
|
+
end
|
86
|
+
|
87
|
+
def issues_by_severity
|
88
|
+
return {} unless analyzed? && issues.present?
|
89
|
+
|
90
|
+
issues.group_by { |issue| issue["severity"] || "unknown" }
|
91
|
+
end
|
92
|
+
|
93
|
+
def critical_issues_count
|
94
|
+
issues_by_severity["critical"]&.count || 0
|
95
|
+
end
|
96
|
+
|
97
|
+
def warning_issues_count
|
98
|
+
issues_by_severity["warning"]&.count || 0
|
99
|
+
end
|
100
|
+
|
54
101
|
def to_s
|
55
102
|
id
|
56
103
|
end
|
@@ -2,96 +2,35 @@ module RailsPulse
|
|
2
2
|
module Requests
|
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
|
-
# Extract actual time range from ransack query conditions
|
30
|
-
start_time, end_time = extract_time_range_from_ransack
|
31
|
-
|
32
|
-
# Create time range based on grouping type
|
33
|
-
case @group_by
|
34
|
-
when :group_by_hour
|
35
|
-
time_range = generate_hour_range(start_time, end_time)
|
36
|
-
else # :group_by_day
|
37
|
-
time_range = generate_day_range(start_time, end_time)
|
38
|
-
end
|
39
|
-
|
40
|
-
# Fill in all periods with zero values for missing periods
|
41
|
-
time_range.each_with_object({}) do |period, result|
|
42
|
-
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
43
|
-
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
44
|
-
|
45
|
-
normalized_occurred_at =
|
46
|
-
case @group_by
|
47
|
-
when :group_by_hour
|
48
|
-
occurred_at&.beginning_of_hour || occurred_at
|
49
|
-
when :group_by_day
|
50
|
-
occurred_at&.beginning_of_day || occurred_at
|
51
|
-
else
|
52
|
-
occurred_at
|
53
|
-
end
|
54
|
-
|
55
|
-
# Use actual data if available, otherwise default to 0
|
56
|
-
average_duration = actual_data[period] || 0
|
57
|
-
result[normalized_occurred_at.to_i] = {
|
58
|
-
value: average_duration.to_f
|
59
|
-
}
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
def generate_day_range(start_time, end_time)
|
64
|
-
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
65
|
-
end
|
66
|
-
|
67
|
-
def generate_hour_range(start_time, end_time)
|
68
|
-
current = start_time
|
69
|
-
hours = []
|
70
|
-
while current <= end_time
|
71
|
-
hours << current
|
72
|
-
current += 1.hour
|
73
|
-
end
|
74
|
-
hours
|
75
|
-
end
|
76
|
-
|
77
|
-
def extract_time_range_from_ransack
|
78
|
-
# Extract time range from ransack conditions
|
79
|
-
conditions = @ransack_query.conditions
|
80
|
-
|
81
|
-
# For requests, look for occurred_at conditions on rails_pulse_requests
|
82
|
-
start_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "gteq" }
|
83
|
-
end_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "lt" }
|
84
|
-
|
85
|
-
start_time = start_condition&.v || 2.weeks.ago
|
86
|
-
end_time = end_condition&.v || Time.current
|
87
|
-
|
88
|
-
# Normalize time boundaries based on grouping
|
89
|
-
case @group_by
|
90
|
-
when :group_by_hour
|
91
|
-
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
92
|
-
else
|
93
|
-
[ 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)
|
94
32
|
end
|
33
|
+
data
|
95
34
|
end
|
96
35
|
end
|
97
36
|
end
|
@@ -4,6 +4,7 @@ module RailsPulse
|
|
4
4
|
|
5
5
|
# Associations
|
6
6
|
has_many :requests, class_name: "RailsPulse::Request", foreign_key: "route_id", dependent: :restrict_with_exception
|
7
|
+
has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
|
7
8
|
|
8
9
|
# Validations
|
9
10
|
validates :method, presence: true
|
@@ -56,12 +57,6 @@ module RailsPulse
|
|
56
57
|
Arel.sql("COUNT(rails_pulse_requests.id)")
|
57
58
|
end
|
58
59
|
|
59
|
-
# Remove the problematic ransacker that causes SQL syntax errors
|
60
|
-
# The status_indicator will be handled differently or removed from filtering
|
61
|
-
# ransacker :status_indicator do
|
62
|
-
# # Removed to fix SQL generation issues in tests
|
63
|
-
# end
|
64
|
-
|
65
60
|
def to_breadcrumb
|
66
61
|
path
|
67
62
|
end
|
@@ -7,39 +7,59 @@ 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 aggregated metrics with conditional sums
|
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(avg_duration * count) AS total_weighted_duration",
|
23
|
+
"SUM(count) AS total_requests",
|
24
|
+
"SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN avg_duration * count ELSE 0 END) AS current_weighted_duration",
|
25
|
+
"SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS current_requests",
|
26
|
+
"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 avg_duration * count ELSE 0 END) AS previous_weighted_duration",
|
27
|
+
"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_requests"
|
28
|
+
).take
|
20
29
|
|
21
|
-
# Calculate
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
previous_period_avg = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
|
30
|
+
# Calculate metrics from single query result
|
31
|
+
average_response_time = metrics.total_requests.to_i > 0 ? (metrics.total_weighted_duration / metrics.total_requests).round(0) : 0
|
32
|
+
current_period_avg = metrics.current_requests.to_i > 0 ? (metrics.current_weighted_duration / metrics.current_requests) : 0
|
33
|
+
previous_period_avg = metrics.previous_requests.to_i > 0 ? (metrics.previous_weighted_duration / metrics.previous_requests) : 0
|
26
34
|
|
27
|
-
percentage = previous_period_avg.zero? ?
|
28
|
-
trend_icon = percentage < 0.1 ?
|
35
|
+
percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
|
36
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
|
29
37
|
trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
|
30
38
|
|
31
|
-
|
32
|
-
|
33
|
-
.
|
34
|
-
.
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
39
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
40
|
+
grouped_weighted = base_query
|
41
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
42
|
+
.sum(Arel.sql("avg_duration * count"))
|
43
|
+
|
44
|
+
grouped_counts = base_query
|
45
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
46
|
+
.sum(:count)
|
47
|
+
|
48
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
49
|
+
end_day = Time.current.to_date
|
50
|
+
|
51
|
+
sparkline_data = {}
|
52
|
+
(start_day..end_day).each do |day|
|
53
|
+
weighted_sum = grouped_weighted[day] || 0
|
54
|
+
count_sum = grouped_counts[day] || 0
|
55
|
+
avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
|
56
|
+
label = day.strftime("%b %-d")
|
57
|
+
sparkline_data[label] = { value: avg }
|
58
|
+
end
|
41
59
|
|
42
60
|
{
|
61
|
+
id: "average_response_times",
|
62
|
+
context: "routes",
|
43
63
|
title: "Average Response Time",
|
44
64
|
summary: "#{average_response_time} ms",
|
45
65
|
line_chart_data: sparkline_data,
|
@@ -7,60 +7,58 @@ module RailsPulse
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def to_metric_card
|
10
|
-
|
11
|
-
|
12
|
-
RailsPulse::Route.where(id: @route)
|
13
|
-
else
|
14
|
-
RailsPulse::Route.all
|
15
|
-
end
|
10
|
+
last_7_days = 7.days.ago.beginning_of_day
|
11
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
16
12
|
|
17
|
-
|
13
|
+
# Single query to get all error 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
|
18
20
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
.
|
23
|
-
|
24
|
-
|
25
|
-
path: route.path,
|
26
|
-
error_rate: error_rate.round(2)
|
27
|
-
}
|
28
|
-
end
|
21
|
+
metrics = base_query.select(
|
22
|
+
"SUM(error_count) AS total_errors",
|
23
|
+
"SUM(count) AS total_requests",
|
24
|
+
"SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN error_count ELSE 0 END) AS current_errors",
|
25
|
+
"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 error_count ELSE 0 END) AS previous_errors"
|
26
|
+
).take
|
29
27
|
|
30
|
-
# Calculate
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
total_days = min_time && max_time && min_time != max_time ? (max_time - min_time) / 1.day : 1
|
36
|
-
errors_per_day = total_errors / total_days
|
37
|
-
error_rate_summary = "#{errors_per_day.round(2)} / day"
|
28
|
+
# Calculate metrics from single query result
|
29
|
+
total_errors = metrics.total_errors || 0
|
30
|
+
total_requests = metrics.total_requests || 0
|
31
|
+
current_period_errors = metrics.current_errors || 0
|
32
|
+
previous_period_errors = metrics.previous_errors || 0
|
38
33
|
|
39
|
-
#
|
40
|
-
|
41
|
-
.where(is_error: true)
|
42
|
-
.group_by_week(:occurred_at, time_zone: "UTC")
|
43
|
-
.count
|
44
|
-
.each_with_object({}) do |(date, count), hash|
|
45
|
-
formatted_date = date.strftime("%b %-d")
|
46
|
-
hash[formatted_date] = {
|
47
|
-
value: count
|
48
|
-
}
|
49
|
-
end
|
34
|
+
# Calculate overall error rate percentage
|
35
|
+
overall_error_rate = total_requests > 0 ? (total_errors.to_f / total_requests * 100).round(2) : 0
|
50
36
|
|
51
|
-
#
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
37
|
+
# Calculate trend
|
38
|
+
percentage = previous_period_errors.zero? ? 0 : ((previous_period_errors - current_period_errors) / previous_period_errors.to_f * 100).abs.round(1)
|
39
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
|
40
|
+
trend_amount = previous_period_errors.zero? ? "0%" : "#{percentage}%"
|
41
|
+
|
42
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
43
|
+
grouped_daily = base_query
|
44
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
45
|
+
.sum(:error_count)
|
56
46
|
|
57
|
-
|
58
|
-
|
47
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
48
|
+
end_day = Time.current.to_date
|
49
|
+
|
50
|
+
sparkline_data = {}
|
51
|
+
(start_day..end_day).each do |day|
|
52
|
+
total = grouped_daily[day] || 0
|
53
|
+
label = day.strftime("%b %-d")
|
54
|
+
sparkline_data[label] = { value: total }
|
55
|
+
end
|
59
56
|
|
60
57
|
{
|
58
|
+
id: "error_rate_per_route",
|
59
|
+
context: "routes",
|
61
60
|
title: "Error Rate Per Route",
|
62
|
-
|
63
|
-
summary: error_rate_summary,
|
61
|
+
summary: "#{overall_error_rate}%",
|
64
62
|
line_chart_data: sparkline_data,
|
65
63
|
trend_icon: trend_icon,
|
66
64
|
trend_amount: trend_amount,
|