rails_pulse 0.5.0 → 0.5.1.pre.1
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 +11 -0
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +9 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +4 -1
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +1 -1
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/detail_capture_helper.rb +43 -0
- data/app/jobs/rails_pulse/summary_job.rb +45 -0
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +43 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +9 -1
- data/app/models/rails_pulse/operation.rb +45 -19
- data/app/models/rails_pulse/rollup.rb +50 -0
- data/app/services/rails_pulse/detail_capture_rate.rb +141 -0
- data/app/services/rails_pulse/operation_suggestions.rb +3 -0
- data/app/services/rails_pulse/query_analysis_service.rb +5 -1
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +160 -10
- data/app/views/rails_pulse/components/_panel.html.erb +23 -0
- data/app/views/rails_pulse/queries/_diagnostics.html.erb +3 -0
- data/app/views/rails_pulse/requests/index.html.erb +4 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -0
- data/app/views/rails_pulse/storage/show.html.erb +17 -0
- data/db/rails_pulse_migrate/20261007000001_create_rails_pulse_rollups.rb +35 -0
- data/db/rails_pulse_schema.rb +34 -2
- data/lib/generators/rails_pulse/base_methods.rb +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +34 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +13 -0
- data/lib/rails_pulse/cleanup_service.rb +44 -9
- data/lib/rails_pulse/configuration.rb +90 -1
- data/lib/rails_pulse/duration_histogram.rb +150 -0
- data/lib/rails_pulse/engine.rb +2 -0
- data/lib/rails_pulse/middleware/request_collector.rb +88 -21
- data/lib/rails_pulse/rollup_accumulator.rb +217 -0
- data/lib/rails_pulse/tasks/status_reporter.rb +40 -1
- data/lib/rails_pulse/tracker.rb +99 -4
- data/lib/rails_pulse/version.rb +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08538cb7f42c80b0e0cc87f03be930ebda1242bcaec8e3a95f6e6c2fd4894b6e'
|
|
4
|
+
data.tar.gz: a442604b862d2ed178ef9636a17529e285bdf1795d13140b7f1003591ebd7f93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52ecffc7297e1ce526134121dd7a89d401fe4569939c6cfdd3151f4c95043062c9c29416668ddea11bb132a5205ba4ff41c02915a5a990365175911a23434450
|
|
7
|
+
data.tar.gz: 13454d0ce774b2a0b6df138cc328329265968f67b1a5faa3164a90dea173481c8dce0a7fc505f5c140e6875991ef543baebb8a027fb3222c447b8eab6df3c25a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.1.pre.1] - 2026-10-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Detail capture for high-traffic installs.** With `config.store_full_details = { above_threshold: :slow }`, only requests and queries past the named threshold, errors and a 1% sample are stored in full, while dashboards still count all traffic from compact hourly aggregates. Off by default; upgrading adds a table, so run `rails generate rails_pulse:upgrade && rails db:migrate`.
|
|
15
|
+
- **How much detail capture keeps.** `rails rails_pulse:status`, the Storage page and the info icon on lists of captured requests show what share of the last day's traffic was kept in full; when it is over a quarter, the dashboard's Storage badge warns and says which setting to change.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Requests whose controller raises an unhandled exception are now recorded, with the status the error page sends, so error rates and request counts include them; previously only the exception was tracked and the request was missing.
|
|
20
|
+
|
|
10
21
|
## [0.5.0] - 2026-10-16
|
|
11
22
|
|
|
12
23
|
## [0.5.0.pre.2] - 2026-10-11
|
|
@@ -127,6 +127,15 @@ module RailsPulse
|
|
|
127
127
|
data = collection.limit(limit).offset(offset)
|
|
128
128
|
[ data, { total: total, limit: limit, offset: offset } ]
|
|
129
129
|
end
|
|
130
|
+
|
|
131
|
+
# Rows and counts read straight from raw requests or operations cover
|
|
132
|
+
# only the captured subset while detail capture is on (decision 0022);
|
|
133
|
+
# the flag tells API consumers so. Aggregates from summaries stay
|
|
134
|
+
# complete and carry no flag.
|
|
135
|
+
def apply_sampled_detail(meta)
|
|
136
|
+
meta[:sampled_detail] = true if RailsPulse.configuration.detail_capture_settings
|
|
137
|
+
meta
|
|
138
|
+
end
|
|
130
139
|
end
|
|
131
140
|
end
|
|
132
141
|
end
|
|
@@ -14,13 +14,16 @@ module RailsPulse
|
|
|
14
14
|
SUMMARY_GRACE = 2.hours
|
|
15
15
|
|
|
16
16
|
def show
|
|
17
|
-
|
|
17
|
+
payload = {
|
|
18
18
|
as_of: Time.current.utc.iso8601,
|
|
19
19
|
telemetry: telemetry,
|
|
20
20
|
summaries: summaries,
|
|
21
21
|
retention: retention,
|
|
22
22
|
collection: collection
|
|
23
23
|
}
|
|
24
|
+
# Raw request counts cover only captured requests under detail capture
|
|
25
|
+
payload[:sampled_detail] = true if RailsPulse.configuration.detail_capture_settings
|
|
26
|
+
render json: payload
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
private
|
|
@@ -65,7 +65,7 @@ module RailsPulse
|
|
|
65
65
|
QuerySerializer.serialize(query, stats: stats_for(row, locations[row.query_id])) if query
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
render json: { data: data, meta: { total: total, limit: limit, offset: offset } }
|
|
68
|
+
render json: { data: data, meta: apply_sampled_detail({ total: total, limit: limit, offset: offset }) }
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Restricts the operations to those issued while serving one route, so
|
|
@@ -20,7 +20,7 @@ module RailsPulse
|
|
|
20
20
|
return if performed?
|
|
21
21
|
|
|
22
22
|
data, meta = paginated(collection)
|
|
23
|
-
render json: { data: data.map { |request| RequestSerializer.serialize(request) }, meta: meta }
|
|
23
|
+
render json: { data: data.map { |request| RequestSerializer.serialize(request) }, meta: apply_sampled_detail(meta) }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module DetailCaptureHelper
|
|
3
|
+
# The panel info (see components/_panel) for a panel built from
|
|
4
|
+
# individual requests while store_full_details names a threshold: those rows are the
|
|
5
|
+
# captured subset, but every aggregate still counts all traffic
|
|
6
|
+
# (decision 0022). nil while store_full_details is :all.
|
|
7
|
+
#
|
|
8
|
+
# subject is the route or query the page is about, or nil for all requests.
|
|
9
|
+
def detail_capture_info(text, subject: nil)
|
|
10
|
+
return unless RailsPulse.configuration.detail_capture_settings
|
|
11
|
+
|
|
12
|
+
rate = DetailCaptureRate.for(subject)
|
|
13
|
+
lines = [ text, detail_capture_rate_sentence(rate, subject) ]
|
|
14
|
+
lines << detail_capture_retune_sentence if detail_capture_rate_high?(rate, subject)
|
|
15
|
+
|
|
16
|
+
{ heading: "Full details", lines: lines.compact }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Only the overall share says the settings need retuning: a route that is
|
|
22
|
+
# always slow is meant to be captured in full.
|
|
23
|
+
def detail_capture_rate_high?(rate, subject = nil)
|
|
24
|
+
subject.nil? && rate.present? && rate.high?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def detail_capture_retune_sentence
|
|
28
|
+
advice = DetailCaptureRate.retune_advice(RailsPulse.configuration.detail_capture_settings)
|
|
29
|
+
"That's over a quarter of them, so little storage is saved. To store less, #{advice}."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def detail_capture_rate_sentence(rate, subject)
|
|
33
|
+
return unless rate
|
|
34
|
+
|
|
35
|
+
noun = case subject
|
|
36
|
+
when Route then "of this route's requests"
|
|
37
|
+
when Query then "of this query's executions"
|
|
38
|
+
else "of all requests"
|
|
39
|
+
end
|
|
40
|
+
"Captured in the last day: #{rate.share_label} #{noun}."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -6,9 +6,16 @@ module RailsPulse
|
|
|
6
6
|
def perform(target_hour = nil)
|
|
7
7
|
target_hour ||= 1.hour.ago.beginning_of_hour
|
|
8
8
|
|
|
9
|
+
# Rollup buckets flush on their own clock, so make this process's
|
|
10
|
+
# folded traffic visible before the hour is read. Other processes'
|
|
11
|
+
# buckets are healed by resummarize_hours_with_late_rollups next run.
|
|
12
|
+
Tracker.flush_rollups!
|
|
13
|
+
|
|
9
14
|
# Always run hourly summary
|
|
10
15
|
process_hourly_summary(target_hour)
|
|
11
16
|
|
|
17
|
+
resummarize_hours_with_late_rollups(target_hour)
|
|
18
|
+
|
|
12
19
|
# Check if we should run daily summary (at the start of a new day)
|
|
13
20
|
if midnight?(target_hour)
|
|
14
21
|
process_daily_summary(target_hour.to_date - 1.day)
|
|
@@ -26,8 +33,46 @@ module RailsPulse
|
|
|
26
33
|
raise
|
|
27
34
|
end
|
|
28
35
|
|
|
36
|
+
# A bucket stops accepting folds STALE_HOUR_GRACE past its hour and is
|
|
37
|
+
# flushed within FLUSH_INTERVAL, so nothing lands later than this after
|
|
38
|
+
# the hour it counts.
|
|
39
|
+
LATE_ROLLUP_LOOKBACK = 3.hours
|
|
40
|
+
|
|
29
41
|
private
|
|
30
42
|
|
|
43
|
+
# Rollups flush about once a minute per process, and a long request that
|
|
44
|
+
# started in an hour folds only after it finishes, so rows for an hour can
|
|
45
|
+
# land after that hour was summarized — summaries would otherwise
|
|
46
|
+
# permanently undercount each hour's folded tail. A recent hour whose
|
|
47
|
+
# summary no longer matches its raw request count plus its folded route
|
|
48
|
+
# counts is summarized again; the upsert makes that idempotent. Counts are
|
|
49
|
+
# compared rather than timestamps because the summary's updated_at is
|
|
50
|
+
# written by the database and rollup timestamps by Ruby, which clock skew
|
|
51
|
+
# can order either way. Runs before the daily rollup so a corrected hour
|
|
52
|
+
# feeds its day. Healing is best-effort: a failure here must not stop the
|
|
53
|
+
# current hour's summaries or the period rollups.
|
|
54
|
+
def resummarize_hours_with_late_rollups(target_hour)
|
|
55
|
+
return unless RailsPulse::Rollup.table_available?
|
|
56
|
+
|
|
57
|
+
window = (target_hour - LATE_ROLLUP_LOOKBACK)...target_hour
|
|
58
|
+
hours = RailsPulse::Rollup.where(period_start: window).distinct.pluck(:period_start)
|
|
59
|
+
|
|
60
|
+
hours.each do |hour|
|
|
61
|
+
summarized_count = RailsPulse::Summary.overall_requests
|
|
62
|
+
.where(period_type: "hour", period_start: hour).pick(:count)
|
|
63
|
+
next unless summarized_count
|
|
64
|
+
|
|
65
|
+
current_count = RailsPulse::Request.where(occurred_at: hour...(hour + 1.hour)).count +
|
|
66
|
+
RailsPulse::Rollup.where(rollupable_type: "RailsPulse::Route", period_start: hour).sum(:count)
|
|
67
|
+
next if current_count == summarized_count
|
|
68
|
+
|
|
69
|
+
RailsPulse.logger.info "Re-summarizing hour #{hour}: rollups or requests landed after its summary"
|
|
70
|
+
SummaryService.new("hour", hour).perform
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
RailsPulse.logger.error "Could not re-summarize hours with late rollups: #{e.class} - #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
31
76
|
# Cloud receives an hour only once it is summarized, so the sync follows
|
|
32
77
|
# this job rather than running on a schedule of its own. A queue that
|
|
33
78
|
# refuses the job must not fail the summaries already written.
|
|
@@ -11,7 +11,16 @@ module RailsPulse
|
|
|
11
11
|
# Memoized: computing the items costs several aggregate queries (one a
|
|
12
12
|
# raw-request count), and one dashboard render asks for them repeatedly.
|
|
13
13
|
def pressure_items
|
|
14
|
-
@pressure_items ||= summary_staleness_items + sub_hour_retention_items + writer_drop_items
|
|
14
|
+
@pressure_items ||= summary_staleness_items + sub_hour_retention_items + writer_drop_items + detail_capture_items
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Memoized (nil is a valid value: detail capture off or no rate yet) and
|
|
18
|
+
# shared with StorageStatus, which wraps this object — one storage page
|
|
19
|
+
# render would otherwise compute the rate's aggregate queries repeatedly.
|
|
20
|
+
def detail_capture_rate
|
|
21
|
+
return @detail_capture_rate if defined?(@detail_capture_rate)
|
|
22
|
+
|
|
23
|
+
@detail_capture_rate = RailsPulse::DetailCaptureRate.for
|
|
15
24
|
end
|
|
16
25
|
|
|
17
26
|
# Dropped requests get their own Tracking badge, not this one
|
|
@@ -103,6 +112,39 @@ module RailsPulse
|
|
|
103
112
|
[]
|
|
104
113
|
end
|
|
105
114
|
|
|
115
|
+
# Signal D — store_full_details keeping over a quarter of traffic, so the
|
|
116
|
+
# requests and operations tables grow almost as if it were :all
|
|
117
|
+
def detail_capture_items
|
|
118
|
+
settings = @config.detail_capture_settings
|
|
119
|
+
return [] unless settings
|
|
120
|
+
|
|
121
|
+
rate = detail_capture_rate
|
|
122
|
+
return [] unless rate&.high?
|
|
123
|
+
|
|
124
|
+
routes = RailsPulse::DetailCaptureRate.top_kept_routes_label
|
|
125
|
+
[ {
|
|
126
|
+
type: "STORAGE",
|
|
127
|
+
name: "Full details",
|
|
128
|
+
reason: "Kept #{rate.share_label} of the last day's requests in full, so little storage is saved",
|
|
129
|
+
metric: "#{rate.share_label} kept",
|
|
130
|
+
metric_sub: "of #{rate.total.to_fs(:delimited)} requests, last day",
|
|
131
|
+
link: "#",
|
|
132
|
+
severity: :warning,
|
|
133
|
+
# Cleanup still runs; store_full_details only decides what it has to delete
|
|
134
|
+
cleanup: false,
|
|
135
|
+
sort_score: rate.share,
|
|
136
|
+
popover_title: "Full details are stored for over a quarter of requests",
|
|
137
|
+
popover_body: "With <code>config.store_full_details</code> #{ERB::Util.html_escape(settings.description)}, only those requests " \
|
|
138
|
+
"are stored in full; the rest are folded into hourly totals. In the last day #{rate.share_label} were stored " \
|
|
139
|
+
"in full, so the requests and operations tables are growing almost as if every request were.<br><br>" \
|
|
140
|
+
"<strong>How to fix:</strong> #{RailsPulse::DetailCaptureRate.retune_advice(settings).upcase_first} " \
|
|
141
|
+
"in the Rails Pulse initializer." +
|
|
142
|
+
(routes ? "<br><br>Routes keeping the most: #{ERB::Util.html_escape(routes)}." : "")
|
|
143
|
+
} ]
|
|
144
|
+
rescue ActiveRecord::ActiveRecordError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
106
148
|
def stale_item(severity, reason, metric, metric_sub)
|
|
107
149
|
{
|
|
108
150
|
type: "STORAGE",
|
|
@@ -132,6 +132,14 @@ module RailsPulse
|
|
|
132
132
|
}
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# rate is nil while detail capture is off or its rate is not known yet
|
|
136
|
+
def detail_capture
|
|
137
|
+
@detail_capture ||= begin
|
|
138
|
+
settings = @config.detail_capture_settings
|
|
139
|
+
{ enabled: settings.present?, settings_label: settings&.description, rate: settings && @pressure.detail_capture_rate }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
135
143
|
def database
|
|
136
144
|
@database ||= {
|
|
137
145
|
adapter: adapter_label,
|
|
@@ -514,7 +522,7 @@ module RailsPulse
|
|
|
514
522
|
end
|
|
515
523
|
|
|
516
524
|
def cleanup_health
|
|
517
|
-
items = pressure_items
|
|
525
|
+
items = pressure_items.reject { |item| item[:cleanup] == false }
|
|
518
526
|
return :critical if items.any? { |item| item[:severity] == :critical }
|
|
519
527
|
return :warning if items.any? { |item| item[:severity] == :warning }
|
|
520
528
|
return :disabled unless @config.archiving_enabled
|
|
@@ -52,25 +52,7 @@ module RailsPulse
|
|
|
52
52
|
def self.persist_bulk(ops, context)
|
|
53
53
|
return if ops.empty?
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
# norm_cache avoids re-normalising repeated SQL (e.g. N+1 queries).
|
|
57
|
-
# norm_map feeds the bulk Query resolver: { hashed_sql => normalized_sql }.
|
|
58
|
-
norm_cache = {}
|
|
59
|
-
norm_map = {}
|
|
60
|
-
ops.each do |op|
|
|
61
|
-
next unless op[:operation_type] == "sql"
|
|
62
|
-
sql_source = op[:actual_sql].presence || op[:label].presence
|
|
63
|
-
next unless sql_source
|
|
64
|
-
unless norm_cache.key?(sql_source)
|
|
65
|
-
normalized = RailsPulse::SqlQueryNormalizer.normalize(sql_source)
|
|
66
|
-
hashed = Digest::MD5.hexdigest(normalized)
|
|
67
|
-
norm_cache[sql_source] = [ hashed, normalized ]
|
|
68
|
-
norm_map[hashed] ||= normalized
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Phase 2: resolve Query IDs in bulk (1 SELECT in steady state).
|
|
73
|
-
query_id_map = RailsPulse::Query.bulk_find_or_create(norm_map)
|
|
55
|
+
norm_cache, query_id_map = normalize_and_resolve(ops)
|
|
74
56
|
|
|
75
57
|
now = Time.current
|
|
76
58
|
rows = ops.map do |op|
|
|
@@ -96,6 +78,50 @@ module RailsPulse
|
|
|
96
78
|
insert_all!(rows)
|
|
97
79
|
end
|
|
98
80
|
|
|
81
|
+
# The (query_id, duration) pairs of a batch's SQL operations, resolving
|
|
82
|
+
# (or creating) the Query records exactly as persist_bulk would, without
|
|
83
|
+
# persisting the operations themselves. Detail capture folds these pairs
|
|
84
|
+
# into per-query rollups so the Queries page keeps counting every request.
|
|
85
|
+
def self.resolve_sql_durations(ops)
|
|
86
|
+
ops = Array(ops).select { |op| op[:operation_type] == "sql" }
|
|
87
|
+
return [] if ops.empty?
|
|
88
|
+
|
|
89
|
+
norm_cache, query_id_map = normalize_and_resolve(ops)
|
|
90
|
+
ops.filter_map do |op|
|
|
91
|
+
sql_source = op[:actual_sql].presence || op[:label].presence
|
|
92
|
+
next unless sql_source && (meta = norm_cache[sql_source])
|
|
93
|
+
|
|
94
|
+
query_id = query_id_map[meta.first]
|
|
95
|
+
next unless query_id
|
|
96
|
+
|
|
97
|
+
[ query_id, op[:duration].to_f ]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Phase 1: normalise each unique SQL source once.
|
|
102
|
+
# norm_cache avoids re-normalising repeated SQL (e.g. N+1 queries) and maps
|
|
103
|
+
# sql_source => [hashed, normalized]; norm_map feeds the bulk Query
|
|
104
|
+
# resolver with { hashed_sql => normalized_sql }.
|
|
105
|
+
# Phase 2: resolve Query IDs in bulk (1 SELECT in steady state).
|
|
106
|
+
def self.normalize_and_resolve(ops)
|
|
107
|
+
norm_cache = {}
|
|
108
|
+
norm_map = {}
|
|
109
|
+
ops.each do |op|
|
|
110
|
+
next unless op[:operation_type] == "sql"
|
|
111
|
+
sql_source = op[:actual_sql].presence || op[:label].presence
|
|
112
|
+
next unless sql_source
|
|
113
|
+
unless norm_cache.key?(sql_source)
|
|
114
|
+
normalized = RailsPulse::SqlQueryNormalizer.normalize(sql_source)
|
|
115
|
+
hashed = Digest::MD5.hexdigest(normalized)
|
|
116
|
+
norm_cache[sql_source] = [ hashed, normalized ]
|
|
117
|
+
norm_map[hashed] ||= normalized
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
[ norm_cache, RailsPulse::Query.bulk_find_or_create(norm_map) ]
|
|
122
|
+
end
|
|
123
|
+
private_class_method :normalize_and_resolve
|
|
124
|
+
|
|
99
125
|
def self.ransackable_attributes(auth_object = nil)
|
|
100
126
|
%w[id occurred_at label duration start_time average_query_time_ms query_count operation_type query_id]
|
|
101
127
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# One writer process's cumulative aggregate of the requests (per route) or
|
|
3
|
+
# SQL operations (per query) that detail capture folded instead of
|
|
4
|
+
# persisting during one hour. Decision 0022.
|
|
5
|
+
#
|
|
6
|
+
# Rows are written by replace-upsert: a writer incarnation owns the rows
|
|
7
|
+
# carrying its writer_id and overwrites them with its running totals, so
|
|
8
|
+
# flushing twice is harmless and two processes never contend. Readers sum
|
|
9
|
+
# across writer_ids. Sums, counts, min and max are exact; only the duration
|
|
10
|
+
# histogram (for percentiles) is approximate.
|
|
11
|
+
class Rollup < RailsPulse::ApplicationRecord
|
|
12
|
+
self.table_name = "rails_pulse_rollups"
|
|
13
|
+
|
|
14
|
+
UNIQUE_INDEX = :idx_pulse_rollups_unique
|
|
15
|
+
|
|
16
|
+
belongs_to :rollupable, polymorphic: true
|
|
17
|
+
|
|
18
|
+
validates :period_start, :writer_id, presence: true
|
|
19
|
+
|
|
20
|
+
scope :for_hour, ->(hour_start) { where(period_start: hour_start) }
|
|
21
|
+
|
|
22
|
+
# MySQL has no explicit conflict target and resolves through any unique
|
|
23
|
+
# key, which is why the model's unique index must stay the only one.
|
|
24
|
+
def self.upsert_rows(rows)
|
|
25
|
+
return if rows.empty?
|
|
26
|
+
|
|
27
|
+
unique_by = connection.supports_insert_conflict_target? ? UNIQUE_INDEX : nil
|
|
28
|
+
upsert_all(rows, unique_by: unique_by)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# table_exists?, rescued — for an upgrader who hasn't migrated yet
|
|
40
|
+
def self.table_available?
|
|
41
|
+
table_exists?
|
|
42
|
+
rescue ActiveRecord::ActiveRecordError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def histogram_counts
|
|
47
|
+
DurationHistogram.load(histogram).counts
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# What share of the last day's traffic store_full_details kept in full
|
|
3
|
+
# (decision 0022), for all requests, one route or one query.
|
|
4
|
+
#
|
|
5
|
+
# Reads summaries and rollups only, never the requests table: an hour's
|
|
6
|
+
# summary counts every request, the rollups count the ones folded, and the
|
|
7
|
+
# difference was kept. That limits it to hours SummaryJob has summarized.
|
|
8
|
+
#
|
|
9
|
+
# The window starts at the first hour with any rollup, so hours from before
|
|
10
|
+
# store_full_details was set (when everything was kept) don't count. With
|
|
11
|
+
# no rollup at all in the last day the rate is unknown: it was set too
|
|
12
|
+
# recently, or nothing is being folded.
|
|
13
|
+
class DetailCaptureRate
|
|
14
|
+
WINDOW = 24.hours
|
|
15
|
+
|
|
16
|
+
# Above this share, storing only some full details saves little; the
|
|
17
|
+
# named threshold likely sits below typical timings.
|
|
18
|
+
HIGH_SHARE = 0.25
|
|
19
|
+
|
|
20
|
+
attr_reader :kept, :total
|
|
21
|
+
|
|
22
|
+
# subject: nil for all requests, or a Route or Query
|
|
23
|
+
def self.for(subject = nil, now: Time.current)
|
|
24
|
+
return unless Rollup.table_available?
|
|
25
|
+
|
|
26
|
+
new(subject, now).compute
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What to change when the share is high, e.g. "raise
|
|
30
|
+
# request_thresholds[:slow] (700ms now) or query_thresholds[:slow] (100ms
|
|
31
|
+
# now), set above_threshold: higher, or lower sample_rate_below (1% now)"
|
|
32
|
+
def self.retune_advice(settings)
|
|
33
|
+
key = settings.threshold.inspect
|
|
34
|
+
advice = "raise request_thresholds[#{key}] (#{settings.requests_slower_than.round}ms now) " \
|
|
35
|
+
"or query_thresholds[#{key}] (#{settings.queries_slower_than.round}ms now), " \
|
|
36
|
+
"set store_full_details[:above_threshold] to a higher threshold"
|
|
37
|
+
return advice unless settings.sample_rate.positive?
|
|
38
|
+
|
|
39
|
+
"#{advice}, or lower store_full_details[:sample_rate_below] (#{settings.sample_percent} now)"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# "/reports (2,900 of 3,000), /search (400 of 1,200)", or nil when no
|
|
43
|
+
# route kept anything
|
|
44
|
+
def self.top_kept_routes_label(now: Time.current)
|
|
45
|
+
routes = top_kept_routes(now: now).map do |route, kept, total|
|
|
46
|
+
"#{route.path} (#{kept.to_fs(:delimited)} of #{total.to_fs(:delimited)})"
|
|
47
|
+
end
|
|
48
|
+
routes.join(", ").presence
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Routes keeping the most requests in the window, as
|
|
52
|
+
# [[route, kept, total], ...], most kept first.
|
|
53
|
+
def self.top_kept_routes(limit: 3, now: Time.current)
|
|
54
|
+
return [] unless Rollup.table_available?
|
|
55
|
+
|
|
56
|
+
new(nil, now).top_kept_routes(limit)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(subject, now)
|
|
60
|
+
@subject = subject
|
|
61
|
+
@now = now
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def compute
|
|
65
|
+
hours = summarized_hours
|
|
66
|
+
return if hours.empty?
|
|
67
|
+
|
|
68
|
+
@total = summaries.where(period_start: hours).sum(:count).to_i
|
|
69
|
+
return if @total.zero?
|
|
70
|
+
|
|
71
|
+
folded = rollups.where(period_start: hours).sum(:count).to_i
|
|
72
|
+
@kept = [ @total - folded, 0 ].max
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def top_kept_routes(limit)
|
|
77
|
+
hours = summarized_hours(Summary.for_routes)
|
|
78
|
+
return [] if hours.empty?
|
|
79
|
+
|
|
80
|
+
totals = Summary.for_routes.where(period_type: "hour", period_start: hours).group(:summarizable_id).sum(:count)
|
|
81
|
+
folded = Rollup.where(rollupable_type: "RailsPulse::Route", period_start: hours).group(:rollupable_id).sum(:count)
|
|
82
|
+
kept = totals.to_h { |id, total| [ id, [ total.to_i - folded[id].to_i, 0 ].max ] }
|
|
83
|
+
|
|
84
|
+
top = kept.select { |_id, count| count.positive? }.max_by(limit) { |_id, count| count }
|
|
85
|
+
routes = Route.where(id: top.map(&:first)).index_by(&:id)
|
|
86
|
+
top.filter_map { |id, count| [ routes[id], count, totals[id].to_i ] if routes[id] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def share
|
|
90
|
+
kept.fdiv(total)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def high?
|
|
94
|
+
share > HIGH_SHARE
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# "17.7%", "under 0.1%"
|
|
98
|
+
def share_label
|
|
99
|
+
percent = share * 100
|
|
100
|
+
return "under 0.1%" if percent.positive? && percent < 0.1
|
|
101
|
+
|
|
102
|
+
"#{percent.round(1).to_s.delete_suffix('.0')}%"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def window_start
|
|
108
|
+
@window_start ||= (@now - WINDOW).beginning_of_hour
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rollups for the current hour exist before its summary does, so the
|
|
112
|
+
# hours come from the summaries.
|
|
113
|
+
def summarized_hours(scope = subject_summaries)
|
|
114
|
+
first_folded = Rollup.where(rollupable_type: rollupable_type, period_start: window_start..).minimum(:period_start)
|
|
115
|
+
return [] unless first_folded
|
|
116
|
+
|
|
117
|
+
Summary.where(period_type: "hour", period_start: first_folded..)
|
|
118
|
+
.merge(scope)
|
|
119
|
+
.distinct
|
|
120
|
+
.pluck(:period_start)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def summaries
|
|
124
|
+
Summary.where(period_type: "hour").merge(subject_summaries)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def subject_summaries
|
|
128
|
+
@subject ? Summary.where(summarizable: @subject) : Summary.overall_requests
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rollups
|
|
132
|
+
@subject ? Rollup.where(rollupable: @subject) : Rollup.where(rollupable_type: "RailsPulse::Route")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A route or all requests starts at the first folded request anywhere,
|
|
136
|
+
# not its own, so a route whose every request was kept still has a rate.
|
|
137
|
+
def rollupable_type
|
|
138
|
+
@subject.is_a?(Query) ? "RailsPulse::Query" : "RailsPulse::Route"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
|
+
# Works on persisted operations, which under detail capture are the slow,
|
|
3
|
+
# errored and sampled requests' — a slow-biased sample, which is the right
|
|
4
|
+
# input for suggestion tooling.
|
|
2
5
|
class OperationSuggestions
|
|
3
6
|
VIEW_TYPES = %w[template partial layout collection].freeze
|
|
4
7
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Orchestrates comprehensive query analysis using modular analyzers.
|
|
2
|
-
# Coordinates multiple specialized analyzers and consolidates results into
|
|
2
|
+
# Coordinates multiple specialized analyzers and consolidates results into
|
|
3
|
+
# actionable insights. Reads persisted operations, which under detail capture
|
|
4
|
+
# come from requests that were slow, ran a slow query, errored or were
|
|
5
|
+
# sampled — a slow-biased sample,
|
|
6
|
+
# which is the right input for analysis that hunts problems.
|
|
3
7
|
module RailsPulse
|
|
4
8
|
class QueryAnalysisService
|
|
5
9
|
def self.analyze_query(query_id)
|