brainzlab 0.1.33 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9a9a15a426d4a6853086c9a36851e57bd14736c5d11dbeb70fb510fe5fc1c10
4
- data.tar.gz: 19f26af342b3d9150eacb2615da08fa063cc671388f7b97a87b207cd210cf6fc
3
+ metadata.gz: c67c9e29d88bcc008c8ba0464b933b61f3a9bd255868f59af8b7e0daf31665dc
4
+ data.tar.gz: bbfe09e8406b488f995cf312b1b1838cd4f614dc1708316eda3c3b5cd1dcab08
5
5
  SHA512:
6
- metadata.gz: b149c1fbf2c0f13fa34d0b2d2e6df634a1dc01f087f8174ba58fcc0bf05ea0301365721131e03441bf0af7e3b443d5a86acc5dfa141ba4fa9af2c24c610782a9
7
- data.tar.gz: 737cd856a43c4c3555a566653692fd32d04798e464e24bad80d189610b82e81c0d0b2860cda232fae66063477b21687858c14e5178050a771af6745b9741d46f
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.
@@ -45,6 +45,9 @@ module BrainzLab
45
45
  :pulse_sample_tier,
46
46
  :pulse_always_keep_slow_ms,
47
47
  :pulse_excluded_paths,
48
+ :pulse_explain_enabled,
49
+ :pulse_explain_threshold,
50
+ :pulse_explain_timeout,
48
51
  :flux_enabled,
49
52
  :flux_url,
50
53
  :flux_api_key,
@@ -401,6 +404,14 @@ module BrainzLab
401
404
  # default can't flood Recall. Slow-query duration is APM data (Pulse already
402
405
  # records it), so it defaults to a quiet level (:debug) and a higher threshold.
403
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
404
415
  @very_slow_query_threshold = (ENV['BRAINZLAB_VERY_SLOW_QUERY_THRESHOLD'] || 2000).to_i # ms (was hardcoded 1000)
405
416
  @slow_query_log_level = (ENV['BRAINZLAB_SLOW_QUERY_LOG_LEVEL'] || 'debug').to_sym # was :warn → floods
406
417
  @very_slow_query_log_level = (ENV['BRAINZLAB_VERY_SLOW_QUERY_LOG_LEVEL'] || 'warn').to_sym
@@ -498,6 +509,67 @@ module BrainzLab
498
509
  @enabled && !@secret_key.nil? && !@secret_key.empty?
499
510
  end
500
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
+
501
573
  def enabled?
502
574
  @enabled == true
503
575
  end
@@ -714,6 +786,10 @@ module BrainzLab
714
786
 
715
787
  private
716
788
 
789
+ def positive_number?(value)
790
+ value.is_a?(Numeric) && value.positive?
791
+ end
792
+
717
793
  def detect_environment
718
794
  return ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
719
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
- route_pattern = env['grape.routing_args']&.dig(:route_info)&.pattern&.path || path
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
 
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BrainzLab
4
- VERSION = '0.1.33'
4
+ VERSION = '0.1.34'
5
5
  end
data/lib/brainzlab.rb CHANGED
@@ -63,6 +63,7 @@ module BrainzLab
63
63
 
64
64
  def configure
65
65
  yield(configuration) if block_given?
66
+ configuration.validate!
66
67
  configuration
67
68
  end
68
69
 
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.33
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