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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -0
  3. data/lib/brainzlab/beacon/client.rb +4 -6
  4. data/lib/brainzlab/configuration.rb +363 -258
  5. data/lib/brainzlab/cortex/client.rb +4 -6
  6. data/lib/brainzlab/debug.rb +6 -6
  7. data/lib/brainzlab/dendrite/client.rb +4 -6
  8. data/lib/brainzlab/development/logger.rb +2 -2
  9. data/lib/brainzlab/development/store.rb +2 -4
  10. data/lib/brainzlab/devtools/middleware/error_page.rb +1 -1
  11. data/lib/brainzlab/errors.rb +5 -12
  12. data/lib/brainzlab/instrumentation/action_cable.rb +4 -4
  13. data/lib/brainzlab/instrumentation/action_controller.rb +12 -12
  14. data/lib/brainzlab/instrumentation/action_dispatch.rb +1 -1
  15. data/lib/brainzlab/instrumentation/action_mailbox.rb +7 -13
  16. data/lib/brainzlab/instrumentation/action_view.rb +29 -33
  17. data/lib/brainzlab/instrumentation/active_job.rb +6 -10
  18. data/lib/brainzlab/instrumentation/active_record.rb +53 -25
  19. data/lib/brainzlab/instrumentation/active_storage.rb +4 -4
  20. data/lib/brainzlab/instrumentation/active_support_cache.rb +6 -8
  21. data/lib/brainzlab/instrumentation/aws.rb +10 -7
  22. data/lib/brainzlab/instrumentation/grape.rb +37 -2
  23. data/lib/brainzlab/instrumentation/rails_deprecation.rb +1 -1
  24. data/lib/brainzlab/instrumentation/railties.rb +2 -2
  25. data/lib/brainzlab/nerve/client.rb +4 -6
  26. data/lib/brainzlab/pulse/client.rb +1 -1
  27. data/lib/brainzlab/pulse/explain_capture.rb +123 -0
  28. data/lib/brainzlab/pulse.rb +6 -1
  29. data/lib/brainzlab/rails/railtie.rb +1 -3
  30. data/lib/brainzlab/reflex/client.rb +1 -1
  31. data/lib/brainzlab/sentinel/client.rb +4 -6
  32. data/lib/brainzlab/signal/client.rb +16 -2
  33. data/lib/brainzlab/signal.rb +8 -0
  34. data/lib/brainzlab/synapse/client.rb +4 -6
  35. data/lib/brainzlab/testing/event_store.rb +3 -3
  36. data/lib/brainzlab/testing/matchers.rb +4 -4
  37. data/lib/brainzlab/testing.rb +5 -3
  38. data/lib/brainzlab/utilities/route_path_normalizer.rb +91 -0
  39. data/lib/brainzlab/utilities.rb +1 -0
  40. data/lib/brainzlab/vault/client.rb +4 -6
  41. data/lib/brainzlab/version.rb +1 -1
  42. data/lib/brainzlab/vision/client.rb +3 -3
  43. data/lib/brainzlab.rb +1 -0
  44. data/lib/fluyenta-ruby.rb +1 -1
  45. 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
- if tracker[:count] == BrainzLab.configuration.n_plus_one_threshold
196
- report_n_plus_one(table, tracker)
197
- end
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
- if BrainzLab.configuration.recall_effectively_enabled?
213
- BrainzLab::Recall.warn(
214
- "Potential N+1 query detected",
215
- table: table,
216
- query_count: tracker[:count],
217
- sample_queries: tracker[:queries]
218
- )
219
- end
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.object_id] = event.time
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&.object_id) || event.time
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
- "Strict loading violation detected",
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
- "Deprecated association accessed",
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
- if sql =~ /FROM\s+["'`]?(\w+)["'`]?/i
494
- ::Regexp.last_match(1)
495
- end
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
- checksum = payload[:checksum]
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
- "Storage bulk delete by prefix",
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
- "Slow storage URL generation",
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
- disposition = payload[:disposition]
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 > 0 ? (hit_count.to_f / key_count * 100).round(1) : 0
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
- "Cache pattern delete",
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
- "Cache pruned",
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
- serialized = payload[:serialized]
597
- deserialized = payload[:deserialized]
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
- "Message serializer fallback used",
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 SDK v3 uses plugins
19
- if defined?(::Aws::Plugins)
20
- ::Aws.config[:plugins] ||= []
21
- ::Aws.config[:plugins] << BrainzLabPlugin unless ::Aws.config[:plugins].include?(BrainzLabPlugin)
22
- end
23
-
24
- # Also hook into Seahorse for lower-level tracking
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
- route_pattern = env['grape.routing_args']&.dig(:route_info)&.pattern&.path || path
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
- "Rails deprecation warning",
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 # 100ms
8
- VERY_SLOW_INITIALIZER_THRESHOLD = 500 # 500ms
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
- if @config.on_error
216
- @config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
217
- end
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
- if @config.on_error
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
@@ -207,7 +207,7 @@ module BrainzLab
207
207
  end
208
208
 
209
209
  def handle_error(error, context: {})
210
- log_error("#{error.message}")
210
+ log_error(error.message.to_s)
211
211
 
212
212
  # Call on_error callback if configured
213
213
  return unless @config.on_error
@@ -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
@@ -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
- Time.parse(timestamp) rescue Time.now.utc
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
@@ -125,7 +125,7 @@ module BrainzLab
125
125
  end
126
126
 
127
127
  def handle_error(error, context: {})
128
- log_error("#{error.message}")
128
+ log_error(error.message.to_s)
129
129
 
130
130
  # Call on_error callback if configured
131
131
  return unless @config.on_error
@@ -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
- if @config.on_error
217
- @config.on_error.call(structured_error, { service: 'Sentinel', operation: operation })
218
- end
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
- if @config.on_error
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/rules/trigger', payload)
37
+ post('/api/v1/alerts/trigger', payload)
24
38
  end
25
39
 
26
40
  private
@@ -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
- if @config.on_error
289
- @config.on_error.call(structured_error, { service: 'Synapse', operation: operation })
290
- end
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
- if @config.on_error
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
- next false unless error[:error_class] == error_class.to_s ||
192
- (error_class.is_a?(Class) && error[:exception].is_a?(error_class))
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