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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -0,0 +1,152 @@
1
+ module RailsPulse
2
+ # Strips PII from exception messages before they are persisted or exported.
3
+ #
4
+ # Extracted from ExceptionCaptureService so the same filtering applies to
5
+ # occurrence rows, job-run failure messages, and anything that ships a
6
+ # message off the machine (see RailsPulse::Exceptions::RawData).
7
+ #
8
+ # Three layers, applied in order:
9
+ #
10
+ # 1. Adapter-specific cleanup for ActiveRecord::StatementInvalid — the
11
+ # driver error is kept, the offending statement and any literal values
12
+ # the database echoes back (PG `DETAIL: Key (col)=(val)`, MySQL
13
+ # `Duplicate entry 'val'`) are redacted.
14
+ # 2. `key=value` / `key: value` / `"key":"value"` pairs anywhere in the
15
+ # text whose key matches the host's `config.filter_parameters` — the
16
+ # same rules Rails applies to logged params, so `password=hunter2` and
17
+ # `token: abc` are masked without any extra configuration.
18
+ # 3. An optional host hook, `config.exception_message_filter`, for anything
19
+ # the generic rules cannot know about.
20
+ class ExceptionMessageSanitizer
21
+ MAX_LENGTH = 500
22
+ FILTERED = "[FILTERED]".freeze
23
+
24
+ # Matches `key=value`, `key: value`, `'key' => value` and JSON-style
25
+ # `"key":"value"` fragments in free text. A bare `:` only counts as a
26
+ # separator after a quoted key or when followed by whitespace, so URL
27
+ # schemes (`https://…`) and clock times (`12:30`) are not read as pairs.
28
+ # The value is a quoted string (with escapes) or a run of characters up
29
+ # to the next delimiter — including `&` and `?`, so query strings split
30
+ # per key. An unquoted value may not end right before another `=` or `:`
31
+ # (atomic, so `Error: token: x` is not read as `Error` = `token`), but
32
+ # may contain `://` so URLs survive as one value.
33
+ KEY_VALUE_PAIR = /
34
+ (?<lead>["'])?
35
+ (?<key>[A-Za-z_][\w.\-\[\]]*)
36
+ (?(<lead>)\k<lead>)
37
+ (?<sep>\s*=>\s*|\s*=\s*|\s*:\s+|(?(<lead>):|(?!)))
38
+ (?<value>
39
+ "(?:[^"\\]|\\.)*"
40
+ | '(?:[^'\\]|\\.)*'
41
+ | (?>(?:[^\s,;)\]}&\#?=:]|:(?=\/\/))+)(?![=:]|\s*=>)
42
+ )
43
+ /x
44
+
45
+ class << self
46
+ def call(message, statement_invalid: false, exception: nil)
47
+ new(message, statement_invalid: statement_invalid, exception: exception).call
48
+ end
49
+
50
+ def for_exception(exception)
51
+ call(exception.message.to_s, statement_invalid: statement_invalid?(exception), exception: exception)
52
+ end
53
+
54
+ private
55
+
56
+ # ActiveRecord may not be loaded in every host context this runs in.
57
+ def statement_invalid?(exception)
58
+ defined?(ActiveRecord::StatementInvalid) && exception.is_a?(ActiveRecord::StatementInvalid)
59
+ end
60
+ end
61
+
62
+ def initialize(message, statement_invalid: false, exception: nil)
63
+ @message = message.to_s
64
+ @statement_invalid = statement_invalid
65
+ @exception = exception
66
+ end
67
+
68
+ def call
69
+ message = @message
70
+ message = redact_statement_invalid(message) if @statement_invalid
71
+ message = filter_key_value_pairs(message)
72
+ message = apply_custom_filter(message)
73
+ message.truncate(MAX_LENGTH)
74
+ end
75
+
76
+ private
77
+
78
+ def redact_statement_invalid(message)
79
+ # Strip the appended SQL statement, which embeds the offending query
80
+ # including literal PII values.
81
+ message = message.split("\n").first.to_s
82
+ message = message.sub(/\s*:\s*(?:INSERT|UPDATE|DELETE|SELECT)\b.*/i, "")
83
+ # PostgreSQL: DETAIL: Key (email)=(alice@example.com) already exists.
84
+ message = message.gsub(/DETAIL:\s*Key\s*\([^)]*\)=\([^)]*\)/, "DETAIL: Key (…)=(…)")
85
+ # MySQL puts the offending value on the first line, ahead of the SQL:
86
+ # Duplicate entry 'alice@example.com' for key 'index_users_on_email'
87
+ message.gsub(/Duplicate entry '(?:[^'\\]|\\.)*'/i, "Duplicate entry '#{FILTERED}'")
88
+ end
89
+
90
+ # Rails' ParameterFilter only masks by key, so on its own it does nothing
91
+ # for a message like "login failed password=hunter2". Scan the text for
92
+ # key/value fragments and ask the filter about each key individually —
93
+ # this reuses the host's exact matching rules (substring, regexp, and
94
+ # proc filters alike).
95
+ def filter_key_value_pairs(message)
96
+ filter = parameter_filter
97
+ return message unless filter
98
+
99
+ # A host that filters a key literally named "message" wants the whole
100
+ # thing gone; honour that before looking inside it.
101
+ whole = filter.filter_param("message", message)
102
+ return whole unless whole.equal?(message)
103
+
104
+ message.gsub(KEY_VALUE_PAIR) do
105
+ match = Regexp.last_match
106
+ key = match[:key]
107
+ value = match[:value]
108
+
109
+ if filter.filter(key => value)[key] == value
110
+ match[0]
111
+ else
112
+ lead = match[:lead].to_s
113
+ "#{lead}#{key}#{lead}#{match[:sep]}#{FILTERED}"
114
+ end
115
+ end
116
+ end
117
+
118
+ def apply_custom_filter(message)
119
+ hook = custom_filter
120
+ return message unless hook
121
+
122
+ result = hook.arity == 1 ? hook.call(message) : hook.call(message, @exception)
123
+ result.to_s
124
+ rescue => e
125
+ # The hook exists to remove sensitive data; if it blows up, storing the
126
+ # unfiltered message would defeat its purpose. Fail closed.
127
+ RailsPulse.logger.warn("[RailsPulse] exception_message_filter raised #{e.class}: #{e.message}; message redacted")
128
+ FILTERED
129
+ end
130
+
131
+ def parameter_filter
132
+ patterns = filter_parameters
133
+ return nil if patterns.blank?
134
+ return nil unless defined?(ActiveSupport::ParameterFilter)
135
+
136
+ ActiveSupport::ParameterFilter.new(patterns)
137
+ end
138
+
139
+ def filter_parameters
140
+ Rails.application.config.filter_parameters
141
+ rescue
142
+ []
143
+ end
144
+
145
+ def custom_filter
146
+ hook = RailsPulse.configuration&.exception_message_filter
147
+ hook if hook.respond_to?(:call)
148
+ rescue
149
+ nil
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,144 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # Estimates when a metric changed, by finding the split in a time series that
4
+ # best separates it into a "before" and an "after".
5
+ #
6
+ # The method is a single-pass step search: try every split with enough
7
+ # periods on each side, and keep the one that maximises the gap between the
8
+ # traffic-weighted mean before it and after it. That is deliberately simpler
9
+ # than a statistical change-point model. It is explainable — the UI can show
10
+ # a user the two means it compared — and it does not need a distributional
11
+ # assumption to defend.
12
+ #
13
+ # Precision is bounded by what summaries still exist. Hourly summaries are
14
+ # pruned at `hourly_summary_retention` (2 days by default), so a change point
15
+ # can only be pinned to the hour inside that window. Beyond it the answer is
16
+ # a day, and `granularity` says which the caller got — never silently one
17
+ # pretending to be the other.
18
+ class ChangePoint
19
+ MIN_PERIODS_PER_SIDE = 2
20
+
21
+ Result = Struct.new(
22
+ :at, :granularity, :before_value, :after_value, :before_count, :after_count,
23
+ keyword_init: true
24
+ ) do
25
+ def ratio
26
+ return nil if before_value.nil? || before_value.zero?
27
+
28
+ after_value / before_value
29
+ end
30
+
31
+ def delta
32
+ return nil if before_value.nil? || after_value.nil?
33
+
34
+ after_value - before_value
35
+ end
36
+
37
+ # True when the estimate sits inside the retained hourly window, so `at`
38
+ # is accurate to the hour rather than the day.
39
+ def hourly?
40
+ granularity == "hour"
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ at: at,
46
+ granularity: granularity,
47
+ before_value: before_value,
48
+ after_value: after_value,
49
+ before_count: before_count,
50
+ after_count: after_count,
51
+ ratio: ratio,
52
+ delta: delta
53
+ }
54
+ end
55
+ end
56
+
57
+ # @param subject [Route, Query, Job, :requests]
58
+ # @param metric [Symbol] :p50, :p95, :p99, :avg or :error_rate
59
+ # @param range [Range<Time>] the window to search within
60
+ # @return [Result, nil] nil when there is not enough of a series to split
61
+ def self.call(subject, metric: :p95, range: nil)
62
+ range ||= default_range
63
+ new(subject, metric: metric, range: range).call
64
+ end
65
+
66
+ def self.default_range
67
+ (RailsPulse.configuration.baseline_window.ago..Time.current)
68
+ end
69
+
70
+ def initialize(subject, metric:, range:)
71
+ @subject = Subject.wrap(subject)
72
+ @metric = Metric.validate!(metric)
73
+ @range = range
74
+ end
75
+
76
+ def call
77
+ series = best_available_series
78
+ return nil if series.nil? || series.size < MIN_PERIODS_PER_SIDE * 2
79
+
80
+ split = best_split(series)
81
+ return nil if split.nil?
82
+
83
+ before = series.points[0...split]
84
+ after = series.points[split..]
85
+
86
+ Result.new(
87
+ at: after.first.period_start,
88
+ granularity: series.period_type,
89
+ before_value: Metric.combine(before.map { |p| [ p.value, p.count ] }),
90
+ after_value: Metric.combine(after.map { |p| [ p.value, p.count ] }),
91
+ before_count: before.sum(&:count),
92
+ after_count: after.sum(&:count)
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :subject, :metric, :range
99
+
100
+ # Prefer hourly precision, but only where hourly summaries are actually
101
+ # retained. Asking for hours outside that window returns a truncated series
102
+ # that would place the change point at the edge of retention rather than
103
+ # where it happened — worse than honestly answering in days.
104
+ def best_available_series
105
+ hourly = hourly_series
106
+ return hourly if hourly && hourly.size >= MIN_PERIODS_PER_SIDE * 2
107
+
108
+ daily = Series.load(subject, metric: metric, range: range, period_type: "day")
109
+ daily.empty? ? hourly : daily
110
+ end
111
+
112
+ def hourly_series
113
+ retention_start = RailsPulse.configuration.hourly_summary_retention.ago
114
+ return nil if range.end && range.end < retention_start
115
+
116
+ hourly_range = ([ range.begin, retention_start ].compact.max)..range.end
117
+ series = Series.load(subject, metric: metric, range: hourly_range, period_type: "hour")
118
+ series.empty? ? nil : series
119
+ end
120
+
121
+ # Returns the index the "after" side starts at, or nil if no split has
122
+ # enough periods on both sides.
123
+ def best_split(series)
124
+ points = series.points
125
+ best_index = nil
126
+ best_gap = 0.0
127
+
128
+ (MIN_PERIODS_PER_SIDE..(points.size - MIN_PERIODS_PER_SIDE)).each do |index|
129
+ before = Metric.combine(points[0...index].map { |p| [ p.value, p.count ] })
130
+ after = Metric.combine(points[index..].map { |p| [ p.value, p.count ] })
131
+ next if before.nil? || after.nil?
132
+
133
+ gap = (after - before).abs
134
+ if gap > best_gap
135
+ best_gap = gap
136
+ best_index = index
137
+ end
138
+ end
139
+
140
+ best_index
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,92 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # Measures a subject's recent behaviour against its own history.
4
+ #
5
+ # This is the entry point for "what changed?", the question the dashboard's
6
+ # threshold rules cannot answer. It reads only from summaries, which cleanup
7
+ # retains at day granularity indefinitely, so a comparison stays available
8
+ # long after the raw requests behind it have aged out.
9
+ class Compare
10
+ # @param subject [Route, Query, Job, :requests]
11
+ # @param metric [Symbol] :p50, :p95, :p99, :avg or :error_rate
12
+ # @param as_of [Time] treat this as "now"; the current window ends here
13
+ # @return [Comparison] always returned — ask it whether it is comparable
14
+ def self.call(subject, metric: :p95, as_of: Time.current)
15
+ new(subject, metric: metric, as_of: as_of).call
16
+ end
17
+
18
+ # Compares every subject of a kind in one pass, for callers that need to
19
+ # rank or filter across an application rather than inspect one route.
20
+ #
21
+ # @param scope [ActiveRecord::Relation, Class] Route, Query or Job
22
+ # @return [Array<Comparison>] only comparisons with usable data on both sides
23
+ def self.scan(scope, metric: :p95, as_of: Time.current)
24
+ scope.find_each.filter_map do |record|
25
+ comparison = call(record, metric: metric, as_of: as_of)
26
+ comparison if comparison.sufficient_data?
27
+ end
28
+ end
29
+
30
+ def initialize(subject, metric: :p95, as_of: Time.current)
31
+ @subject = Subject.wrap(subject)
32
+ @metric = Metric.validate!(metric)
33
+ @as_of = as_of
34
+ end
35
+
36
+ def call
37
+ current = Series.load(subject, metric: metric, range: current_range, period_type: period_type)
38
+ baseline = Series.load(subject, metric: metric, range: baseline_range, period_type: period_type)
39
+
40
+ Comparison.new(
41
+ subject: subject,
42
+ metric: metric,
43
+ period_type: period_type,
44
+ baseline_value: baseline.value,
45
+ baseline_count: baseline.total_count,
46
+ baseline_periods: baseline.size,
47
+ current_value: current.value,
48
+ current_count: current.total_count
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :subject, :metric, :as_of
55
+
56
+ def period_type
57
+ "day"
58
+ end
59
+
60
+ def comparison_window
61
+ RailsPulse.configuration.comparison_window
62
+ end
63
+
64
+ def baseline_window
65
+ RailsPulse.configuration.baseline_window
66
+ end
67
+
68
+ # Windows are aligned to period boundaries rather than measured back from
69
+ # the wall clock. Day summaries are written by SummaryJob at midnight for
70
+ # the day that just ended, so an unaligned "now minus 24 hours" window
71
+ # straddles two day-periods and matches neither — at noon it would contain
72
+ # no day summary at all. Aligning means the current window is always the
73
+ # most recent *complete* day.
74
+ def current_end
75
+ RailsPulse::Summary.normalize_period_start(period_type, as_of)
76
+ end
77
+
78
+ # The recent slice under test: the last complete period(s).
79
+ def current_range
80
+ (current_end - comparison_window)...current_end
81
+ end
82
+
83
+ # History, ending where the current window begins. The two must not
84
+ # overlap: including the period under test in its own baseline damps
85
+ # exactly the change the comparison exists to detect.
86
+ def baseline_range
87
+ baseline_end = current_end - comparison_window
88
+ (baseline_end - baseline_window)...baseline_end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,126 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # The result of measuring a recent window against a historical baseline.
4
+ #
5
+ # This is a value object on purpose. It is the shape callers outside this
6
+ # namespace — dashboards today, findings and Pro tooling later — are expected
7
+ # to depend on, so it carries every number needed to explain the verdict
8
+ # rather than just the verdict.
9
+ class Comparison
10
+ attr_reader :subject, :metric, :baseline_value, :baseline_count, :baseline_periods,
11
+ :current_value, :current_count, :period_type
12
+
13
+ def initialize(subject:, metric:, period_type:,
14
+ baseline_value:, baseline_count:, baseline_periods:,
15
+ current_value:, current_count:)
16
+ @subject = subject
17
+ @metric = metric
18
+ @period_type = period_type
19
+ @baseline_value = baseline_value
20
+ @baseline_count = baseline_count
21
+ @baseline_periods = baseline_periods
22
+ @current_value = current_value
23
+ @current_count = current_count
24
+ end
25
+
26
+ # True when both sides produced a value. A comparison can be well-formed
27
+ # and still not be a regression; this only says the arithmetic is possible.
28
+ def comparable?
29
+ !baseline_value.nil? && !current_value.nil? && baseline_value.to_f > 0
30
+ end
31
+
32
+ def delta
33
+ return nil unless comparable?
34
+
35
+ current_value - baseline_value
36
+ end
37
+
38
+ def ratio
39
+ return nil unless comparable?
40
+
41
+ current_value / baseline_value
42
+ end
43
+
44
+ def percent_change
45
+ return nil unless comparable?
46
+
47
+ (ratio - 1) * 100
48
+ end
49
+
50
+ def direction
51
+ return :unknown unless comparable?
52
+ return :flat if delta.abs < Float::EPSILON
53
+
54
+ delta.positive? ? :up : :down
55
+ end
56
+
57
+ def unit
58
+ Metric.unit(metric)
59
+ end
60
+
61
+ # Enough traffic and enough history for the comparison to mean anything.
62
+ # Checked separately from `regression?` so a caller can tell "nothing
63
+ # happened" apart from "not enough data to say".
64
+ def sufficient_data?
65
+ return false unless comparable?
66
+
67
+ thresholds = RailsPulse.configuration.regression_thresholds
68
+
69
+ baseline_periods >= thresholds[:min_baseline_periods] &&
70
+ baseline_count >= thresholds[:min_samples] &&
71
+ current_count >= thresholds[:min_samples]
72
+ end
73
+
74
+ # A deterministic regression: the metric grew by at least the configured
75
+ # multiple AND by at least the absolute floor for its unit. Both gates
76
+ # matter — the ratio alone flags trivial millisecond noise on fast
77
+ # endpoints, and the floor alone flags slow endpoints that never changed.
78
+ def regression?
79
+ return false unless sufficient_data?
80
+ return false unless Metric.higher_is_worse?(metric)
81
+
82
+ thresholds = RailsPulse.configuration.regression_thresholds
83
+ floor = metric == :error_rate ? thresholds[:min_delta_rate] : thresholds[:min_delta_ms]
84
+
85
+ ratio >= thresholds[:ratio] && delta >= floor
86
+ end
87
+
88
+ # One line a human can check the maths against. Findings and API output
89
+ # both render from this rather than each inventing their own phrasing.
90
+ def summary
91
+ return "not enough data to compare" unless comparable?
92
+
93
+ "#{metric.to_s.upcase} #{format_value(baseline_value)} → #{format_value(current_value)} " \
94
+ "(#{percent_change.positive? ? '+' : ''}#{percent_change.round(1)}%)"
95
+ end
96
+
97
+ def to_h
98
+ {
99
+ subject_type: subject.type,
100
+ subject_id: subject.id,
101
+ subject_label: subject.label,
102
+ metric: metric,
103
+ unit: unit,
104
+ period_type: period_type,
105
+ baseline_value: baseline_value,
106
+ baseline_count: baseline_count,
107
+ baseline_periods: baseline_periods,
108
+ current_value: current_value,
109
+ current_count: current_count,
110
+ delta: delta,
111
+ ratio: ratio,
112
+ percent_change: percent_change,
113
+ direction: direction,
114
+ sufficient_data: sufficient_data?,
115
+ regression: regression?
116
+ }
117
+ end
118
+
119
+ private
120
+
121
+ def format_value(value)
122
+ metric == :error_rate ? "#{value.round(2)}%" : "#{value.round(0).to_i}ms"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,63 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # Maps the metric names the Operations API accepts onto the summary columns
4
+ # that back them, and knows how to aggregate each one across several summary
5
+ # rows.
6
+ #
7
+ # Duration metrics are traffic-weighted: a day with 10,000 requests should
8
+ # move a baseline further than a day with 12. Averaging the stored
9
+ # percentiles unweighted would let a quiet Sunday count as much as a busy
10
+ # Monday and make every baseline noisy.
11
+ module Metric
12
+ DURATION_COLUMNS = {
13
+ p50: "p50_duration",
14
+ p95: "p95_duration",
15
+ p99: "p99_duration",
16
+ avg: "avg_duration"
17
+ }.freeze
18
+
19
+ SUPPORTED = (DURATION_COLUMNS.keys + [ :error_rate ]).freeze
20
+
21
+ # Higher is worse for every metric currently supported, but stating it
22
+ # explicitly keeps the comparison code from assuming it forever.
23
+ def self.higher_is_worse?(_metric)
24
+ true
25
+ end
26
+
27
+ def self.validate!(metric)
28
+ return metric.to_sym if SUPPORTED.include?(metric.to_sym)
29
+
30
+ raise ArgumentError, "unsupported metric #{metric.inspect} — expected one of #{SUPPORTED.join(', ')}"
31
+ end
32
+
33
+ def self.unit(metric)
34
+ metric.to_sym == :error_rate ? "%" : "ms"
35
+ end
36
+
37
+ # SQL that reduces a set of summary rows to a single value for this metric.
38
+ # NULLIF guards the zero-count case rather than letting the database raise
39
+ # or return a division artifact.
40
+ def self.aggregate_sql(metric)
41
+ if metric.to_sym == :error_rate
42
+ "SUM(rails_pulse_summaries.error_count) * 100.0 / " \
43
+ "NULLIF(SUM(rails_pulse_summaries.count), 0)"
44
+ else
45
+ column = DURATION_COLUMNS.fetch(metric.to_sym)
46
+ "SUM(rails_pulse_summaries.#{column} * rails_pulse_summaries.count) / " \
47
+ "NULLIF(SUM(rails_pulse_summaries.count), 0)"
48
+ end
49
+ end
50
+
51
+ # Reduces an already-loaded set of [value, count] pairs the same way
52
+ # aggregate_sql would. Used by change-point detection, which needs the
53
+ # per-period series in memory anyway and should not round-trip per split.
54
+ def self.combine(pairs)
55
+ total_count = pairs.sum { |(_value, count)| count.to_i }
56
+ return nil if total_count.zero?
57
+
58
+ weighted = pairs.sum { |(value, count)| value.to_f * count.to_i }
59
+ weighted / total_count
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,84 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # Loads a subject's metric as an ordered time series of summary periods.
4
+ #
5
+ # Every operation in this namespace reads through here, so there is exactly
6
+ # one place that knows how summaries are queried. The row counts involved are
7
+ # small by construction — 30 day-periods for a month of baseline, 48
8
+ # hour-periods for the retained hourly window — so the reduction happens in
9
+ # Ruby rather than in SQL. That keeps one code path across SQLite, PostgreSQL
10
+ # and MySQL instead of three dialects of weighted average.
11
+ class Series
12
+ Point = Struct.new(:period_start, :period_end, :value, :count, keyword_init: true)
13
+
14
+ attr_reader :subject, :metric, :period_type, :points
15
+
16
+ def self.load(subject, metric:, range:, period_type: "day")
17
+ subject = Subject.wrap(subject)
18
+ metric = Metric.validate!(metric)
19
+
20
+ rows = subject.summaries
21
+ .where(period_type: period_type)
22
+ .where(period_start: range)
23
+ .order(:period_start)
24
+ .pluck(:period_start, :period_end, :count, :p50_duration, :p95_duration, :p99_duration, :avg_duration, :error_count)
25
+
26
+ points = rows.filter_map do |period_start, period_end, count, p50, p95, p99, avg, error_count|
27
+ value = extract(metric, p50: p50, p95: p95, p99: p99, avg: avg, count: count, error_count: error_count)
28
+ next if value.nil?
29
+
30
+ Point.new(period_start: period_start, period_end: period_end, value: value.to_f, count: count.to_i)
31
+ end
32
+
33
+ new(subject: subject, metric: metric, period_type: period_type, points: points)
34
+ end
35
+
36
+ def self.extract(metric, p50:, p95:, p99:, avg:, count:, error_count:)
37
+ case metric
38
+ when :p50 then p50
39
+ when :p95 then p95
40
+ when :p99 then p99
41
+ when :avg then avg
42
+ when :error_rate
43
+ count.to_i.zero? ? nil : (error_count.to_i * 100.0 / count.to_i)
44
+ end
45
+ end
46
+ private_class_method :extract
47
+
48
+ def initialize(subject:, metric:, period_type:, points:)
49
+ @subject = subject
50
+ @metric = metric
51
+ @period_type = period_type
52
+ @points = points
53
+ end
54
+
55
+ def empty?
56
+ points.empty?
57
+ end
58
+
59
+ def size
60
+ points.size
61
+ end
62
+
63
+ # Total observations behind the series — requests, executions or runs,
64
+ # depending on the subject. Used to decide whether a comparison has enough
65
+ # traffic to be worth reporting.
66
+ def total_count
67
+ @total_count ||= points.sum(&:count)
68
+ end
69
+
70
+ # The traffic-weighted value across every period in the series.
71
+ def value
72
+ @value ||= Metric.combine(points.map { |point| [ point.value, point.count ] })
73
+ end
74
+
75
+ def first_period_start
76
+ points.first&.period_start
77
+ end
78
+
79
+ def last_period_start
80
+ points.last&.period_start
81
+ end
82
+ end
83
+ end
84
+ end