rails_pulse 0.3.3 → 0.4.0.pre.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Exceptions
|
|
3
|
+
module Cards
|
|
4
|
+
# Exceptions raised per 1,000 requests.
|
|
5
|
+
#
|
|
6
|
+
# Raw occurrence counts rise with traffic, so a busy week and a broken
|
|
7
|
+
# week look the same on the Occurrences card. Dividing by request volume
|
|
8
|
+
# separates the two: this is the only exception figure that can fall while
|
|
9
|
+
# occurrences climb, which is what "we shipped a fix but got busier" looks
|
|
10
|
+
# like.
|
|
11
|
+
#
|
|
12
|
+
# Both sides come from rails_pulse_summaries — the all-groups exception
|
|
13
|
+
# rollup over the overall request summary — so the ratio stays accurate
|
|
14
|
+
# after cleanup has pruned the underlying records.
|
|
15
|
+
class ExceptionRate < RailsPulse::Cards::Base
|
|
16
|
+
PER = 1_000
|
|
17
|
+
|
|
18
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
19
|
+
@disabled_tags = disabled_tags
|
|
20
|
+
@show_non_tagged = show_non_tagged
|
|
21
|
+
@period = period
|
|
22
|
+
@period_type = period_type
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_metric_card
|
|
26
|
+
exceptions = window_totals(RailsPulse::Summary.overall_exceptions)
|
|
27
|
+
requests = window_totals(RailsPulse::Summary.overall_requests)
|
|
28
|
+
|
|
29
|
+
rate = rate_for(exceptions[:total], requests[:total])
|
|
30
|
+
|
|
31
|
+
# More exceptions per request is worse, so the arrow reads the
|
|
32
|
+
# opposite way round from a throughput card — the strip already
|
|
33
|
+
# colours trending-up as negative.
|
|
34
|
+
if show_trend?
|
|
35
|
+
trend_icon, trend_amount = trend_for(
|
|
36
|
+
rate_for(exceptions[:current], requests[:current]),
|
|
37
|
+
rate_for(exceptions[:previous], requests[:previous])
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
id: "exceptions_rate",
|
|
43
|
+
chart_color: RailsPulse::ChartColors::DEFAULT,
|
|
44
|
+
context: "exceptions",
|
|
45
|
+
title: "Exception Rate",
|
|
46
|
+
summary: "#{rate} per #{format_number(PER)}",
|
|
47
|
+
chart_data: sparkline_data,
|
|
48
|
+
trend_icon: trend_icon,
|
|
49
|
+
trend_amount: trend_amount,
|
|
50
|
+
trend_text: (show_trend? ? comparison_period_text : nil),
|
|
51
|
+
period_stat: period_stat(exceptions[:total], requests[:total]),
|
|
52
|
+
help_heading: "Exception Rate",
|
|
53
|
+
help_text: "Exceptions raised per 1,000 requests over the period. Occurrence counts on their own rise and fall with traffic; this figure does not, so it is the one to watch when you want to know whether the application itself got worse. Reads the all-groups exception rollup against the overall request summary. Exceptions raised inside background jobs are counted in the numerator but have no request to divide by, so a job-heavy application will read high."
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Total, current-window and previous-window counts in one pass, so the
|
|
60
|
+
# rate can be compared across windows without four separate queries.
|
|
61
|
+
def window_totals(scope)
|
|
62
|
+
row = scope
|
|
63
|
+
.where(period_type: @period_type, period_start: range_start..now)
|
|
64
|
+
.select(
|
|
65
|
+
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
66
|
+
"SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
|
|
67
|
+
"SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
|
|
68
|
+
)
|
|
69
|
+
.take
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
total: row&.total_count.to_i,
|
|
73
|
+
current: row&.current_count.to_i,
|
|
74
|
+
previous: row&.previous_count.to_i
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# No requests in a period is "nothing to divide by", not "a rate of
|
|
79
|
+
# zero" — but zero is the honest reading for a card that answers "how
|
|
80
|
+
# often did this happen", and it keeps the sparkline continuous.
|
|
81
|
+
def rate_for(exceptions, requests)
|
|
82
|
+
return 0.0 if requests.to_i.zero?
|
|
83
|
+
|
|
84
|
+
(exceptions.to_f / requests * PER).round(1)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sparkline_data
|
|
88
|
+
exceptions = grouped_counts(RailsPulse::Summary.overall_exceptions)
|
|
89
|
+
requests = grouped_counts(RailsPulse::Summary.overall_requests)
|
|
90
|
+
|
|
91
|
+
sparkline_from(
|
|
92
|
+
requests.keys.union(exceptions.keys).index_with do |bucket|
|
|
93
|
+
rate_for(exceptions[bucket], requests[bucket])
|
|
94
|
+
end
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def grouped_counts(scope)
|
|
99
|
+
scoped = scope.where(period_type: @period_type, period_start: current_window_start..now)
|
|
100
|
+
|
|
101
|
+
if period_type_hours?
|
|
102
|
+
scoped.group_by_hour(:period_start).sum("rails_pulse_summaries.count")
|
|
103
|
+
else
|
|
104
|
+
scoped.group_by_date(:period_start).sum("rails_pulse_summaries.count")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def period_stat(exceptions, requests)
|
|
109
|
+
return period_date_range if requests.zero?
|
|
110
|
+
|
|
111
|
+
"#{format_number(exceptions)} raised · #{format_number(requests)} requests"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Exceptions
|
|
3
|
+
module Cards
|
|
4
|
+
# How many distinct exception sites are currently open.
|
|
5
|
+
#
|
|
6
|
+
# Occurrence volume and open-group count answer different questions: one
|
|
7
|
+
# loud exception firing ten thousand times and a hundred different sites
|
|
8
|
+
# each firing once are both worth knowing about, and neither number tells
|
|
9
|
+
# you about the other.
|
|
10
|
+
class OpenGroups < RailsPulse::Cards::Base
|
|
11
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
12
|
+
@disabled_tags = disabled_tags
|
|
13
|
+
@show_non_tagged = show_non_tagged
|
|
14
|
+
@period = period
|
|
15
|
+
@period_type = period_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_metric_card
|
|
19
|
+
open_count = RailsPulse::ExceptionGroup.where(status: "open").count
|
|
20
|
+
|
|
21
|
+
# New groups are the ones that did not exist before this window — a
|
|
22
|
+
# site that has just started failing, rather than a known one.
|
|
23
|
+
new_in_window = RailsPulse::ExceptionGroup
|
|
24
|
+
.where(status: "open")
|
|
25
|
+
.where(first_seen_at: current_window_start..now)
|
|
26
|
+
.count
|
|
27
|
+
|
|
28
|
+
grouped = RailsPulse::ExceptionGroup
|
|
29
|
+
.where(first_seen_at: current_window_start..now)
|
|
30
|
+
.group_by_date(:first_seen_at)
|
|
31
|
+
.count
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
id: "exceptions_open_groups",
|
|
35
|
+
chart_color: RailsPulse::ChartColors::DEFAULT,
|
|
36
|
+
context: "exceptions",
|
|
37
|
+
title: "Open Groups",
|
|
38
|
+
summary: "#{format_number(open_count)} open",
|
|
39
|
+
chart_data: sparkline_from(grouped),
|
|
40
|
+
trend_text: nil,
|
|
41
|
+
period_stat: "#{format_number(new_in_window)} new this period",
|
|
42
|
+
help_heading: "Open Exception Groups",
|
|
43
|
+
help_text: "Distinct exception sites currently marked open — one per exception class and code location, not one per raise. Resolved and ignored groups are excluded. The period figure counts sites seen for the first time in this window, which is usually where a new bug shows up."
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Exceptions
|
|
3
|
+
module Cards
|
|
4
|
+
# How often exceptions fired, and whether that is going up.
|
|
5
|
+
#
|
|
6
|
+
# This is the card that makes "which exceptions are increasing?" a
|
|
7
|
+
# question the product can answer at a glance. It reads the all-groups
|
|
8
|
+
# rollup summary rather than ExceptionGroup#occurrence_count, which is a
|
|
9
|
+
# lifetime counter and never goes down.
|
|
10
|
+
class TotalOccurrences < RailsPulse::Cards::Base
|
|
11
|
+
SUMMARIZABLE_TYPE = "RailsPulse::ExceptionGroup".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
14
|
+
@disabled_tags = disabled_tags
|
|
15
|
+
@show_non_tagged = show_non_tagged
|
|
16
|
+
@period = period
|
|
17
|
+
@period_type = period_type
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_metric_card
|
|
21
|
+
base_query = RailsPulse::Summary
|
|
22
|
+
.overall_exceptions
|
|
23
|
+
.where(period_type: @period_type, period_start: range_start..now)
|
|
24
|
+
|
|
25
|
+
metrics = base_query.select(
|
|
26
|
+
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
27
|
+
"SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
|
|
28
|
+
"SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
|
|
29
|
+
).take
|
|
30
|
+
|
|
31
|
+
total = metrics&.total_count.to_i
|
|
32
|
+
current = metrics&.current_count.to_i
|
|
33
|
+
previous = metrics&.previous_count.to_i
|
|
34
|
+
|
|
35
|
+
# More exceptions is worse, so the trend arrow means the opposite of
|
|
36
|
+
# what it means on a throughput card.
|
|
37
|
+
trend_icon, trend_amount = trend_for(current, previous) if show_trend?
|
|
38
|
+
|
|
39
|
+
grouped = base_query
|
|
40
|
+
.where(period_start: current_window_start..now)
|
|
41
|
+
.group_by_date(:period_start)
|
|
42
|
+
.sum("rails_pulse_summaries.count")
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
id: "exceptions_total_occurrences",
|
|
46
|
+
chart_color: RailsPulse::ChartColors::DEFAULT,
|
|
47
|
+
context: "exceptions",
|
|
48
|
+
title: "Occurrences",
|
|
49
|
+
summary: "#{format_number(total)} raised",
|
|
50
|
+
chart_data: sparkline_from(grouped),
|
|
51
|
+
trend_icon: trend_icon,
|
|
52
|
+
trend_amount: trend_amount,
|
|
53
|
+
trend_text: (show_trend? ? comparison_period_text : nil),
|
|
54
|
+
period_stat: period_date_range,
|
|
55
|
+
help_heading: "Exception Occurrences",
|
|
56
|
+
help_text: "How many exceptions your application raised over the period, across every group. Read from retained summaries, so the history stays accurate even after individual occurrence records have been cleaned up. A rising trend is the signal to look at which groups are growing."
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Exceptions
|
|
3
|
+
module Charts
|
|
4
|
+
# Exception frequency over time, read from the all-groups rollup summary.
|
|
5
|
+
#
|
|
6
|
+
# Reads summaries rather than counting occurrence rows directly: occurrence
|
|
7
|
+
# rows are pruned by retention, so counting them would show a chart that
|
|
8
|
+
# silently flattens toward zero the further back you look. Summaries are
|
|
9
|
+
# retained at day granularity indefinitely.
|
|
10
|
+
class OccurrenceVolume
|
|
11
|
+
def initialize(start_time:, end_time:, period_type: "day")
|
|
12
|
+
@start_time = start_time
|
|
13
|
+
@end_time = end_time
|
|
14
|
+
@period_type = period_type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_chart_data
|
|
18
|
+
totals = RailsPulse::Summary
|
|
19
|
+
.overall_exceptions
|
|
20
|
+
.where(period_type: @period_type, period_start: @start_time..@end_time)
|
|
21
|
+
.group(:period_start)
|
|
22
|
+
.sum(:count)
|
|
23
|
+
|
|
24
|
+
return nil if totals.empty?
|
|
25
|
+
|
|
26
|
+
raw = totals.transform_keys { |period_start| period_start.to_i }
|
|
27
|
+
padded = pad_with_zeros(raw)
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
series: [ {
|
|
31
|
+
name: "Occurrences",
|
|
32
|
+
data: padded.map { |timestamp, value| [ timestamp * 1000, value ] },
|
|
33
|
+
type: "bar",
|
|
34
|
+
color: RailsPulse::ChartColors::DEFAULT
|
|
35
|
+
} ]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# A period with no exceptions produces no summary row, which is the
|
|
42
|
+
# answer "zero" rather than "no data" — leaving the gap unfilled would
|
|
43
|
+
# draw a chart that skips quiet days and misrepresents the trend.
|
|
44
|
+
def pad_with_zeros(raw)
|
|
45
|
+
step = @period_type.to_s == "hour" ? 3600 : 86_400
|
|
46
|
+
|
|
47
|
+
{}.tap do |padded|
|
|
48
|
+
(@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
|
|
49
|
+
padded[timestamp] = raw[timestamp] || 0
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Exceptions
|
|
3
|
+
# Builds the plain-text "Raw Data" block shown on the exception pages and
|
|
4
|
+
# copied to the clipboard for pasting into an LLM.
|
|
5
|
+
#
|
|
6
|
+
# Markdown rather than JSON (as the query page uses) because the payload is
|
|
7
|
+
# mostly backtrace and source snippets, which JSON string-escapes into
|
|
8
|
+
# something neither a human nor a model reads well.
|
|
9
|
+
#
|
|
10
|
+
# Runs its own aggregate queries off the group's occurrences association so
|
|
11
|
+
# the views and controllers stay thin and this stays independently testable.
|
|
12
|
+
class RawData
|
|
13
|
+
include RailsPulse::BacktraceHelper
|
|
14
|
+
|
|
15
|
+
# Caps keep the payload pasteable.
|
|
16
|
+
MAX_EXPANDED_FRAMES = 10
|
|
17
|
+
MAX_ENDPOINTS = 10
|
|
18
|
+
MAX_FRAMES = 50
|
|
19
|
+
SOURCE_RADIUS = 3
|
|
20
|
+
RECENT_WINDOW = 24.hours
|
|
21
|
+
PRIOR_WINDOW = 7.days
|
|
22
|
+
|
|
23
|
+
def initialize(group, occurrence: nil, aggregates: true)
|
|
24
|
+
@group = group
|
|
25
|
+
@occurrence = occurrence
|
|
26
|
+
@aggregates = aggregates
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
sections.compact_blank.join("\n\n") << "\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :group, :occurrence
|
|
36
|
+
|
|
37
|
+
def sections
|
|
38
|
+
[
|
|
39
|
+
heading,
|
|
40
|
+
summary_section,
|
|
41
|
+
frequency_section,
|
|
42
|
+
deploy_section,
|
|
43
|
+
request_section,
|
|
44
|
+
params_section,
|
|
45
|
+
backtrace_section,
|
|
46
|
+
source_section
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def aggregates?
|
|
51
|
+
@aggregates
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def heading
|
|
55
|
+
"# Exception: #{group.exception_class}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def summary_section
|
|
59
|
+
list = [
|
|
60
|
+
[ "Class", group.exception_class ],
|
|
61
|
+
[ "Message", message ],
|
|
62
|
+
[ "Location", group.location.presence || "unknown" ],
|
|
63
|
+
[ "Status", group.status ],
|
|
64
|
+
[ "Preserved from cleanup", group.preserve? ? "yes" : "no" ],
|
|
65
|
+
[ "Fingerprint", group.fingerprint ]
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
section("Summary", bullets(list))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The group message is sanitized at capture; occurrence messages are too
|
|
72
|
+
# (see ExceptionMessageSanitizer), but fall back to the group's if blank.
|
|
73
|
+
def message
|
|
74
|
+
(occurrence&.message.presence || group.message.presence || "—")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def frequency_section
|
|
78
|
+
list = [
|
|
79
|
+
[ "First seen", timestamp(group.first_seen_at) ],
|
|
80
|
+
[ "Last seen", timestamp(group.last_seen_at) ],
|
|
81
|
+
[ "Total occurrences (lifetime)", group.occurrence_count ]
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
if aggregates?
|
|
85
|
+
list << [ "Stored occurrences", "#{stored_count} (older rows may have been pruned by retention)" ]
|
|
86
|
+
list << [ "Last 24 hours", recent_count ]
|
|
87
|
+
list << [ "Preceding 7 days", prior_count ]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
section("Frequency", bullets(list))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def deploy_section
|
|
94
|
+
return unless aggregates?
|
|
95
|
+
|
|
96
|
+
shas = occurrences.where.not(deploy_sha: nil).distinct.pluck(:deploy_sha)
|
|
97
|
+
return if shas.empty?
|
|
98
|
+
|
|
99
|
+
first_sha = occurrences.order(:occurred_at).pick(:deploy_sha)
|
|
100
|
+
|
|
101
|
+
list = [ [ "Deploy SHAs seen", shas.join(", ") ] ]
|
|
102
|
+
list << [ "Earliest stored occurrence deployed at", first_sha ] if first_sha.present?
|
|
103
|
+
|
|
104
|
+
section("Deploys", bullets(list))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def request_section
|
|
108
|
+
lines = []
|
|
109
|
+
|
|
110
|
+
if aggregates?
|
|
111
|
+
environments = occurrences.where.not(environment: nil).distinct.pluck(:environment)
|
|
112
|
+
lines << "- Environments: #{environments.join(', ')}" if environments.any?
|
|
113
|
+
|
|
114
|
+
endpoints = occurrences.where.not(request_url: nil)
|
|
115
|
+
.distinct.limit(MAX_ENDPOINTS)
|
|
116
|
+
.pluck(:request_method, :request_url)
|
|
117
|
+
if endpoints.any?
|
|
118
|
+
lines << "- Endpoints:"
|
|
119
|
+
endpoints.each { |method, url| lines << " - #{method.presence || '?'} #{url}" }
|
|
120
|
+
end
|
|
121
|
+
elsif occurrence
|
|
122
|
+
lines << "- Environment: #{occurrence.environment}" if occurrence.environment.present?
|
|
123
|
+
if occurrence.request_url.present?
|
|
124
|
+
lines << "- Endpoint: #{occurrence.request_method.presence || '?'} #{occurrence.request_url}"
|
|
125
|
+
end
|
|
126
|
+
lines << "- Deploy SHA: #{occurrence.deploy_sha}" if occurrence.deploy_sha.present?
|
|
127
|
+
lines << "- Occurred at: #{timestamp(occurrence.occurred_at)}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
section("Request context", lines.join("\n")) if lines.any?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Already run through ActiveSupport::ParameterFilter at capture time.
|
|
134
|
+
def params_section
|
|
135
|
+
params = occurrence&.request_params
|
|
136
|
+
return if params.blank?
|
|
137
|
+
|
|
138
|
+
section("Request params (filtered)", fenced(JSON.pretty_generate(params), "json"))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def backtrace_section
|
|
142
|
+
return if frames.empty?
|
|
143
|
+
|
|
144
|
+
lines = frames.each_with_index.map do |frame, index|
|
|
145
|
+
origin = app_frame?(frame) ? "app" : "gem"
|
|
146
|
+
"#{(index + 1).to_s.rjust(2)}. [#{origin}] #{frame_display_path(frame)}:#{frame['line']}:in `#{frame['method']}'"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
title = "Backtrace"
|
|
150
|
+
title += " (occurrence at #{timestamp(occurrence.occurred_at)})" if occurrence&.occurred_at
|
|
151
|
+
|
|
152
|
+
section(title, fenced(lines.join("\n")))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Source is read from disk for app frames only, and BacktraceHelper
|
|
156
|
+
# sandboxes reads to Rails.root.
|
|
157
|
+
def source_section
|
|
158
|
+
expanded = frames.select { |frame| app_frame?(frame) }
|
|
159
|
+
.first(MAX_EXPANDED_FRAMES)
|
|
160
|
+
.filter_map do |frame|
|
|
161
|
+
lines = frame_source_lines(frame, radius: SOURCE_RADIUS)
|
|
162
|
+
next if lines.blank?
|
|
163
|
+
|
|
164
|
+
body = lines.map do |lineno, content|
|
|
165
|
+
marker = lineno == frame["line"].to_i ? ">" : " "
|
|
166
|
+
"#{marker} #{lineno.to_s.rjust(4)} | #{content}"
|
|
167
|
+
end.join("\n")
|
|
168
|
+
|
|
169
|
+
"### #{frame_display_path(frame)}:#{frame['line']} — `#{frame['method']}`\n\n#{fenced(body, 'ruby')}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return if expanded.empty?
|
|
173
|
+
|
|
174
|
+
section("Source", expanded.join("\n\n"))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def frames
|
|
178
|
+
@frames ||= Array(occurrence&.backtrace).first(MAX_FRAMES).select { |frame| frame.is_a?(Hash) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def occurrences
|
|
182
|
+
group.occurrences
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def stored_count
|
|
186
|
+
@stored_count ||= occurrences.count
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def recent_count
|
|
190
|
+
@recent_count ||= occurrences.where(occurred_at: RECENT_WINDOW.ago..).count
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def prior_count
|
|
194
|
+
@prior_count ||= occurrences.where(occurred_at: (RECENT_WINDOW + PRIOR_WINDOW).ago...RECENT_WINDOW.ago).count
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def timestamp(time)
|
|
198
|
+
return "—" if time.blank?
|
|
199
|
+
time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def bullets(pairs)
|
|
203
|
+
pairs.map { |label, value| "- #{label}: #{value}" }.join("\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def fenced(body, language = "")
|
|
207
|
+
"```#{language}\n#{body}\n```"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def section(title, body)
|
|
211
|
+
return if body.blank?
|
|
212
|
+
"## #{title}\n\n#{body}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -40,7 +40,13 @@ module RailsPulse
|
|
|
40
40
|
current_p95 = weighted_average(current_weighted_p95, current_runs)
|
|
41
41
|
previous_p95 = weighted_average(previous_weighted_p95, previous_runs)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
baseline = baseline_trend_for(@job, metric: :p95)
|
|
44
|
+
|
|
45
|
+
if baseline
|
|
46
|
+
trend_icon, trend_amount, trend_caption = baseline
|
|
47
|
+
elsif show_trend?
|
|
48
|
+
trend_icon, trend_amount = trend_for(current_p95, previous_p95)
|
|
49
|
+
end
|
|
44
50
|
|
|
45
51
|
grouped_weighted = base_query
|
|
46
52
|
.group_by_date(:period_start)
|
|
@@ -61,7 +67,7 @@ module RailsPulse
|
|
|
61
67
|
chart_data: sparkline_data,
|
|
62
68
|
trend_icon: trend_icon,
|
|
63
69
|
trend_amount: trend_amount,
|
|
64
|
-
trend_text: (show_trend? ? comparison_period_text : nil),
|
|
70
|
+
trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
|
|
65
71
|
period_stat: total_runs > 0 ? "Across #{format_number(total_runs)} runs" : period_date_range,
|
|
66
72
|
help_heading: "P95 Duration",
|
|
67
73
|
help_text: "The 95th percentile execution time — 95% of jobs complete faster than this. Weighted by job volume across all classes. A rising P95 indicates jobs are becoming slower, which may cause queue backlog."
|
|
@@ -83,6 +83,9 @@ module RailsPulse
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
op[:label] = op[:label]&.truncate(255)
|
|
86
|
+
# actual_sql is used for normalization above but should not be persisted
|
|
87
|
+
# unless the user has opted in — it may contain unparameterized PII.
|
|
88
|
+
op[:actual_sql] = nil unless RailsPulse.configuration.capture_actual_sql
|
|
86
89
|
op
|
|
87
90
|
end
|
|
88
91
|
# insert_all! requires every row to have identical keys.
|
|
@@ -109,20 +112,26 @@ module RailsPulse
|
|
|
109
112
|
Arel.sql("COUNT(rails_pulse_operations.id)")
|
|
110
113
|
end
|
|
111
114
|
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
# Handle different time formats for database compatibility. An
|
|
116
|
+
# unparseable value becomes nil, which Ransack treats as "no filter",
|
|
117
|
+
# rather than a 500 from a hand-edited query string.
|
|
118
|
+
OCCURRED_AT_FORMATTER = lambda do |val|
|
|
114
119
|
case val
|
|
115
120
|
when Time, DateTime, ActiveSupport::TimeWithZone
|
|
116
121
|
val.utc.iso8601
|
|
117
122
|
when String
|
|
118
|
-
Time.zone.parse(val)
|
|
123
|
+
Time.zone.parse(val)&.utc&.iso8601
|
|
119
124
|
when Integer
|
|
120
125
|
Time.at(val).utc.iso8601
|
|
121
126
|
else
|
|
122
127
|
# Fallback: try to parse as integer timestamp
|
|
123
128
|
Time.at(val.to_i).utc.iso8601
|
|
124
129
|
end
|
|
125
|
-
|
|
130
|
+
rescue ArgumentError, TypeError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
ransacker :occurred_at, formatter: OCCURRED_AT_FORMATTER do |parent|
|
|
126
135
|
parent.table[:occurred_at]
|
|
127
136
|
end
|
|
128
137
|
|
|
@@ -26,8 +26,13 @@ module RailsPulse
|
|
|
26
26
|
current_period_p95 = metrics.current_p95 || 0
|
|
27
27
|
previous_period_p95 = metrics.previous_p95 || 0
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
baseline = baseline_trend_for(@query, metric: :p95)
|
|
30
|
+
|
|
31
|
+
if baseline
|
|
32
|
+
trend_icon, trend_amount, trend_caption = baseline
|
|
33
|
+
elsif show_trend?
|
|
34
|
+
trend_icon, trend_amount = trend_for(current_period_p95, previous_period_p95)
|
|
35
|
+
end
|
|
31
36
|
|
|
32
37
|
# Create a query for sparkline data using only the current period
|
|
33
38
|
sparkline_query = RailsPulse::Summary
|
|
@@ -65,7 +70,7 @@ module RailsPulse
|
|
|
65
70
|
chart_data: sparkline_data,
|
|
66
71
|
trend_icon: trend_icon,
|
|
67
72
|
trend_amount: trend_amount,
|
|
68
|
-
trend_text: (show_trend? ? comparison_period_text : nil),
|
|
73
|
+
trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
|
|
69
74
|
period_stat: metrics.total_count.to_i > 0 ? "Across #{format_number(metrics.total_count.to_i)} queries" : period_date_range,
|
|
70
75
|
help_heading: "P95 Query Time",
|
|
71
76
|
help_text: "The 95th percentile database query duration — 95% of queries complete faster than this. Weighted by execution frequency across all queries. Slow queries are often caused by missing indexes or N+1 patterns."
|
|
@@ -21,7 +21,7 @@ module RailsPulse
|
|
|
21
21
|
before_validation :set_request_uuid, on: :create
|
|
22
22
|
|
|
23
23
|
def self.ransackable_attributes(auth_object = nil)
|
|
24
|
-
%w[id route_id occurred_at duration status status_category status_indicator route_path response_size_bytes]
|
|
24
|
+
%w[id route_id occurred_at duration status status_category status_indicator route_path http_method controller_action response_size_bytes]
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def self.ransackable_associations(auth_object = nil)
|
|
@@ -36,6 +36,10 @@ module RailsPulse
|
|
|
36
36
|
Arel.sql("rails_pulse_routes.path")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
ransacker :http_method do |parent|
|
|
40
|
+
parent.table[:method]
|
|
41
|
+
end
|
|
42
|
+
|
|
39
43
|
ransacker :status_category do |parent|
|
|
40
44
|
# Returns the first digit of the status code (2, 3, 4, or 5)
|
|
41
45
|
# Use FLOOR instead of CAST for cross-database compatibility
|