brainzlab 0.1.33 → 0.1.35
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/beacon/client.rb +4 -6
- data/lib/brainzlab/configuration.rb +363 -258
- data/lib/brainzlab/cortex/client.rb +4 -6
- data/lib/brainzlab/debug.rb +6 -6
- data/lib/brainzlab/dendrite/client.rb +4 -6
- data/lib/brainzlab/development/logger.rb +2 -2
- data/lib/brainzlab/development/store.rb +2 -4
- data/lib/brainzlab/devtools/middleware/error_page.rb +1 -1
- data/lib/brainzlab/errors.rb +5 -12
- data/lib/brainzlab/instrumentation/action_cable.rb +4 -4
- data/lib/brainzlab/instrumentation/action_controller.rb +12 -12
- data/lib/brainzlab/instrumentation/action_dispatch.rb +1 -1
- data/lib/brainzlab/instrumentation/action_mailbox.rb +7 -13
- data/lib/brainzlab/instrumentation/action_view.rb +29 -33
- data/lib/brainzlab/instrumentation/active_job.rb +6 -10
- data/lib/brainzlab/instrumentation/active_record.rb +53 -25
- data/lib/brainzlab/instrumentation/active_storage.rb +4 -4
- data/lib/brainzlab/instrumentation/active_support_cache.rb +6 -8
- data/lib/brainzlab/instrumentation/aws.rb +10 -7
- data/lib/brainzlab/instrumentation/grape.rb +37 -2
- data/lib/brainzlab/instrumentation/rails_deprecation.rb +1 -1
- data/lib/brainzlab/instrumentation/railties.rb +2 -2
- data/lib/brainzlab/nerve/client.rb +4 -6
- data/lib/brainzlab/pulse/client.rb +1 -1
- data/lib/brainzlab/pulse/explain_capture.rb +123 -0
- data/lib/brainzlab/pulse.rb +6 -1
- data/lib/brainzlab/rails/railtie.rb +1 -3
- data/lib/brainzlab/reflex/client.rb +1 -1
- data/lib/brainzlab/sentinel/client.rb +4 -6
- data/lib/brainzlab/signal/client.rb +16 -2
- data/lib/brainzlab/signal.rb +8 -0
- data/lib/brainzlab/synapse/client.rb +4 -6
- data/lib/brainzlab/testing/event_store.rb +3 -3
- data/lib/brainzlab/testing/matchers.rb +4 -4
- data/lib/brainzlab/testing.rb +5 -3
- data/lib/brainzlab/utilities/route_path_normalizer.rb +91 -0
- data/lib/brainzlab/utilities.rb +1 -0
- data/lib/brainzlab/vault/client.rb +4 -6
- data/lib/brainzlab/version.rb +1 -1
- data/lib/brainzlab/vision/client.rb +3 -3
- data/lib/brainzlab.rb +1 -0
- data/lib/fluyenta-ruby.rb +1 -1
- metadata +3 -1
|
@@ -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
|
|
@@ -50,7 +80,6 @@ module BrainzLab
|
|
|
50
80
|
end
|
|
51
81
|
|
|
52
82
|
def handle_sql_event(event)
|
|
53
|
-
payload = event.payload
|
|
54
83
|
duration = event.duration.round(2)
|
|
55
84
|
|
|
56
85
|
# Record breadcrumb for Reflex (enhanced)
|
|
@@ -134,7 +163,8 @@ module BrainzLab
|
|
|
134
163
|
'db.cached' => payload[:cached] || false,
|
|
135
164
|
'db.async' => payload[:async] || false,
|
|
136
165
|
'db.row_count' => payload[:row_count],
|
|
137
|
-
'db.affected_rows' => payload[:affected_rows]
|
|
166
|
+
'db.affected_rows' => payload[:affected_rows],
|
|
167
|
+
'db.explain_plan' => explain_plan_for(payload, duration)
|
|
138
168
|
}.compact
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -187,14 +217,12 @@ module BrainzLab
|
|
|
187
217
|
tracker[:count] += 1
|
|
188
218
|
|
|
189
219
|
# Store sample queries (limited)
|
|
190
|
-
if tracker[:queries].size < 3
|
|
191
|
-
tracker[:queries] << truncate_sql(sql, 200)
|
|
192
|
-
end
|
|
220
|
+
tracker[:queries] << truncate_sql(sql, 200) if tracker[:queries].size < 3
|
|
193
221
|
|
|
194
222
|
# Detect N+1 pattern
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
223
|
+
return unless tracker[:count] == BrainzLab.configuration.n_plus_one_threshold
|
|
224
|
+
|
|
225
|
+
report_n_plus_one(table, tracker)
|
|
198
226
|
end
|
|
199
227
|
|
|
200
228
|
def report_n_plus_one(table, tracker)
|
|
@@ -209,14 +237,14 @@ module BrainzLab
|
|
|
209
237
|
}
|
|
210
238
|
)
|
|
211
239
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
return unless BrainzLab.configuration.recall_effectively_enabled?
|
|
241
|
+
|
|
242
|
+
BrainzLab::Recall.warn(
|
|
243
|
+
'Potential N+1 query detected',
|
|
244
|
+
table: table,
|
|
245
|
+
query_count: tracker[:count],
|
|
246
|
+
sample_queries: tracker[:queries]
|
|
247
|
+
)
|
|
220
248
|
end
|
|
221
249
|
|
|
222
250
|
def clear_n_plus_one_tracker!
|
|
@@ -301,8 +329,8 @@ module BrainzLab
|
|
|
301
329
|
transaction = event.payload[:transaction]
|
|
302
330
|
return unless transaction
|
|
303
331
|
|
|
304
|
-
Thread.current[:brainzlab_transaction_starts] ||= {}
|
|
305
|
-
Thread.current[:brainzlab_transaction_starts][transaction
|
|
332
|
+
Thread.current[:brainzlab_transaction_starts] ||= {}.compare_by_identity
|
|
333
|
+
Thread.current[:brainzlab_transaction_starts][transaction] = event.time
|
|
306
334
|
rescue StandardError => e
|
|
307
335
|
BrainzLab.debug_log("ActiveRecord transaction start instrumentation failed: #{e.message}")
|
|
308
336
|
end
|
|
@@ -313,8 +341,8 @@ module BrainzLab
|
|
|
313
341
|
outcome = payload[:outcome] # :commit, :rollback, :restart, :incomplete
|
|
314
342
|
|
|
315
343
|
# Calculate duration from stored start time
|
|
316
|
-
starts = Thread.current[:brainzlab_transaction_starts] || {}
|
|
317
|
-
start_time = starts.delete(transaction
|
|
344
|
+
starts = Thread.current[:brainzlab_transaction_starts] || {}.compare_by_identity
|
|
345
|
+
start_time = starts.delete(transaction) || event.time
|
|
318
346
|
duration = ((event.end - start_time) * 1000).round(2)
|
|
319
347
|
|
|
320
348
|
connection_name = extract_connection_name(payload[:connection])
|
|
@@ -409,7 +437,7 @@ module BrainzLab
|
|
|
409
437
|
# Log to Recall
|
|
410
438
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
411
439
|
BrainzLab::Recall.warn(
|
|
412
|
-
|
|
440
|
+
'Strict loading violation detected',
|
|
413
441
|
owner_class: owner_class,
|
|
414
442
|
association: association_name.to_s,
|
|
415
443
|
message: "Attempted to lazily load #{association_name} on #{owner_class} with strict_loading enabled"
|
|
@@ -454,7 +482,7 @@ module BrainzLab
|
|
|
454
482
|
# Log to Recall
|
|
455
483
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
456
484
|
BrainzLab::Recall.warn(
|
|
457
|
-
|
|
485
|
+
'Deprecated association accessed',
|
|
458
486
|
owner_class: owner_class,
|
|
459
487
|
association: association_name.to_s,
|
|
460
488
|
message: message
|
|
@@ -490,9 +518,9 @@ module BrainzLab
|
|
|
490
518
|
# Handles: SELECT ... FROM table_name, SELECT ... FROM "table_name", etc.
|
|
491
519
|
return nil unless sql =~ /\ASELECT/i
|
|
492
520
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
521
|
+
return unless sql =~ /FROM\s+["'`]?(\w+)["'`]?/i
|
|
522
|
+
|
|
523
|
+
::Regexp.last_match(1)
|
|
496
524
|
end
|
|
497
525
|
|
|
498
526
|
def skip_query?(payload)
|
|
@@ -145,7 +145,7 @@ module BrainzLab
|
|
|
145
145
|
|
|
146
146
|
key = payload[:key]
|
|
147
147
|
service = payload[:service]
|
|
148
|
-
|
|
148
|
+
payload[:checksum]
|
|
149
149
|
|
|
150
150
|
# Record breadcrumb
|
|
151
151
|
if BrainzLab.configuration.reflex_effectively_enabled?
|
|
@@ -294,7 +294,7 @@ module BrainzLab
|
|
|
294
294
|
# Log to Recall - bulk deletes are significant
|
|
295
295
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
296
296
|
BrainzLab::Recall.info(
|
|
297
|
-
|
|
297
|
+
'Storage bulk delete by prefix',
|
|
298
298
|
prefix: truncate_key(prefix),
|
|
299
299
|
service: service,
|
|
300
300
|
duration_ms: duration
|
|
@@ -357,7 +357,7 @@ module BrainzLab
|
|
|
357
357
|
# Log slow URL generations
|
|
358
358
|
if duration >= 50 && BrainzLab.configuration.recall_effectively_enabled?
|
|
359
359
|
BrainzLab::Recall.warn(
|
|
360
|
-
|
|
360
|
+
'Slow storage URL generation',
|
|
361
361
|
key: truncate_key(key),
|
|
362
362
|
service: service,
|
|
363
363
|
duration_ms: duration
|
|
@@ -411,7 +411,7 @@ module BrainzLab
|
|
|
411
411
|
key = payload[:key]
|
|
412
412
|
service = payload[:service]
|
|
413
413
|
content_type = payload[:content_type]
|
|
414
|
-
|
|
414
|
+
payload[:disposition]
|
|
415
415
|
|
|
416
416
|
# Record breadcrumb
|
|
417
417
|
if BrainzLab.configuration.reflex_effectively_enabled?
|
|
@@ -109,7 +109,7 @@ module BrainzLab
|
|
|
109
109
|
hit_count: hit_count,
|
|
110
110
|
miss_count: key_count - hit_count,
|
|
111
111
|
duration_ms: duration,
|
|
112
|
-
hit_rate: key_count
|
|
112
|
+
hit_rate: key_count.positive? ? (hit_count.to_f / key_count * 100).round(1) : 0
|
|
113
113
|
}
|
|
114
114
|
)
|
|
115
115
|
end
|
|
@@ -476,7 +476,7 @@ module BrainzLab
|
|
|
476
476
|
# Log pattern deletes
|
|
477
477
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
478
478
|
BrainzLab::Recall.info(
|
|
479
|
-
|
|
479
|
+
'Cache pattern delete',
|
|
480
480
|
pattern: truncate_key(key),
|
|
481
481
|
duration_ms: duration
|
|
482
482
|
)
|
|
@@ -502,7 +502,6 @@ module BrainzLab
|
|
|
502
502
|
duration = event.duration.round(2)
|
|
503
503
|
|
|
504
504
|
size = payload[:size]
|
|
505
|
-
key = payload[:key]
|
|
506
505
|
|
|
507
506
|
# Record breadcrumb
|
|
508
507
|
if BrainzLab.configuration.reflex_effectively_enabled?
|
|
@@ -539,7 +538,6 @@ module BrainzLab
|
|
|
539
538
|
payload = event.payload
|
|
540
539
|
duration = event.duration.round(2)
|
|
541
540
|
|
|
542
|
-
key = payload[:key]
|
|
543
541
|
from = payload[:from]
|
|
544
542
|
to = payload[:to]
|
|
545
543
|
|
|
@@ -563,7 +561,7 @@ module BrainzLab
|
|
|
563
561
|
# Log prune operations
|
|
564
562
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
565
563
|
BrainzLab::Recall.info(
|
|
566
|
-
|
|
564
|
+
'Cache pruned',
|
|
567
565
|
from: from,
|
|
568
566
|
to: to,
|
|
569
567
|
duration_ms: duration
|
|
@@ -593,8 +591,8 @@ module BrainzLab
|
|
|
593
591
|
|
|
594
592
|
serializer = payload[:serializer]
|
|
595
593
|
fallback = payload[:fallback]
|
|
596
|
-
|
|
597
|
-
|
|
594
|
+
payload[:serialized]
|
|
595
|
+
payload[:deserialized]
|
|
598
596
|
|
|
599
597
|
# Record breadcrumb - this is a warning as it indicates a format mismatch
|
|
600
598
|
if BrainzLab.configuration.reflex_effectively_enabled?
|
|
@@ -613,7 +611,7 @@ module BrainzLab
|
|
|
613
611
|
# Log to Recall - this is significant for debugging serialization issues
|
|
614
612
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
615
613
|
BrainzLab::Recall.warn(
|
|
616
|
-
|
|
614
|
+
'Message serializer fallback used',
|
|
617
615
|
serializer: serializer.to_s,
|
|
618
616
|
fallback: fallback.to_s,
|
|
619
617
|
duration_ms: duration
|
|
@@ -15,13 +15,16 @@ module BrainzLab
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def install_plugin!
|
|
18
|
-
# AWS
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
18
|
+
# Registered through Seahorse only. Every AWS client inherits from
|
|
19
|
+
# Seahorse::Client::Base, so this one hook covers S3, SQS and the rest,
|
|
20
|
+
# on every version of the SDK.
|
|
21
|
+
#
|
|
22
|
+
# There used to be a second registration through Aws.config[:plugins].
|
|
23
|
+
# That option only exists on newer aws-sdk-core; on older ones it does
|
|
24
|
+
# not degrade, it makes *every* client fail to construct with
|
|
25
|
+
# "invalid configuration option :plugins" — so an app pinned to an
|
|
26
|
+
# older SDK could not talk to S3 at all. It bought nothing either way,
|
|
27
|
+
# since the handler below is the same handler.
|
|
25
28
|
return unless defined?(::Seahorse::Client::Base)
|
|
26
29
|
|
|
27
30
|
install_seahorse_handler!
|
|
@@ -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,20 @@ 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
|
+
return nil unless routing_args.respond_to?(:dig)
|
|
207
|
+
|
|
208
|
+
routing_args[key]
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
179
213
|
def extract_route_pattern(endpoint)
|
|
180
214
|
return '/' unless endpoint
|
|
181
215
|
|
|
@@ -242,8 +276,9 @@ module BrainzLab
|
|
|
242
276
|
method = request.request_method
|
|
243
277
|
path = request.path
|
|
244
278
|
|
|
245
|
-
# Get route pattern from Grape if available
|
|
246
|
-
|
|
279
|
+
# Get route pattern from Grape if available, else normalize the raw
|
|
280
|
+
# path so /users/1 and /users/2 aggregate as one endpoint.
|
|
281
|
+
route_pattern = route_pattern_for(env, path)
|
|
247
282
|
|
|
248
283
|
spans = Thread.current[:brainzlab_pulse_spans] || []
|
|
249
284
|
|
|
@@ -60,7 +60,7 @@ module BrainzLab
|
|
|
60
60
|
# Log to Recall for tracking
|
|
61
61
|
if BrainzLab.configuration.recall_effectively_enabled?
|
|
62
62
|
BrainzLab::Recall.warn(
|
|
63
|
-
|
|
63
|
+
'Rails deprecation warning',
|
|
64
64
|
message: truncate_message(message, 500),
|
|
65
65
|
gem_name: gem_name,
|
|
66
66
|
deprecation_horizon: deprecation_horizon,
|
|
@@ -4,8 +4,8 @@ module BrainzLab
|
|
|
4
4
|
module Instrumentation
|
|
5
5
|
class Railties
|
|
6
6
|
# Thresholds for slow initializers (in milliseconds)
|
|
7
|
-
SLOW_INITIALIZER_THRESHOLD = 100
|
|
8
|
-
VERY_SLOW_INITIALIZER_THRESHOLD = 500
|
|
7
|
+
SLOW_INITIALIZER_THRESHOLD = 100 # 100ms
|
|
8
|
+
VERY_SLOW_INITIALIZER_THRESHOLD = 500 # 500ms
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
def install!
|
|
@@ -212,9 +212,9 @@ module BrainzLab
|
|
|
212
212
|
BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
|
|
213
213
|
|
|
214
214
|
# Call on_error callback if configured
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
return unless @config.on_error
|
|
216
|
+
|
|
217
|
+
@config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
def handle_response_error(response, operation)
|
|
@@ -224,9 +224,7 @@ module BrainzLab
|
|
|
224
224
|
BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
|
|
225
225
|
|
|
226
226
|
# Call on_error callback if configured
|
|
227
|
-
|
|
228
|
-
@config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
|
|
229
|
-
end
|
|
227
|
+
@config.on_error&.call(structured_error, { service: 'Nerve', operation: operation })
|
|
230
228
|
|
|
231
229
|
structured_error
|
|
232
230
|
end
|
|
@@ -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
|
data/lib/brainzlab/pulse.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'pulse/provisioner'
|
|
|
5
5
|
require_relative 'pulse/tracer'
|
|
6
6
|
require_relative 'pulse/instrumentation'
|
|
7
7
|
require_relative 'pulse/propagation'
|
|
8
|
+
require_relative 'pulse/explain_capture'
|
|
8
9
|
|
|
9
10
|
module BrainzLab
|
|
10
11
|
module Pulse
|
|
@@ -118,7 +119,11 @@ module BrainzLab
|
|
|
118
119
|
|
|
119
120
|
# Parse timestamp or use current time
|
|
120
121
|
started_at = if timestamp
|
|
121
|
-
|
|
122
|
+
begin
|
|
123
|
+
Time.parse(timestamp)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
Time.now.utc
|
|
126
|
+
end
|
|
122
127
|
else
|
|
123
128
|
Time.now.utc
|
|
124
129
|
end
|
|
@@ -33,9 +33,7 @@ module BrainzLab
|
|
|
33
33
|
# Add request context middleware. Gated on `enabled` so the kill switch
|
|
34
34
|
# (BRAINZLAB_SDK_ENABLED=false) fully removes it — it previously loaded
|
|
35
35
|
# unconditionally, so disabling didn't stop its session interference.
|
|
36
|
-
if BrainzLab.configuration.enabled
|
|
37
|
-
app.middleware.insert_after ActionDispatch::RequestId, BrainzLab::Rails::Middleware
|
|
38
|
-
end
|
|
36
|
+
app.middleware.insert_after ActionDispatch::RequestId, BrainzLab::Rails::Middleware if BrainzLab.configuration.enabled
|
|
39
37
|
|
|
40
38
|
# Add DevTools middlewares if enabled
|
|
41
39
|
if BrainzLab.configuration.devtools_enabled
|
|
@@ -213,9 +213,9 @@ module BrainzLab
|
|
|
213
213
|
BrainzLab.debug_log("[Sentinel::Client] #{operation} failed: #{structured_error.message}")
|
|
214
214
|
|
|
215
215
|
# Call on_error callback if configured
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
return unless @config.on_error
|
|
217
|
+
|
|
218
|
+
@config.on_error.call(structured_error, { service: 'Sentinel', operation: operation })
|
|
219
219
|
end
|
|
220
220
|
|
|
221
221
|
def handle_response_error(response, operation)
|
|
@@ -225,9 +225,7 @@ module BrainzLab
|
|
|
225
225
|
BrainzLab.debug_log("[Sentinel::Client] #{operation} failed: #{structured_error.message}")
|
|
226
226
|
|
|
227
227
|
# Call on_error callback if configured
|
|
228
|
-
|
|
229
|
-
@config.on_error.call(structured_error, { service: 'Sentinel', operation: operation })
|
|
230
|
-
end
|
|
228
|
+
@config.on_error&.call(structured_error, { service: 'Sentinel', operation: operation })
|
|
231
229
|
|
|
232
230
|
structured_error
|
|
233
231
|
end
|
|
@@ -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,
|
|
@@ -285,9 +285,9 @@ module BrainzLab
|
|
|
285
285
|
BrainzLab.debug_log("[Synapse::Client] #{operation} failed: #{structured_error.message}")
|
|
286
286
|
|
|
287
287
|
# Call on_error callback if configured
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
return unless @config.on_error
|
|
289
|
+
|
|
290
|
+
@config.on_error.call(structured_error, { service: 'Synapse', operation: operation })
|
|
291
291
|
end
|
|
292
292
|
|
|
293
293
|
def handle_response_error(response, operation)
|
|
@@ -297,9 +297,7 @@ module BrainzLab
|
|
|
297
297
|
BrainzLab.debug_log("[Synapse::Client] #{operation} failed: #{structured_error.message}")
|
|
298
298
|
|
|
299
299
|
# Call on_error callback if configured
|
|
300
|
-
|
|
301
|
-
@config.on_error.call(structured_error, { service: 'Synapse', operation: operation })
|
|
302
|
-
end
|
|
300
|
+
@config.on_error&.call(structured_error, { service: 'Synapse', operation: operation })
|
|
303
301
|
|
|
304
302
|
structured_error
|
|
305
303
|
end
|
|
@@ -187,9 +187,9 @@ module BrainzLab
|
|
|
187
187
|
def error_captured?(error_class = nil, message: nil, context: nil)
|
|
188
188
|
@mutex.synchronize do
|
|
189
189
|
@errors.any? do |error|
|
|
190
|
-
if error_class
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
if error_class && !(error[:error_class] == error_class.to_s ||
|
|
191
|
+
(error_class.is_a?(Class) && error[:exception].is_a?(error_class)))
|
|
192
|
+
next false
|
|
193
193
|
end
|
|
194
194
|
|
|
195
195
|
if message
|