brainzlab 0.1.32 → 0.1.34
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/README.md +38 -0
- data/lib/brainzlab/configuration.rb +81 -0
- data/lib/brainzlab/instrumentation/active_record.rb +32 -1
- data/lib/brainzlab/instrumentation/grape.rb +35 -2
- data/lib/brainzlab/pulse/client.rb +13 -0
- data/lib/brainzlab/pulse/explain_capture.rb +123 -0
- data/lib/brainzlab/pulse/tracer.rb +19 -4
- data/lib/brainzlab/pulse.rb +1 -0
- data/lib/brainzlab/signal/client.rb +16 -2
- data/lib/brainzlab/signal.rb +8 -0
- data/lib/brainzlab/utilities/route_path_normalizer.rb +91 -0
- data/lib/brainzlab/utilities.rb +1 -0
- data/lib/brainzlab/version.rb +1 -1
- data/lib/brainzlab.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c67c9e29d88bcc008c8ba0464b933b61f3a9bd255868f59af8b7e0daf31665dc
|
|
4
|
+
data.tar.gz: bbfe09e8406b488f995cf312b1b1838cd4f614dc1708316eda3c3b5cd1dcab08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d83a33af5f933e84f1bec1327f564b79d2fe2dc8c94035beebbc1985d5e44130d88a442ca95bb2654f9a4bb3aed207e3514d083c2fb69d92a2faf0df38becc62
|
|
7
|
+
data.tar.gz: 93a945a9c8d6f3d91ef144fd005b2b0f1bcdad9a90c8f7cabdca7f2a4602b6678409cc2e17d2e06baf8e560301c0b9dafbb2339f6e56e353022098ed145e732a
|
data/README.md
CHANGED
|
@@ -198,6 +198,44 @@ config.pulse_excluded_paths = ['/health', '/ping', '/up', '/assets']
|
|
|
198
198
|
config.pulse_sample_rate = 1.0 # 1.0 = 100%
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
### Query plans (EXPLAIN)
|
|
202
|
+
|
|
203
|
+
Off by default. When enabled, a query slower than the threshold gets an
|
|
204
|
+
`EXPLAIN` run against your own database and the plan travels with the span, so
|
|
205
|
+
Pulse can tell you *why* a query is slow instead of only that it is.
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
config.pulse_explain_enabled = true # default: false
|
|
209
|
+
config.pulse_explain_threshold = 500 # ms; defaults to slow_query_threshold
|
|
210
|
+
config.pulse_explain_timeout = 2 # seconds to wait for a plan
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Or via environment: `BRAINZLAB_PULSE_EXPLAIN_ENABLED`,
|
|
214
|
+
`BRAINZLAB_PULSE_EXPLAIN_THRESHOLD`, `BRAINZLAB_PULSE_EXPLAIN_TIMEOUT`.
|
|
215
|
+
|
|
216
|
+
What it will and will not do:
|
|
217
|
+
|
|
218
|
+
- **Read-only statements only.** A write is never explained.
|
|
219
|
+
- **Plain `EXPLAIN`, never `EXPLAIN ANALYZE`** — the statement is planned, not run.
|
|
220
|
+
- **One plan per distinct statement**, not one per occurrence.
|
|
221
|
+
- **A timeout**, so a slow planner cannot stall a request.
|
|
222
|
+
- **Failures are swallowed.** If the database refuses the `EXPLAIN`, the span is
|
|
223
|
+
recorded without a plan; instrumentation never breaks the app.
|
|
224
|
+
|
|
225
|
+
It does issue one extra query per newly-seen slow statement, which is why you opt
|
|
226
|
+
in rather than opt out.
|
|
227
|
+
|
|
228
|
+
### Configuration validation
|
|
229
|
+
|
|
230
|
+
`BrainzLab.configure` validates the configuration when your block finishes and
|
|
231
|
+
raises `BrainzLab::ConfigurationError` listing everything that is wrong, rather
|
|
232
|
+
than letting a typo turn into missing telemetry. The common one:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
config.pulse_sample_rate = 50 # raises — sample rates are fractions
|
|
236
|
+
config.pulse_sample_rate = 0.5 # 50%
|
|
237
|
+
```
|
|
238
|
+
|
|
201
239
|
### Distributed Tracing
|
|
202
240
|
|
|
203
241
|
Pulse supports distributed tracing across services using W3C Trace Context and B3 propagation.
|
|
@@ -43,7 +43,11 @@ module BrainzLab
|
|
|
43
43
|
:pulse_flush_interval,
|
|
44
44
|
:pulse_sample_rate,
|
|
45
45
|
:pulse_sample_tier,
|
|
46
|
+
:pulse_always_keep_slow_ms,
|
|
46
47
|
:pulse_excluded_paths,
|
|
48
|
+
:pulse_explain_enabled,
|
|
49
|
+
:pulse_explain_threshold,
|
|
50
|
+
:pulse_explain_timeout,
|
|
47
51
|
:flux_enabled,
|
|
48
52
|
:flux_url,
|
|
49
53
|
:flux_api_key,
|
|
@@ -261,6 +265,10 @@ module BrainzLab
|
|
|
261
265
|
# BRAINZLAB_PULSE_TIER=client (or PULSE_SAMPLE_RATE) to keep everything.
|
|
262
266
|
@pulse_sample_rate = (v = ENV['PULSE_SAMPLE_RATE'] || ENV['BRAINZLAB_PULSE_SAMPLE_RATE']).nil? || v.to_s.strip.empty? ? nil : v.to_f
|
|
263
267
|
@pulse_sample_tier = (ENV['BRAINZLAB_PULSE_TIER'] || 'internal').to_s.strip.downcase
|
|
268
|
+
# Traces at/above this duration (ms) bypass sampling and are ALWAYS kept,
|
|
269
|
+
# so slow requests / N+1s are never sampled away (APM's whole purpose).
|
|
270
|
+
# Set to 0 to disable. Default 1000ms.
|
|
271
|
+
@pulse_always_keep_slow_ms = (ENV['BRAINZLAB_PULSE_ALWAYS_KEEP_SLOW_MS'] || 1000).to_i
|
|
264
272
|
@pulse_excluded_paths = %w[/health /ping /up /assets]
|
|
265
273
|
|
|
266
274
|
# Flux settings
|
|
@@ -396,6 +404,14 @@ module BrainzLab
|
|
|
396
404
|
# default can't flood Recall. Slow-query duration is APM data (Pulse already
|
|
397
405
|
# records it), so it defaults to a quiet level (:debug) and a higher threshold.
|
|
398
406
|
@slow_query_threshold = (ENV['BRAINZLAB_SLOW_QUERY_THRESHOLD'] || 500).to_i # ms (was hardcoded 100)
|
|
407
|
+
|
|
408
|
+
# Query plan capture. Off by default: it issues an extra (read-only)
|
|
409
|
+
# EXPLAIN against the app's own database, so an app opts in deliberately.
|
|
410
|
+
# Threshold defaults to the slow query threshold — a fast query has no
|
|
411
|
+
# plan worth looking at.
|
|
412
|
+
@pulse_explain_enabled = ENV['BRAINZLAB_PULSE_EXPLAIN_ENABLED'] == 'true'
|
|
413
|
+
@pulse_explain_threshold = (ENV['BRAINZLAB_PULSE_EXPLAIN_THRESHOLD'] || @slow_query_threshold).to_i
|
|
414
|
+
@pulse_explain_timeout = (ENV['BRAINZLAB_PULSE_EXPLAIN_TIMEOUT'] || 2).to_i
|
|
399
415
|
@very_slow_query_threshold = (ENV['BRAINZLAB_VERY_SLOW_QUERY_THRESHOLD'] || 2000).to_i # ms (was hardcoded 1000)
|
|
400
416
|
@slow_query_log_level = (ENV['BRAINZLAB_SLOW_QUERY_LOG_LEVEL'] || 'debug').to_sym # was :warn → floods
|
|
401
417
|
@very_slow_query_log_level = (ENV['BRAINZLAB_VERY_SLOW_QUERY_LOG_LEVEL'] || 'warn').to_sym
|
|
@@ -493,6 +509,67 @@ module BrainzLab
|
|
|
493
509
|
@enabled && !@secret_key.nil? && !@secret_key.empty?
|
|
494
510
|
end
|
|
495
511
|
|
|
512
|
+
# Settings validated eagerly by #validate!, grouped by the shape they must
|
|
513
|
+
# have. Kept as data so adding a setting is a one-line change here rather
|
|
514
|
+
# than another branch in the validation logic.
|
|
515
|
+
SAMPLE_RATE_SETTINGS = %i[pulse_sample_rate reflex_sample_rate].freeze
|
|
516
|
+
|
|
517
|
+
POSITIVE_INTEGER_SETTINGS = %i[
|
|
518
|
+
recall_buffer_size recall_flush_interval
|
|
519
|
+
pulse_buffer_size pulse_flush_interval
|
|
520
|
+
flux_buffer_size flux_flush_interval
|
|
521
|
+
pulse_explain_timeout
|
|
522
|
+
].freeze
|
|
523
|
+
|
|
524
|
+
NON_NEGATIVE_NUMBER_SETTINGS = %i[
|
|
525
|
+
pulse_always_keep_slow_ms
|
|
526
|
+
slow_query_threshold very_slow_query_threshold
|
|
527
|
+
slow_request_threshold very_slow_request_threshold
|
|
528
|
+
n_plus_one_threshold pulse_explain_threshold
|
|
529
|
+
].freeze
|
|
530
|
+
|
|
531
|
+
# Validate the configuration and raise on anything that would misbehave
|
|
532
|
+
# silently at runtime. Called by BrainzLab.configure once the user's block
|
|
533
|
+
# has been applied, so a typo surfaces at boot rather than as missing
|
|
534
|
+
# telemetry days later.
|
|
535
|
+
#
|
|
536
|
+
# @return [Configuration] self, when every setting is valid
|
|
537
|
+
# @raise [ConfigurationError] listing every invalid setting at once
|
|
538
|
+
def validate!
|
|
539
|
+
problems = []
|
|
540
|
+
|
|
541
|
+
SAMPLE_RATE_SETTINGS.each do |setting|
|
|
542
|
+
value = public_send(setting)
|
|
543
|
+
next if value.nil? # nil means "fall back to the tier default"
|
|
544
|
+
|
|
545
|
+
unless value.is_a?(Numeric) && value.between?(0, 1)
|
|
546
|
+
problems << "#{setting} must be a number between 0 and 1 (got #{value.inspect})"
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
POSITIVE_INTEGER_SETTINGS.each do |setting|
|
|
551
|
+
value = public_send(setting)
|
|
552
|
+
problems << "#{setting} must be a positive number (got #{value.inspect})" unless positive_number?(value)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
NON_NEGATIVE_NUMBER_SETTINGS.each do |setting|
|
|
556
|
+
value = public_send(setting)
|
|
557
|
+
unless value.is_a?(Numeric) && !value.negative?
|
|
558
|
+
problems << "#{setting} must be a non-negative number (got #{value.inspect})"
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
return self if problems.empty?
|
|
563
|
+
|
|
564
|
+
raise ConfigurationError.new(
|
|
565
|
+
"Invalid BrainzLab configuration: #{problems.join('; ')}",
|
|
566
|
+
hint: 'Fix these values in your BrainzLab.configure block or the matching environment variables. ' \
|
|
567
|
+
'Sample rates are fractions (0.5 = 50%), not percentages.',
|
|
568
|
+
code: 'invalid_configuration',
|
|
569
|
+
context: { problems: problems }
|
|
570
|
+
)
|
|
571
|
+
end
|
|
572
|
+
|
|
496
573
|
def enabled?
|
|
497
574
|
@enabled == true
|
|
498
575
|
end
|
|
@@ -709,6 +786,10 @@ module BrainzLab
|
|
|
709
786
|
|
|
710
787
|
private
|
|
711
788
|
|
|
789
|
+
def positive_number?(value)
|
|
790
|
+
value.is_a?(Numeric) && value.positive?
|
|
791
|
+
end
|
|
792
|
+
|
|
712
793
|
def detect_environment
|
|
713
794
|
return ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
714
795
|
return ENV['RACK_ENV'] if ENV['RACK_ENV']
|
|
@@ -31,6 +31,36 @@ module BrainzLab
|
|
|
31
31
|
BrainzLab.debug_log('ActiveRecord instrumentation installed (sql, instantiation, transactions, strict_loading)')
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Capture the execution plan for a query slow enough to be worth one.
|
|
35
|
+
#
|
|
36
|
+
# Runs here, in the app, because Pulse never has access to the app's
|
|
37
|
+
# database — a plan can only be produced on this side of the wire. The
|
|
38
|
+
# plan travels with the span so the backend can point at a sequential
|
|
39
|
+
# scan instead of guessing from normalized SQL.
|
|
40
|
+
#
|
|
41
|
+
# @param payload [Hash] the sql.active_record payload
|
|
42
|
+
# @param duration [Numeric] query duration in ms
|
|
43
|
+
# @return [String, nil] the plan, or nil when not captured
|
|
44
|
+
def explain_plan_for(payload, duration)
|
|
45
|
+
config = BrainzLab.configuration
|
|
46
|
+
return nil unless config.pulse_explain_enabled
|
|
47
|
+
return nil if duration < config.pulse_explain_threshold
|
|
48
|
+
return nil if payload[:cached] # never reached the database
|
|
49
|
+
|
|
50
|
+
connection = payload[:connection]
|
|
51
|
+
return nil if connection.nil?
|
|
52
|
+
|
|
53
|
+
result = BrainzLab::Pulse::ExplainCapture.capture(
|
|
54
|
+
sql: payload[:sql],
|
|
55
|
+
connection: connection,
|
|
56
|
+
timeout: config.pulse_explain_timeout
|
|
57
|
+
)
|
|
58
|
+
result && result[:plan]
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
BrainzLab.debug_log("Explain plan capture failed: #{e.message}")
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
34
64
|
def installed?
|
|
35
65
|
@installed == true
|
|
36
66
|
end
|
|
@@ -134,7 +164,8 @@ module BrainzLab
|
|
|
134
164
|
'db.cached' => payload[:cached] || false,
|
|
135
165
|
'db.async' => payload[:async] || false,
|
|
136
166
|
'db.row_count' => payload[:row_count],
|
|
137
|
-
'db.affected_rows' => payload[:affected_rows]
|
|
167
|
+
'db.affected_rows' => payload[:affected_rows],
|
|
168
|
+
'db.explain_plan' => explain_plan_for(payload, duration)
|
|
138
169
|
}.compact
|
|
139
170
|
}
|
|
140
171
|
|
|
@@ -25,6 +25,26 @@ module BrainzLab
|
|
|
25
25
|
@installed = false
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Resolve the route pattern to name a trace by.
|
|
29
|
+
#
|
|
30
|
+
# Grape exposes the matched pattern (/users/:id) via routing_args, and
|
|
31
|
+
# that is always preferred. When it does not, naming the trace after the
|
|
32
|
+
# raw path turns every record into its own endpoint — /users/1,
|
|
33
|
+
# /users/2, ... — so the path is normalized first, using the routing
|
|
34
|
+
# args as param hints when they are present.
|
|
35
|
+
#
|
|
36
|
+
# @param env [Hash] the Rack env
|
|
37
|
+
# @param path [String, nil] the concrete request path
|
|
38
|
+
# @return [String, nil] a pattern safe to aggregate on
|
|
39
|
+
def route_pattern_for(env, path)
|
|
40
|
+
routing_args = safe_routing_args(env)
|
|
41
|
+
pattern = routing_args && safe_dig(routing_args, :route_info)&.pattern&.path
|
|
42
|
+
return pattern if pattern
|
|
43
|
+
|
|
44
|
+
params = routing_args.is_a?(Hash) ? routing_args.reject { |k, _| k.to_sym == :route_info } : nil
|
|
45
|
+
BrainzLab::Utilities::RoutePathNormalizer.normalize(path, params)
|
|
46
|
+
end
|
|
47
|
+
|
|
28
48
|
private
|
|
29
49
|
|
|
30
50
|
def install_notifications!
|
|
@@ -176,6 +196,18 @@ module BrainzLab
|
|
|
176
196
|
}
|
|
177
197
|
end
|
|
178
198
|
|
|
199
|
+
def safe_routing_args(env)
|
|
200
|
+
env.is_a?(Hash) ? env['grape.routing_args'] : nil
|
|
201
|
+
rescue StandardError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def safe_dig(routing_args, key)
|
|
206
|
+
routing_args.respond_to?(:dig) ? routing_args.dig(key) : nil
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
179
211
|
def extract_route_pattern(endpoint)
|
|
180
212
|
return '/' unless endpoint
|
|
181
213
|
|
|
@@ -242,8 +274,9 @@ module BrainzLab
|
|
|
242
274
|
method = request.request_method
|
|
243
275
|
path = request.path
|
|
244
276
|
|
|
245
|
-
# Get route pattern from Grape if available
|
|
246
|
-
|
|
277
|
+
# Get route pattern from Grape if available, else normalize the raw
|
|
278
|
+
# path so /users/1 and /users/2 aggregate as one endpoint.
|
|
279
|
+
route_pattern = route_pattern_for(env, path)
|
|
247
280
|
|
|
248
281
|
spans = Thread.current[:brainzlab_pulse_spans] || []
|
|
249
282
|
|
|
@@ -72,11 +72,24 @@ module BrainzLab
|
|
|
72
72
|
# effective per-tier sample rate (internal=0.1, standard=0.5, client=1.0).
|
|
73
73
|
def keep_trace?(payload)
|
|
74
74
|
return true if payload[:error] == true || payload['error'] == true
|
|
75
|
+
# Always keep SLOW traces, regardless of sampling. Surfacing slow
|
|
76
|
+
# requests (N+1s, slow endpoints) is the whole point of APM — sampling
|
|
77
|
+
# them out blinds debugging, which is exactly how an inquiries-page N+1
|
|
78
|
+
# stayed invisible on a 10%-sampled app.
|
|
79
|
+
return true if slow_trace?(payload)
|
|
75
80
|
|
|
76
81
|
rate = @config.effective_pulse_sample_rate
|
|
77
82
|
rate >= 1.0 || rand < rate
|
|
78
83
|
end
|
|
79
84
|
|
|
85
|
+
def slow_trace?(payload)
|
|
86
|
+
threshold = @config.pulse_always_keep_slow_ms.to_i
|
|
87
|
+
return false unless threshold.positive?
|
|
88
|
+
|
|
89
|
+
duration = payload[:duration_ms] || payload['duration_ms']
|
|
90
|
+
!duration.nil? && duration.to_f >= threshold
|
|
91
|
+
end
|
|
92
|
+
|
|
80
93
|
def buffer_trace(payload)
|
|
81
94
|
should_flush = false
|
|
82
95
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module BrainzLab
|
|
6
|
+
module Pulse
|
|
7
|
+
# Captures a query execution plan from the application's own database
|
|
8
|
+
# connection and attaches it to the span.
|
|
9
|
+
#
|
|
10
|
+
# This runs client-side on purpose: Pulse is an external service and never
|
|
11
|
+
# has access to a customer's database, so an EXPLAIN can only be produced
|
|
12
|
+
# here, where the connection already exists. It is what turns "this query
|
|
13
|
+
# took 340ms" into "this query sequentially scans orders".
|
|
14
|
+
#
|
|
15
|
+
# Safety rules, in order of importance:
|
|
16
|
+
# * read-only statements only — never explain a write
|
|
17
|
+
# * plain EXPLAIN, never EXPLAIN ANALYZE (which executes the statement)
|
|
18
|
+
# * a wall-clock timeout, so a slow planner cannot stall a request
|
|
19
|
+
# * one EXPLAIN per distinct statement, not per occurrence
|
|
20
|
+
# * every failure is swallowed — instrumentation must never break the app
|
|
21
|
+
module ExplainCapture
|
|
22
|
+
DEFAULT_TIMEOUT = 2 # seconds
|
|
23
|
+
# Bounds the memory held by the "already explained" set on an app with
|
|
24
|
+
# heavily varied SQL.
|
|
25
|
+
MAX_CACHED_STATEMENTS = 500
|
|
26
|
+
|
|
27
|
+
# Guards against the EXPLAIN itself emitting a sql.active_record event
|
|
28
|
+
# that feeds straight back into capture.
|
|
29
|
+
IN_FLIGHT_KEY = :brainzlab_explain_in_flight
|
|
30
|
+
|
|
31
|
+
READ_ONLY_START = /\A\s*(?:SELECT|WITH)\b/i
|
|
32
|
+
# Any statement separator followed by more content: a plan-only statement
|
|
33
|
+
# must be a single query.
|
|
34
|
+
MULTI_STATEMENT = /;\s*\S/
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# @param sql [String] the statement to explain
|
|
38
|
+
# @param connection [Object] an ActiveRecord-like connection
|
|
39
|
+
# @param timeout [Integer] seconds to wait for the plan
|
|
40
|
+
# @return [Hash, nil] { plan:, adapter: } or nil when not captured
|
|
41
|
+
def capture(sql:, connection:, timeout: DEFAULT_TIMEOUT)
|
|
42
|
+
return nil if Thread.current[IN_FLIGHT_KEY]
|
|
43
|
+
return nil unless explainable?(sql)
|
|
44
|
+
return nil unless connection.respond_to?(:exec_query)
|
|
45
|
+
|
|
46
|
+
fingerprint = fingerprint_for(sql)
|
|
47
|
+
return nil if already_explained?(fingerprint)
|
|
48
|
+
|
|
49
|
+
Thread.current[IN_FLIGHT_KEY] = true
|
|
50
|
+
begin
|
|
51
|
+
remember(fingerprint)
|
|
52
|
+
plan = Timeout.timeout(timeout) { run_explain(sql, connection) }
|
|
53
|
+
return nil if plan.nil? || plan.empty?
|
|
54
|
+
|
|
55
|
+
{ plan: plan, adapter: adapter_for(connection) }
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
BrainzLab.debug_log("Explain capture failed: #{e.message}")
|
|
58
|
+
nil
|
|
59
|
+
ensure
|
|
60
|
+
Thread.current[IN_FLIGHT_KEY] = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Forget which statements have been explained. Used by the specs and
|
|
65
|
+
# available to long-lived processes that want to re-sample plans.
|
|
66
|
+
def reset!
|
|
67
|
+
mutex.synchronize { explained.clear }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def explainable?(sql)
|
|
73
|
+
return false if sql.nil? || sql.strip.empty?
|
|
74
|
+
return false unless sql.match?(READ_ONLY_START)
|
|
75
|
+
return false if sql.match?(MULTI_STATEMENT)
|
|
76
|
+
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_explain(sql, connection)
|
|
81
|
+
result = connection.exec_query("EXPLAIN #{sql}")
|
|
82
|
+
rows = result.respond_to?(:rows) ? result.rows : result
|
|
83
|
+
return nil if rows.nil?
|
|
84
|
+
|
|
85
|
+
Array(rows).map { |row| Array(row).join(' | ') }.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def adapter_for(connection)
|
|
89
|
+
return nil unless connection.respond_to?(:adapter_name)
|
|
90
|
+
|
|
91
|
+
connection.adapter_name.to_s.downcase
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Collapse whitespace so that the same statement formatted differently
|
|
97
|
+
# is not explained twice.
|
|
98
|
+
def fingerprint_for(sql)
|
|
99
|
+
sql.strip.gsub(/\s+/, ' ')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def already_explained?(fingerprint)
|
|
103
|
+
mutex.synchronize { explained.include?(fingerprint) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def remember(fingerprint)
|
|
107
|
+
mutex.synchronize do
|
|
108
|
+
explained.clear if explained.size >= MAX_CACHED_STATEMENTS
|
|
109
|
+
explained << fingerprint
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def explained
|
|
114
|
+
@explained ||= Set.new
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def mutex
|
|
118
|
+
@mutex ||= Mutex.new
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -44,8 +44,8 @@ module BrainzLab
|
|
|
44
44
|
duration_ms = ((ended_at - trace[:started_at]) * 1000).round(2)
|
|
45
45
|
|
|
46
46
|
payload = trace.merge(
|
|
47
|
-
ended_at: ended_at
|
|
48
|
-
started_at: trace[:started_at]
|
|
47
|
+
ended_at: iso_ts(ended_at),
|
|
48
|
+
started_at: iso_ts(trace[:started_at]),
|
|
49
49
|
duration_ms: duration_ms,
|
|
50
50
|
error: error,
|
|
51
51
|
error_class: error_class,
|
|
@@ -99,8 +99,8 @@ module BrainzLab
|
|
|
99
99
|
parent_span_id: span[:parent_span_id],
|
|
100
100
|
name: span[:name],
|
|
101
101
|
kind: span[:kind],
|
|
102
|
-
started_at: span[:started_at]
|
|
103
|
-
ended_at: span[:ended_at]
|
|
102
|
+
started_at: iso_ts(span[:started_at]),
|
|
103
|
+
ended_at: iso_ts(span[:ended_at]),
|
|
104
104
|
duration_ms: span[:duration_ms],
|
|
105
105
|
error: span[:error],
|
|
106
106
|
error_class: span[:error_class],
|
|
@@ -108,6 +108,21 @@ module BrainzLab
|
|
|
108
108
|
data: span[:data]
|
|
109
109
|
}.compact
|
|
110
110
|
end
|
|
111
|
+
|
|
112
|
+
# Rails 8 changed ActiveSupport::Notifications::Event#time/#end to return a
|
|
113
|
+
# Float (monotonic), so spans carry Float timestamps. Calling #utc on them
|
|
114
|
+
# raised NoMethodError, crashing trace serialization (error/job traces) on
|
|
115
|
+
# Rails 8 apps. Coerce Time/Float/Integer/String safely.
|
|
116
|
+
def iso_ts(ts)
|
|
117
|
+
return nil if ts.nil?
|
|
118
|
+
|
|
119
|
+
case ts
|
|
120
|
+
when Time, DateTime then ts.utc.iso8601(3)
|
|
121
|
+
when Numeric then Time.at(ts).utc.iso8601(3)
|
|
122
|
+
when String then ts
|
|
123
|
+
else ts.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
111
126
|
end
|
|
112
127
|
end
|
|
113
128
|
end
|
data/lib/brainzlab/pulse.rb
CHANGED
|
@@ -11,16 +11,30 @@ module BrainzLab
|
|
|
11
11
|
@config = config
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# ⚠️ Signal NO expone un endpoint de alta de alertas ad-hoc.
|
|
15
|
+
# `resources :alerts` es `only: [:index, :show]`, así que este POST
|
|
16
|
+
# devolvía 404 (verificado contra producción el 2026-10-16). Se enruta a
|
|
17
|
+
# `alerts#trigger`, que es lo más cercano que existe — pero exige que YA
|
|
18
|
+
# exista una AlertRule con ese `name` en el proyecto, o responde 404.
|
|
19
|
+
# Ver company/tasks/signal-alertas-caidas en el OS: reconciliar la API
|
|
20
|
+
# del SDK con el modelo de Signal es una decisión de producto pendiente.
|
|
14
21
|
def send_alert(alert)
|
|
15
|
-
post('/api/v1/alerts', alert)
|
|
22
|
+
post('/api/v1/alerts/trigger', alert)
|
|
16
23
|
end
|
|
17
24
|
|
|
25
|
+
# ⚠️ Signal no tiene ninguna ruta `notifications` (grep en su
|
|
26
|
+
# config/routes.rb: 0 coincidencias). Este POST siempre fue un 404.
|
|
27
|
+
# No se reenruta a ciegas porque no hay equivalente: entregar a un canal
|
|
28
|
+
# concreto es otra cosa que disparar una alerta.
|
|
18
29
|
def send_notification(notification)
|
|
19
30
|
post('/api/v1/notifications', notification)
|
|
20
31
|
end
|
|
21
32
|
|
|
33
|
+
# Antes posteaba a '/api/v1/rules/trigger', que no existe —
|
|
34
|
+
# `resources :rules` solo expone los miembros mute/unmute/test.
|
|
35
|
+
# La ruta real es `post "alerts/trigger" => "alerts#trigger"`.
|
|
22
36
|
def trigger_rule(payload)
|
|
23
|
-
post('/api/v1/
|
|
37
|
+
post('/api/v1/alerts/trigger', payload)
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
private
|
data/lib/brainzlab/signal.rb
CHANGED
|
@@ -72,9 +72,17 @@ module BrainzLab
|
|
|
72
72
|
ensure_provisioned!
|
|
73
73
|
return unless BrainzLab.configuration.signal_valid?
|
|
74
74
|
|
|
75
|
+
# `alerts#trigger` en Signal lee params[:name], params[:value] y
|
|
76
|
+
# params[:labels] — NO params[:rule] ni params[:context]. Mandar `rule:`
|
|
77
|
+
# hacía que `find_by!(name: nil)` levantara RecordNotFound → 404, así
|
|
78
|
+
# que ni con la ruta correcta habría funcionado. Se mantienen las claves
|
|
79
|
+
# viejas por compatibilidad con cualquier consumidor que las lea.
|
|
75
80
|
payload = {
|
|
76
81
|
type: 'trigger',
|
|
82
|
+
name: rule_name,
|
|
77
83
|
rule: rule_name,
|
|
84
|
+
value: context[:value] || context['value'],
|
|
85
|
+
labels: context,
|
|
78
86
|
context: context,
|
|
79
87
|
timestamp: Time.now.utc.iso8601(3),
|
|
80
88
|
environment: BrainzLab.configuration.environment,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Utilities
|
|
5
|
+
# Collapses concrete request paths into route patterns, so that
|
|
6
|
+
# /users/1, /users/2 ... /users/99999 aggregate as a single /users/:id
|
|
7
|
+
# endpoint instead of one endpoint per record.
|
|
8
|
+
#
|
|
9
|
+
# Rails instrumentation names traces by controller#action and does not need
|
|
10
|
+
# this. Grape and plain Rack do: when the router does not expose a route
|
|
11
|
+
# pattern the SDK falls back to the raw PATH_INFO, which is where the
|
|
12
|
+
# cardinality blowup comes from.
|
|
13
|
+
#
|
|
14
|
+
# Two modes:
|
|
15
|
+
# * with path params — substitute the exact values the router resolved
|
|
16
|
+
# * without them — fall back to recognising id-shaped segments
|
|
17
|
+
module RoutePathNormalizer
|
|
18
|
+
# Params that describe the dispatch rather than the path itself.
|
|
19
|
+
SKIP_PARAMS = %i[controller action format].freeze
|
|
20
|
+
|
|
21
|
+
UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
22
|
+
NUMERIC_PATTERN = /\A\d+\z/
|
|
23
|
+
# 16+ hex chars: git SHAs, hex tokens. Shorter hex strings are too easily
|
|
24
|
+
# a real word segment ("beef", "cafe") to risk substituting.
|
|
25
|
+
LONG_HEX_PATTERN = /\A\h{16,}\z/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param path [String, nil] the concrete request path
|
|
30
|
+
# @param path_params [Hash, nil] params the router resolved, when available
|
|
31
|
+
# @return [String, nil] the path with variable segments replaced
|
|
32
|
+
def normalize(path, path_params = nil)
|
|
33
|
+
return path if path.nil? || path.empty?
|
|
34
|
+
|
|
35
|
+
params = normalize_params(path_params)
|
|
36
|
+
|
|
37
|
+
# -1 keeps trailing empty segments, so a trailing slash survives.
|
|
38
|
+
segments = path.split('/', -1)
|
|
39
|
+
normalized = segments.map do |segment|
|
|
40
|
+
next segment if segment.empty?
|
|
41
|
+
|
|
42
|
+
params.empty? ? substitute_by_shape(segment) : substitute_by_param(segment, params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
normalized.join('/')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_params(path_params)
|
|
49
|
+
return {} if path_params.nil? || path_params.empty?
|
|
50
|
+
|
|
51
|
+
# Map value => [param names]. A value claimed by more than one param is
|
|
52
|
+
# ambiguous, so it is left alone rather than guessed at.
|
|
53
|
+
path_params.each_with_object({}) do |(key, value), map|
|
|
54
|
+
next if SKIP_PARAMS.include?(key.to_sym)
|
|
55
|
+
|
|
56
|
+
(map[value.to_s] ||= []) << key
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def substitute_by_param(segment, params)
|
|
61
|
+
keys = params[segment]
|
|
62
|
+
return ":#{keys.first}" if keys && keys.length == 1
|
|
63
|
+
|
|
64
|
+
# "42.json" when the id param is "42"
|
|
65
|
+
if segment.include?('.')
|
|
66
|
+
base, extension = segment.split('.', 2)
|
|
67
|
+
keys = params[base]
|
|
68
|
+
return ":#{keys.first}.#{extension}" if keys && keys.length == 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
segment
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def substitute_by_shape(segment)
|
|
75
|
+
base, extension = segment.include?('.') ? segment.split('.', 2) : [segment, nil]
|
|
76
|
+
placeholder = placeholder_for(base)
|
|
77
|
+
return segment if placeholder.nil?
|
|
78
|
+
|
|
79
|
+
extension ? "#{placeholder}.#{extension}" : placeholder
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def placeholder_for(segment)
|
|
83
|
+
return ':uuid' if segment.match?(UUID_PATTERN)
|
|
84
|
+
return ':id' if segment.match?(NUMERIC_PATTERN)
|
|
85
|
+
return ':id' if segment.match?(LONG_HEX_PATTERN)
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/brainzlab/utilities.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative 'utilities/rate_limiter'
|
|
|
4
4
|
require_relative 'utilities/circuit_breaker'
|
|
5
5
|
require_relative 'utilities/health_check'
|
|
6
6
|
require_relative 'utilities/log_formatter'
|
|
7
|
+
require_relative 'utilities/route_path_normalizer'
|
|
7
8
|
|
|
8
9
|
module BrainzLab
|
|
9
10
|
module Utilities
|
data/lib/brainzlab/version.rb
CHANGED
data/lib/brainzlab.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainzlab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.34
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BrainzLab
|
|
@@ -177,6 +177,7 @@ files:
|
|
|
177
177
|
- lib/brainzlab/nerve/provisioner.rb
|
|
178
178
|
- lib/brainzlab/pulse.rb
|
|
179
179
|
- lib/brainzlab/pulse/client.rb
|
|
180
|
+
- lib/brainzlab/pulse/explain_capture.rb
|
|
180
181
|
- lib/brainzlab/pulse/instrumentation.rb
|
|
181
182
|
- lib/brainzlab/pulse/propagation.rb
|
|
182
183
|
- lib/brainzlab/pulse/provisioner.rb
|
|
@@ -211,6 +212,7 @@ files:
|
|
|
211
212
|
- lib/brainzlab/utilities/health_check.rb
|
|
212
213
|
- lib/brainzlab/utilities/log_formatter.rb
|
|
213
214
|
- lib/brainzlab/utilities/rate_limiter.rb
|
|
215
|
+
- lib/brainzlab/utilities/route_path_normalizer.rb
|
|
214
216
|
- lib/brainzlab/vault.rb
|
|
215
217
|
- lib/brainzlab/vault/cache.rb
|
|
216
218
|
- lib/brainzlab/vault/client.rb
|