rails-profiler 0.42.0 → 0.43.0
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 +18 -0
- data/lib/profiler/alerts/sql_redaction.rb +41 -0
- data/lib/profiler/alerts/webhook.rb +62 -0
- data/lib/profiler/alerts/worker.rb +84 -0
- data/lib/profiler/alerts.rb +199 -0
- data/lib/profiler/configuration.rb +23 -0
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f56117e3f0074f2373965bdcd9424ba51d53ccb58fc150826ef4e76c8f387b69
|
|
4
|
+
data.tar.gz: 4003700e30187467f5642d7731f75253655b53eee39d4435a617b41f3b7b0bfa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8bdb227c96e7ccf65bf6415965dee2b10348dfc8a056d7ddc1b77bbe1e0aa81d56b84fbb10e8656991cee6a6de07d27dac20b63d2b2797abc432a0de276fb8c
|
|
7
|
+
data.tar.gz: 4f372a6f1ce65c7735233f0c629dfbbce3fea31023dfb3ca7f4d9172d75c912fe597b8d3c3390acbe5b28c911107c3400397c5a1507b9ab2d17c1ae52f3d653c
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,24 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.43.0] - 2026-10-20
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Alerts:** The new `config.alerts_enabled` (default `false`) turns on threshold alerts. Each profile
|
|
29
|
+
saved that crosses `slow_query_threshold`, `max_queries_warning` or
|
|
30
|
+
`allocated_objects_warning_threshold`, or that repeats a normalized SQL statement (an N+1), is handed to
|
|
31
|
+
the `config.on_detection` block and, when `config.alert_webhook_url` is set, POSTed there as generic JSON
|
|
32
|
+
(see `docs/configuration.md` for the payload). The checks run on one worker thread with a bounded queue (a full queue drops and logs the check), never delay the
|
|
33
|
+
request, and their failures are logged. A profile saved again is not alerted again for a type already delivered,
|
|
34
|
+
and the `path` of a console profile is `null` in the payload. `config.alert_webhook_url=` raises `ArgumentError`
|
|
35
|
+
for a URL that is not http(s) with a host, or that carries credentials. An alert carries the normalized SQL and a fingerprint, never a body
|
|
36
|
+
or a parameter; SQL literals (quoted strings with a backslash or a doubled quote, double quoted text,
|
|
37
|
+
comments, dollar-quoted bodies) are removed from the statement, and a statement whose literals cannot be
|
|
38
|
+
read with certainty is cut before the first one.
|
|
39
|
+
|
|
22
40
|
## [0.42.0] - 2026-10-20
|
|
23
41
|
|
|
24
42
|
<!-- stamped -->
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Alerts
|
|
5
|
+
# Removes from a SQL statement what the shared N1Patterns.normalize_sql lets through: strings
|
|
6
|
+
# with a backslash escape or a doubled quote, double quoted text, comments and dollar-quoted
|
|
7
|
+
# bodies. Used for alerts only; the shared normalization is left as it is.
|
|
8
|
+
#
|
|
9
|
+
# The statement is read twice, with backslash escapes (MySQL) and without (standard SQL). When
|
|
10
|
+
# the two readings disagree, or a quote or a comment opener is left, the statement is cut at
|
|
11
|
+
# the first one: a value is dropped rather than guessed. A literal in a vendor syntax not
|
|
12
|
+
# listed here can still remain, hence also the cut at SQL_LIMIT in Alerts.
|
|
13
|
+
module SqlRedaction
|
|
14
|
+
STRINGS_WITH_BACKSLASH = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"/m
|
|
15
|
+
STRINGS_STANDARD = /'(?:[^']|'')*'|"(?:[^"]|"")*"/m
|
|
16
|
+
DOLLAR_QUOTED = /\$(?<tag>[A-Za-z_]\w*|)\$.*?\$\k<tag>\$/m
|
|
17
|
+
COMMENTS = %r{/\*.*?\*/|--[^\n]*|(?<=\s|\A)\#[^\n]*}m
|
|
18
|
+
# Tried left to right, so a quote in a comment, or a comment in a quote, is not read as the other.
|
|
19
|
+
READINGS = [STRINGS_WITH_BACKSLASH, STRINGS_STANDARD].map do |strings|
|
|
20
|
+
Regexp.union(strings, DOLLAR_QUOTED, COMMENTS)
|
|
21
|
+
end.freeze
|
|
22
|
+
# What is left of a literal or a comment when a form was not read to its end.
|
|
23
|
+
OPENER = %r{['"]|/\*|--|\$(?:[A-Za-z_]\w*)?\$|(?<=\s|\A)#}
|
|
24
|
+
|
|
25
|
+
# The statement is cut here before it is read. The dollar-quote pattern scans to the end for
|
|
26
|
+
# each opener, which is quadratic on a long statement full of $ with no closing tag; this
|
|
27
|
+
# bounds it. Alerts only send SQL_LIMIT characters of the normalized statement anyway.
|
|
28
|
+
MAX_LENGTH = 5_000
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def redact(sql)
|
|
33
|
+
sql = sql[0, MAX_LENGTH] if sql.length > MAX_LENGTH
|
|
34
|
+
readings = READINGS.map { |pattern| sql.gsub(pattern) { |m| m.start_with?("'", '"') ? "?" : " " } }
|
|
35
|
+
return sql[0, sql.index(OPENER) || sql.length] if readings.uniq.size > 1 || readings.first.match?(OPENER)
|
|
36
|
+
|
|
37
|
+
readings.first
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Profiler
|
|
8
|
+
module Alerts
|
|
9
|
+
# POSTs an alert as JSON. Called from the detached thread of Alerts.check, which holds nothing
|
|
10
|
+
# of the request, so its own HTTP call is not recorded into a profile and it never delays the
|
|
11
|
+
# request. Errors are logged, never raised.
|
|
12
|
+
module Webhook
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The URL as a String, nil when none is given. Raises ArgumentError for a URL that cannot be
|
|
16
|
+
# called: not http(s), no host, or credentials in it (never sent, so refused).
|
|
17
|
+
def validate!(value)
|
|
18
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
19
|
+
|
|
20
|
+
uri = URI.parse(value.to_s.strip)
|
|
21
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
22
|
+
raise ArgumentError, "config.alert_webhook_url: #{value.to_s.inspect} must be an http(s) URL with a host"
|
|
23
|
+
end
|
|
24
|
+
if uri.userinfo
|
|
25
|
+
raise ArgumentError, "config.alert_webhook_url: credentials in the URL are not sent; " \
|
|
26
|
+
"put the secret in the path or a query parameter of the receiver"
|
|
27
|
+
end
|
|
28
|
+
uri.to_s
|
|
29
|
+
rescue URI::InvalidURIError
|
|
30
|
+
raise ArgumentError, "config.alert_webhook_url: #{value.to_s.inspect} is not a valid URL"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def post(alert, url, timeout)
|
|
34
|
+
return if url.nil? || url.to_s.empty?
|
|
35
|
+
|
|
36
|
+
send_request(url, alert, timeout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def send_request(url, alert, timeout)
|
|
40
|
+
response = http_for(URI.parse(url.to_s), timeout).request(request_for(url, alert))
|
|
41
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
42
|
+
|
|
43
|
+
Profiler.log_error("Alerts: webhook answered #{response.code} for #{alert[:type]}")
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Profiler.log_error("Alerts: webhook failed for #{alert[:type]}", e)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def http_for(uri, timeout)
|
|
49
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
50
|
+
http.use_ssl = uri.scheme == "https"
|
|
51
|
+
http.open_timeout = http.read_timeout = http.write_timeout = timeout
|
|
52
|
+
http
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def request_for(url, alert)
|
|
56
|
+
request = Net::HTTP::Post.new(URI.parse(url.to_s).request_uri, "Content-Type" => "application/json")
|
|
57
|
+
request.body = JSON.generate(alert)
|
|
58
|
+
request
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Alerts
|
|
5
|
+
# One thread, one bounded queue. Every check (the detections, the hook, the webhook) runs here,
|
|
6
|
+
# one after the other, so a slow or unreachable webhook never piles up threads: when the queue
|
|
7
|
+
# is full the job is dropped and logged. The thread is started on first use, with
|
|
8
|
+
# RequestContext.detached so it holds nothing of the request that started it, and started
|
|
9
|
+
# again when it is gone (a fork does not carry threads over).
|
|
10
|
+
class Worker
|
|
11
|
+
CAPACITY = 100
|
|
12
|
+
|
|
13
|
+
def initialize(capacity: CAPACITY)
|
|
14
|
+
@queue = SizedQueue.new(capacity)
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
@idle = ConditionVariable.new
|
|
17
|
+
@pending = 0
|
|
18
|
+
@thread = nil
|
|
19
|
+
@pid = Process.pid
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# True when queued, false when the queue is full.
|
|
23
|
+
def push(&job)
|
|
24
|
+
ensure_thread
|
|
25
|
+
@lock.synchronize { @pending += 1 }
|
|
26
|
+
@queue.push(job, true)
|
|
27
|
+
true
|
|
28
|
+
rescue ThreadError
|
|
29
|
+
@lock.synchronize { @pending -= 1 }
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Waits until every job queued so far is done. For tests and for shutdown.
|
|
34
|
+
def drain(timeout = 10)
|
|
35
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
36
|
+
@lock.synchronize do
|
|
37
|
+
while @pending.positive?
|
|
38
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
return false unless left.positive?
|
|
40
|
+
|
|
41
|
+
@idle.wait(@lock, left)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def ensure_thread
|
|
50
|
+
@lock.synchronize do
|
|
51
|
+
reset_after_fork
|
|
52
|
+
return if @thread&.alive?
|
|
53
|
+
|
|
54
|
+
@thread = Instrumentation::RequestContext.detached { Thread.new { work } }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset_after_fork
|
|
59
|
+
return if @pid == Process.pid
|
|
60
|
+
|
|
61
|
+
@pid = Process.pid
|
|
62
|
+
@queue = SizedQueue.new(@queue.max)
|
|
63
|
+
@pending = 0
|
|
64
|
+
@thread = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def work
|
|
68
|
+
loop do
|
|
69
|
+
job = @queue.pop
|
|
70
|
+
begin
|
|
71
|
+
job.call
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Profiler.log_error("Alerts: a check failed", e)
|
|
74
|
+
ensure
|
|
75
|
+
@lock.synchronize do
|
|
76
|
+
@pending -= 1
|
|
77
|
+
@idle.broadcast if @pending <= 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "digest"
|
|
8
|
+
require_relative "alerts/webhook"
|
|
9
|
+
require_relative "alerts/sql_redaction"
|
|
10
|
+
require_relative "alerts/worker"
|
|
11
|
+
# Alerts.normalize_sql wraps N1Patterns.normalize_sql. That file is otherwise only loaded by the MCP
|
|
12
|
+
# server, which an application without MCP never starts, so it is required here, not on first use.
|
|
13
|
+
require_relative "mcp/resources/n1_patterns"
|
|
14
|
+
require_relative "instrumentation/request_context"
|
|
15
|
+
require_relative "collectors/scoped_notifications"
|
|
16
|
+
|
|
17
|
+
module Profiler
|
|
18
|
+
# Threshold alerts. When config.alerts_enabled is true, every profile saved is compared with the
|
|
19
|
+
# thresholds the profiler already has (slow_query_threshold, max_queries_warning,
|
|
20
|
+
# allocated_objects_warning_threshold) and checked for an N+1. Each detection is handed to
|
|
21
|
+
# config.on_detection and, when config.alert_webhook_url is set, POSTed there as JSON.
|
|
22
|
+
#
|
|
23
|
+
# An alert carries no raw data: no body, no parameters, no header, only the SQL statement with
|
|
24
|
+
# its literals replaced with ? (see SqlRedaction) and a fingerprint of it.
|
|
25
|
+
#
|
|
26
|
+
# Nothing here may slow or break the request: the whole check (the detections, which walk the
|
|
27
|
+
# queries of the profile, then the hook and the webhook) runs on the one worker thread of
|
|
28
|
+
# Alerts::Worker, fed through a bounded queue (a full queue drops the check and logs it), so
|
|
29
|
+
# `check` returns at once and a slow webhook never piles up threads.
|
|
30
|
+
# Each family of alert is detected in its own rescue, so an error on one never hides the others.
|
|
31
|
+
#
|
|
32
|
+
# One alert per type and per profile: a profile saved again (a late outbound HTTP call) is not
|
|
33
|
+
# alerted again for a type already delivered. The path of a console profile is the expression
|
|
34
|
+
# typed, which may hold a secret passed as a literal, so it is left out of the payload.
|
|
35
|
+
module Alerts
|
|
36
|
+
TYPES = %w[slow_query too_many_queries excessive_allocations n_plus_one].freeze
|
|
37
|
+
SQL_LIMIT = 200
|
|
38
|
+
N_PLUS_ONE_LIMIT = 10
|
|
39
|
+
DELIVERED_LIMIT = 2000
|
|
40
|
+
DELIVERED_LOCK = Mutex.new
|
|
41
|
+
DELIVERED = {}
|
|
42
|
+
WORKER = Worker.new
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Queues the check for the worker thread. True when queued, nil when alerts are off or the
|
|
47
|
+
# queue is full. The profile is saved and no longer written to by then.
|
|
48
|
+
def check(profile)
|
|
49
|
+
config = Profiler.configuration
|
|
50
|
+
return unless config.alerts_enabled
|
|
51
|
+
return true if WORKER.push { run(profile, config) }
|
|
52
|
+
|
|
53
|
+
Profiler.log_error("Alerts: queue full, check of profile #{profile&.token} dropped")
|
|
54
|
+
nil
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Profiler.log_error("Alerts: could not start the check of profile #{profile&.token}", e)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run(profile, config)
|
|
61
|
+
Collectors::ScopedNotifications.forget_inherited_scope
|
|
62
|
+
detections(profile, config).each { |alert| deliver(alert, config) if first_delivery?(alert) }
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
Profiler.log_error("Alerts: could not check profile #{profile&.token}", e)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True once per profile token and alert type. The store is bounded, oldest token out first.
|
|
68
|
+
def first_delivery?(alert)
|
|
69
|
+
DELIVERED_LOCK.synchronize do
|
|
70
|
+
types = (DELIVERED[alert[:profile_token]] ||= [])
|
|
71
|
+
next false if types.include?(alert[:type])
|
|
72
|
+
|
|
73
|
+
types << alert[:type]
|
|
74
|
+
DELIVERED.shift while DELIVERED.size > DELIVERED_LIMIT
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Waits for the queued checks to finish (tests, shutdown).
|
|
80
|
+
def drain(timeout = 10)
|
|
81
|
+
WORKER.drain(timeout)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Every family in its own rescue: an error on one is logged and the others still go out.
|
|
85
|
+
def detections(profile, config)
|
|
86
|
+
base = base_payload(profile)
|
|
87
|
+
queries = queries_of(profile)
|
|
88
|
+
{ slow_query: -> { slow_query_alerts(base, queries, config) },
|
|
89
|
+
too_many_queries: -> { too_many_queries_alerts(base, queries, config) },
|
|
90
|
+
excessive_allocations: -> { allocation_alerts(base, profile, config) },
|
|
91
|
+
n_plus_one: -> { n_plus_one_alerts(base, queries) } }
|
|
92
|
+
.flat_map { |family, detect| safely(family, profile, &detect) }
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Profiler.log_error("Alerts: could not read profile #{profile&.token}", e)
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def safely(family, profile)
|
|
99
|
+
yield
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Profiler.log_error("Alerts: could not detect #{family} for profile #{profile&.token}", e)
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def base_payload(profile)
|
|
106
|
+
request = profile.collector_data("request")
|
|
107
|
+
{
|
|
108
|
+
profile_token: profile.token,
|
|
109
|
+
method: profile.method,
|
|
110
|
+
path: profile.profile_type == "console" ? nil : profile.path,
|
|
111
|
+
controller_action: request.is_a?(Hash) ? read(request, :controller_action) : nil,
|
|
112
|
+
status: profile.status,
|
|
113
|
+
detected_at: Time.now.utc.iso8601
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A collector's value whatever the keys' type: symbols as the collector built it (the profile
|
|
118
|
+
# Alerts.check receives), strings once the profile is stored and read back. Same principle as
|
|
119
|
+
# Otel::TraceExporter.list.
|
|
120
|
+
def read(hash, key)
|
|
121
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def queries_of(profile)
|
|
125
|
+
data = profile.collector_data("database")
|
|
126
|
+
list = data.is_a?(Hash) ? read(data, :queries) : nil
|
|
127
|
+
return [] unless list.is_a?(Array)
|
|
128
|
+
|
|
129
|
+
list.filter_map do |q|
|
|
130
|
+
next unless q.is_a?(Hash) && read(q, :sql).is_a?(String)
|
|
131
|
+
|
|
132
|
+
{ sql: read(q, :sql), duration: read(q, :duration) }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def slow_query_alerts(base, queries, config)
|
|
137
|
+
threshold = config.slow_query_threshold
|
|
138
|
+
return [] unless threshold
|
|
139
|
+
|
|
140
|
+
slow = queries.select { |q| q[:duration].to_f > threshold }
|
|
141
|
+
return [] if slow.empty?
|
|
142
|
+
|
|
143
|
+
slowest = slow.max_by { |q| q[:duration].to_f }
|
|
144
|
+
[base.merge(type: "slow_query", threshold: threshold, unit: "ms", value: slowest[:duration].to_f,
|
|
145
|
+
count: slow.size, query: describe_normalized(normalize_sql(slowest[:sql])))]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def too_many_queries_alerts(base, queries, config)
|
|
149
|
+
threshold = config.max_queries_warning
|
|
150
|
+
return [] unless threshold && queries.size > threshold
|
|
151
|
+
|
|
152
|
+
[base.merge(type: "too_many_queries", threshold: threshold, unit: "queries", value: queries.size)]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def allocation_alerts(base, profile, config)
|
|
156
|
+
threshold = config.allocated_objects_warning_threshold
|
|
157
|
+
value = profile.allocated_objects
|
|
158
|
+
return [] unless threshold && value && value > threshold
|
|
159
|
+
|
|
160
|
+
[base.merge(type: "excessive_allocations", threshold: threshold, unit: "objects", value: value)]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def n_plus_one_alerts(base, queries)
|
|
164
|
+
groups = queries.group_by { |q| normalize_sql(q[:sql]) }
|
|
165
|
+
repeated = groups.select { |_, list| list.size > 1 }
|
|
166
|
+
return [] if repeated.empty?
|
|
167
|
+
|
|
168
|
+
[base.merge(type: "n_plus_one", threshold: 1, unit: "repeats", value: repeated.values.map(&:size).max,
|
|
169
|
+
patterns: patterns_of(repeated))]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def patterns_of(repeated)
|
|
173
|
+
repeated.sort_by { |_, list| -list.size }.first(N_PLUS_ONE_LIMIT).map do |normalized, list|
|
|
174
|
+
describe_normalized(normalized).merge(count: list.size)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The shared normalization (numbers, $1 and simple quoted strings) after SqlRedaction.redact.
|
|
179
|
+
def normalize_sql(sql)
|
|
180
|
+
MCP::Resources::N1Patterns.normalize_sql(SqlRedaction.redact(sql))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def describe_normalized(normalized)
|
|
184
|
+
{ sql: normalized[0, SQL_LIMIT], fingerprint: Digest::SHA256.hexdigest(normalized)[0, 12] }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def deliver(alert, config)
|
|
188
|
+
call_hook(alert, config)
|
|
189
|
+
Webhook.post(alert, config.alert_webhook_url, config.alert_webhook_timeout)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def call_hook(alert, config)
|
|
193
|
+
hook = config.on_detection
|
|
194
|
+
hook&.call(alert)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
Profiler.log_error("Alerts: config.on_detection raised for #{alert[:type]}", e)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "local_request"
|
|
5
5
|
require_relative "allocation_counter"
|
|
6
|
+
require_relative "alerts/webhook"
|
|
6
7
|
|
|
7
8
|
module Profiler
|
|
8
9
|
# Header the profiler's own clients send on every request. A page on another origin cannot
|
|
@@ -40,9 +41,12 @@ module Profiler
|
|
|
40
41
|
# URL schemes the source links can use. Anything else is never turned into a link.
|
|
41
42
|
EDITOR_URL_SCHEMES = %w[vscode idea cursor].freeze
|
|
42
43
|
|
|
44
|
+
attr_reader :alert_webhook_url
|
|
45
|
+
|
|
43
46
|
attr_accessor :storage_options, :collectors,
|
|
44
47
|
:slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
45
48
|
:track_memory, :allocated_objects_warning_threshold,
|
|
49
|
+
:alerts_enabled, :alert_webhook_timeout,
|
|
46
50
|
:mcp_enabled, :mcp_transport, :mcp_port, :mcp_read_only,
|
|
47
51
|
:authorization_mode, :extension_cors_enabled,
|
|
48
52
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
@@ -84,6 +88,11 @@ module Profiler
|
|
|
84
88
|
# versions did, about 0.1 ms each), :none never.
|
|
85
89
|
@sql_backtrace = :first_and_slow
|
|
86
90
|
@track_memory = true
|
|
91
|
+
# Threshold alerts (Profiler::Alerts): off until enabled; the webhook stays off until a URL is set.
|
|
92
|
+
@alerts_enabled = false
|
|
93
|
+
@alert_webhook_url = nil
|
|
94
|
+
@alert_webhook_timeout = 3 # seconds, for the connection and for the answer
|
|
95
|
+
@on_detection = nil
|
|
87
96
|
# Not compared with anything yet. The default is the former 100 MB memory threshold read
|
|
88
97
|
# as the objects it stood for.
|
|
89
98
|
@allocated_objects_warning_threshold = 100 * 1024 * 1024 / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
@@ -202,6 +211,13 @@ module Profiler
|
|
|
202
211
|
# The editor the file:line links open: "vscode", "idea" or "cursor" (String or Symbol). A
|
|
203
212
|
# value outside that list is kept as given, but #editor_link_scheme answers nil for it, so
|
|
204
213
|
# that no link is built.
|
|
214
|
+
# Nil or empty turns the webhook off. Otherwise an absolute http(s) URL with a host, and no
|
|
215
|
+
# user or password in it: the webhook sends none, so a URL carrying them is refused rather than
|
|
216
|
+
# silently called without them.
|
|
217
|
+
def alert_webhook_url=(value)
|
|
218
|
+
@alert_webhook_url = Alerts::Webhook.validate!(value)
|
|
219
|
+
end
|
|
220
|
+
|
|
205
221
|
def editor_url_scheme=(value)
|
|
206
222
|
@editor_url_scheme = value.nil? ? nil : value.to_s
|
|
207
223
|
end
|
|
@@ -348,6 +364,13 @@ module Profiler
|
|
|
348
364
|
@name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
|
|
349
365
|
end
|
|
350
366
|
|
|
367
|
+
# Block called with the alert (a Hash, see Profiler::Alerts) each time a profile crosses a
|
|
368
|
+
# threshold or shows an N+1. Only runs when config.alerts_enabled is true.
|
|
369
|
+
def on_detection(&block)
|
|
370
|
+
@on_detection = block if block
|
|
371
|
+
@on_detection
|
|
372
|
+
end
|
|
373
|
+
|
|
351
374
|
def authorize_with(&block)
|
|
352
375
|
@authorize_block = block
|
|
353
376
|
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -54,6 +54,7 @@ module Profiler
|
|
|
54
54
|
return false if target.is_a?(Storage::Unavailable)
|
|
55
55
|
|
|
56
56
|
target.save(profile.token, profile)
|
|
57
|
+
Alerts.check(profile)
|
|
57
58
|
true
|
|
58
59
|
rescue StandardError => e
|
|
59
60
|
log_error("#{from}: could not save profile #{profile.token}", e)
|
|
@@ -274,6 +275,7 @@ require_relative "profiler/storage/token"
|
|
|
274
275
|
require_relative "profiler/storage/private_files"
|
|
275
276
|
require_relative "profiler/storage/unavailable"
|
|
276
277
|
require_relative "profiler/assets"
|
|
278
|
+
require_relative "profiler/alerts"
|
|
277
279
|
require_relative "profiler/ajax_data"
|
|
278
280
|
require_relative "profiler/peers"
|
|
279
281
|
require_relative "profiler/peers/peer_proxy"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.43.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -150,6 +150,10 @@ files:
|
|
|
150
150
|
- exe/profiler-mcp
|
|
151
151
|
- lib/profiler.rb
|
|
152
152
|
- lib/profiler/ajax_data.rb
|
|
153
|
+
- lib/profiler/alerts.rb
|
|
154
|
+
- lib/profiler/alerts/sql_redaction.rb
|
|
155
|
+
- lib/profiler/alerts/webhook.rb
|
|
156
|
+
- lib/profiler/alerts/worker.rb
|
|
153
157
|
- lib/profiler/allocation_counter.rb
|
|
154
158
|
- lib/profiler/assets.rb
|
|
155
159
|
- lib/profiler/body_content.rb
|