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,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
|