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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56b64fc5b7eafdb748a1dc236605573317b7eb018192f3f784473dee1452954d
4
- data.tar.gz: a347f0f26ca063176c85d7ce3671c30a85bfc9fc642f2f95d46332dd8dafcf4c
3
+ metadata.gz: f56117e3f0074f2373965bdcd9424ba51d53ccb58fc150826ef4e76c8f387b69
4
+ data.tar.gz: 4003700e30187467f5642d7731f75253655b53eee39d4435a617b41f3b7b0bfa
5
5
  SHA512:
6
- metadata.gz: da5993a01741578d15b8f6d71c46a2e093991ede0526cb622950ca01d905224971a9241c39eb50d5f1adf904ff39217f1d68c81fd9982405167912f75a5f20b2
7
- data.tar.gz: 57f912fefa7c794641f42362384bb207acfd06e617ae031c3fcea27f8e9e6b632f0f10f7a97310a4c57e9a5648c382e2a47aac750850e279eb3c8c2897ff65b4
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.42.0"
4
+ VERSION = "0.43.0"
5
5
 
6
6
  GEM_ROOT = File.expand_path("../..", __dir__)
7
7
 
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.42.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