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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/app/controllers/rails_pulse/api/v1/base_controller.rb +9 -0
  4. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +4 -1
  5. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +1 -1
  6. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +1 -1
  7. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +1 -1
  8. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  9. data/app/helpers/rails_pulse/detail_capture_helper.rb +43 -0
  10. data/app/jobs/rails_pulse/summary_job.rb +45 -0
  11. data/app/models/rails_pulse/dashboard/storage_pressure.rb +43 -1
  12. data/app/models/rails_pulse/dashboard/storage_status.rb +9 -1
  13. data/app/models/rails_pulse/operation.rb +45 -19
  14. data/app/models/rails_pulse/rollup.rb +50 -0
  15. data/app/services/rails_pulse/detail_capture_rate.rb +141 -0
  16. data/app/services/rails_pulse/operation_suggestions.rb +3 -0
  17. data/app/services/rails_pulse/query_analysis_service.rb +5 -1
  18. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +160 -10
  19. data/app/views/rails_pulse/components/_panel.html.erb +23 -0
  20. data/app/views/rails_pulse/queries/_diagnostics.html.erb +3 -0
  21. data/app/views/rails_pulse/requests/index.html.erb +4 -1
  22. data/app/views/rails_pulse/routes/show.html.erb +1 -0
  23. data/app/views/rails_pulse/storage/show.html.erb +17 -0
  24. data/db/rails_pulse_migrate/20261007000001_create_rails_pulse_rollups.rb +35 -0
  25. data/db/rails_pulse_schema.rb +34 -2
  26. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  27. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +34 -2
  28. data/lib/generators/rails_pulse/templates/rails_pulse.rb +13 -0
  29. data/lib/rails_pulse/cleanup_service.rb +44 -9
  30. data/lib/rails_pulse/configuration.rb +90 -1
  31. data/lib/rails_pulse/duration_histogram.rb +150 -0
  32. data/lib/rails_pulse/engine.rb +2 -0
  33. data/lib/rails_pulse/middleware/request_collector.rb +88 -21
  34. data/lib/rails_pulse/rollup_accumulator.rb +217 -0
  35. data/lib/rails_pulse/tasks/status_reporter.rb +40 -1
  36. data/lib/rails_pulse/tracker.rb +99 -4
  37. data/lib/rails_pulse/version.rb +1 -1
  38. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  39. metadata +9 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3a4671cfd97c14be6772995e98269ab8371d16ab80143d3ae3e79cebb7c82ff
4
- data.tar.gz: 557942447466d4b8b555b9c398cea09ecf39a5f18cc604acc972ec25f1399abd
3
+ metadata.gz: '08538cb7f42c80b0e0cc87f03be930ebda1242bcaec8e3a95f6e6c2fd4894b6e'
4
+ data.tar.gz: a442604b862d2ed178ef9636a17529e285bdf1795d13140b7f1003591ebd7f93
5
5
  SHA512:
6
- metadata.gz: 10a91ca1ebba8d5baac05e287c1582fa6b98274d36b4666bdd52cea2fd9df7c7cc9724c6c2e43c408725f366761a1be4c0dc1c008aea2d3f129e9e9470fa182e
7
- data.tar.gz: 2e6d6aadae30d286aceef73717424b3f850ea8ed07b004fd6d6e4a40c431fc6516d0708fd63375598de5bbc4fb56cd775513947274bf13baaea8d19e9b678ab7
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
- render json: {
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
@@ -83,7 +83,7 @@ module RailsPulse
83
83
  meta[:routes_with_traffic] = routes_with_traffic
84
84
  end
85
85
 
86
- render json: { data: data, meta: meta }
86
+ render json: { data: data, meta: apply_sampled_detail(meta) }
87
87
  end
88
88
 
89
89
  def min_requests
@@ -4,6 +4,7 @@ module RailsPulse
4
4
  include BreadcrumbsHelper
5
5
  include ChartHelper
6
6
  include CspHelper
7
+ include DetailCaptureHelper
7
8
  include FormattingHelper
8
9
  include FormHelper
9
10
  include IconHelper
@@ -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
- # Phase 1: normalise each unique SQL source once.
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 actionable insights.
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)