debugbundle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +17 -0
  3. data/Makefile +43 -0
  4. data/README.md +168 -0
  5. data/debugbundle.gemspec +30 -0
  6. data/lib/debugbundle/client.rb +724 -0
  7. data/lib/debugbundle/config.rb +144 -0
  8. data/lib/debugbundle/logging.rb +77 -0
  9. data/lib/debugbundle/rack/middleware.rb +94 -0
  10. data/lib/debugbundle/rack/relay_middleware.rb +37 -0
  11. data/lib/debugbundle/rails/railtie.rb +35 -0
  12. data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
  13. data/lib/debugbundle/rails.rb +10 -0
  14. data/lib/debugbundle/redaction.rb +151 -0
  15. data/lib/debugbundle/relay/handler.rb +231 -0
  16. data/lib/debugbundle/relay.rb +4 -0
  17. data/lib/debugbundle/remote_config.rb +153 -0
  18. data/lib/debugbundle/runtime.rb +22 -0
  19. data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
  20. data/lib/debugbundle/suppression.rb +121 -0
  21. data/lib/debugbundle/transport.rb +190 -0
  22. data/lib/debugbundle/trigger_token.rb +122 -0
  23. data/lib/debugbundle/version.rb +5 -0
  24. data/lib/debugbundle.rb +93 -0
  25. data/spec/client_spec.rb +236 -0
  26. data/spec/debugbundle_spec.rb +54 -0
  27. data/spec/file_transport_spec.rb +54 -0
  28. data/spec/logger_integration_spec.rb +118 -0
  29. data/spec/rack_integration_spec.rb +44 -0
  30. data/spec/rack_middleware_spec.rb +206 -0
  31. data/spec/rails_railtie_spec.rb +96 -0
  32. data/spec/rails_relay_spec.rb +121 -0
  33. data/spec/redaction_spec.rb +42 -0
  34. data/spec/relay_spec.rb +178 -0
  35. data/spec/remote_config_spec.rb +402 -0
  36. data/spec/sidekiq_integration_spec.rb +66 -0
  37. data/spec/sidekiq_middleware_spec.rb +50 -0
  38. data/spec/spec_helper.rb +20 -0
  39. data/spec/suppression_spec.rb +16 -0
  40. metadata +113 -0
@@ -0,0 +1,724 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'time'
5
+
6
+ require 'debugbundle/runtime'
7
+
8
+ module DebugBundle
9
+ class Client
10
+ SCHEMA_VERSION = '2026-03-01'
11
+ SDK_NAME = '@debugbundle/sdk-ruby'
12
+ DEFAULT_SERVICE_NAME = 'ruby-service'
13
+ DEFAULT_ENVIRONMENT = 'development'
14
+ MAX_BUFFER_SIZE = 1_000
15
+ RETRY_AFTER_CAP_SECONDS = 300
16
+ DEFAULT_HEADER_ALLOWLIST = %w[
17
+ user-agent
18
+ content-type
19
+ accept
20
+ x-request-id
21
+ x-correlation-id
22
+ x-debugbundle-trace-id
23
+ ].freeze
24
+ BALANCED_IMMEDIATE_REQUEST_STATUSES = [408, 423, 424, 425, 429].freeze
25
+ INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = (BALANCED_IMMEDIATE_REQUEST_STATUSES + [409]).freeze
26
+ BALANCED_ANOMALY_REQUEST_STATUSES = [400, 401, 403, 404, 409, 410, 422].freeze
27
+ LOCAL_ENVIRONMENTS = %w[development local test].freeze
28
+ REQUEST_TRIGGER_DIRECTIVES_KEY = :__debugbundle_request_trigger_directives__
29
+ THREAD_HOOK_MUTEX = Mutex.new
30
+ LOG_LEVEL_RANKS = {
31
+ debug: 10,
32
+ info: 20,
33
+ warning: 30,
34
+ error: 40,
35
+ fatal: 50,
36
+ critical: 50
37
+ }.freeze
38
+
39
+ attr_reader :config, :last_event_at
40
+
41
+ class << self
42
+ attr_accessor :thread_exception_client
43
+
44
+ def dispatch_thread_exception(error)
45
+ thread_exception_client&.__send__(:capture_thread_exception, error)
46
+ end
47
+
48
+ def install_thread_exception_hook!
49
+ THREAD_HOOK_MUTEX.synchronize do
50
+ return if @thread_exception_hook_installed
51
+
52
+ interceptor = Module.new do
53
+ define_method(:new) do |*args, &block|
54
+ super(*args, &DebugBundle::Client.wrap_thread_block(block))
55
+ end
56
+
57
+ define_method(:start) do |*args, &block|
58
+ super(*args, &DebugBundle::Client.wrap_thread_block(block))
59
+ end
60
+
61
+ define_method(:fork) do |*args, &block|
62
+ super(*args, &DebugBundle::Client.wrap_thread_block(block))
63
+ end
64
+ end
65
+
66
+ ::Thread.singleton_class.prepend(interceptor)
67
+ @thread_exception_hook_installed = true
68
+ end
69
+ end
70
+
71
+ def wrap_thread_block(block)
72
+ return nil unless block
73
+
74
+ proc do |*thread_args|
75
+ block.call(*thread_args)
76
+ rescue StandardError => e
77
+ dispatch_thread_exception(e)
78
+ raise
79
+ end
80
+ end
81
+ end
82
+
83
+ def initialize(transport: nil, time_provider: nil, random_provider: nil, config_fetcher: nil, **options)
84
+ @config = Config.new(**options)
85
+ @time_provider = time_provider || -> { Time.now.utc }
86
+ @random_provider = random_provider || -> { rand }
87
+ @redactor = Redaction::Redactor.new(
88
+ sensitive_fields: Redaction::DEFAULT_SENSITIVE_FIELDS + config.redact_fields
89
+ )
90
+ @transport = transport || build_default_transport
91
+ @config_fetcher = config_fetcher || build_default_config_fetcher(custom_transport: !transport.nil?)
92
+ @context = {}
93
+ @buffer = []
94
+ @buffer_mutex = Mutex.new
95
+ @flush_mutex = Mutex.new
96
+ @probe_buffers = {}
97
+ @suppression = Suppression::Tracker.new
98
+ @last_event_at = nil
99
+ @retry_at = nil
100
+ @consecutive_failures = 0
101
+ @at_exit_registered = false
102
+ @thread_exception_registered = false
103
+ @logger_bindings = {}
104
+ @capture_semantic_logger = nil
105
+ @next_remote_config_poll_at = nil
106
+ @remote_config_etag = nil
107
+ @remote_config = RemoteConfig::Snapshot.default
108
+ @capture_policy = @remote_config.capture_policy
109
+
110
+ refresh_remote_config!
111
+ @capture_policy = RemoteConfig.minimal_capture_policy if @config_fetcher && @remote_config_etag.nil?
112
+ end
113
+
114
+ def capture_exception(error, context: nil, handled: true)
115
+ return unless capture_enabled?
116
+
117
+ poll_remote_config_if_due!
118
+
119
+ merged_context = merge_context(context)
120
+ payload = {
121
+ 'name' => error.class.name,
122
+ 'message' => error.message.to_s,
123
+ 'stack' => Array(error.backtrace).join("\n"),
124
+ 'handled' => handled,
125
+ 'request' => request_payload(merged_context['request']),
126
+ 'response' => response_payload(merged_context['response']),
127
+ 'runtime' => runtime_payload
128
+ }
129
+
130
+ causes = exception_causes(error)
131
+ payload['causes'] = causes unless causes.empty?
132
+
133
+ probe_data = probe_snapshot
134
+ payload['probe_data'] = probe_data unless probe_data.empty?
135
+
136
+ extra_context = merged_context.except('request', 'response', 'correlation')
137
+ payload['context'] = extra_context unless extra_context.empty?
138
+
139
+ suppression_key = [payload['name'], payload['message'], payload['stack']].join(':')
140
+ return unless @suppression.should_capture(suppression_key, now: monotonic_now)
141
+
142
+ enqueue_event(base_event('backend_exception', payload, merged_context))
143
+ end
144
+
145
+ def capture_error(error, context: nil, handled: true)
146
+ capture_exception(error, context: context, handled: handled)
147
+ end
148
+
149
+ def capture_log(message, level: :warning, context: nil)
150
+ return unless capture_enabled?
151
+
152
+ poll_remote_config_if_due!
153
+
154
+ normalized_level = normalize_level(level || :warning)
155
+ return unless level_enabled?(normalized_level)
156
+
157
+ merged_context = merge_context(context)
158
+ payload = {
159
+ 'level' => normalized_level.to_s,
160
+ 'message' => message.to_s,
161
+ 'attributes' => merged_context
162
+ }
163
+ enqueue_event(base_event('log_event', payload, merged_context))
164
+ end
165
+
166
+ def capture_request(request, response, context: nil)
167
+ return unless capture_enabled?
168
+
169
+ poll_remote_config_if_due!
170
+
171
+ merged_context = merge_context(context)
172
+ sanitized_request = request_payload(request)
173
+ sanitized_response = response_payload(response)
174
+ response_status = (sanitized_response['status_code'] || 0).to_i
175
+ return unless capture_request_event?(response_status)
176
+
177
+ payload = {
178
+ 'method' => sanitized_request['method'],
179
+ 'path' => sanitized_request['path'],
180
+ 'query' => sanitized_request['query'],
181
+ 'headers' => sanitized_request['headers'],
182
+ 'body' => sanitized_request['body'],
183
+ 'response_status' => response_status,
184
+ 'duration_ms' => extract_duration_ms(merged_context, sanitized_response),
185
+ 'route_template' => merged_context['route_template'],
186
+ 'controller' => merged_context['controller'],
187
+ 'action' => merged_context['action'],
188
+ 'response_headers' => sanitized_response['headers'],
189
+ 'response_body' => sanitized_response['body']
190
+ }
191
+ enqueue_event(base_event('request_event', payload, merged_context.merge('request' => sanitized_request)))
192
+ end
193
+
194
+ def capture_message(message, level: nil, context: nil)
195
+ capture_log(message, level: level || :info, context: context)
196
+ end
197
+
198
+ def set_context(key, value)
199
+ @context[key.to_s] = @redactor.redact_value(value)
200
+ value
201
+ end
202
+
203
+ def probe(label, data = nil, heavy: false, &block)
204
+ return unless capture_enabled?
205
+
206
+ poll_remote_config_if_due!
207
+ return unless @remote_config.probes_enabled
208
+
209
+ matching_directives = matching_probe_directives(label)
210
+ if heavy
211
+ return if matching_directives.empty?
212
+
213
+ raw_value = block ? block.call : data
214
+ emit_probe_events(label.to_s, @redactor.redact_value(raw_value), matching_directives)
215
+ return
216
+ end
217
+
218
+ return if !@probe_buffers.key?(label) && @probe_buffers.size >= config.max_probe_labels
219
+
220
+ raw_value = block ? block.call : data
221
+ entry = {
222
+ 'label' => label.to_s,
223
+ 'data' => @redactor.redact_value(raw_value),
224
+ 'occurred_at' => now.iso8601
225
+ }
226
+
227
+ bucket = (@probe_buffers[label.to_s] ||= [])
228
+ bucket << entry
229
+ bucket.shift while bucket.length > config.max_probe_entries_per_label
230
+
231
+ emit_probe_events(label.to_s, entry['data'], matching_directives)
232
+ end
233
+
234
+ def capture_exceptions
235
+ at_exit_registered = capture_at_exit
236
+ thread_registered = capture_thread_exceptions
237
+
238
+ at_exit_registered || thread_registered
239
+ end
240
+
241
+ def capture_at_exit
242
+ return false if @at_exit_registered
243
+
244
+ @at_exit_registered = true
245
+ client = self
246
+ at_exit do
247
+ error = $ERROR_INFO
248
+ next unless error.is_a?(Exception)
249
+
250
+ client.capture_exception(error, handled: false)
251
+ client.flush
252
+ end
253
+ true
254
+ end
255
+
256
+ def capture_logger(logger = ::Logger.new($stdout))
257
+ binding_key = logger.object_id
258
+ return logger if @logger_bindings.key?(binding_key)
259
+
260
+ @logger_bindings[binding_key] = Logging.install_stdlib_logger(logger, client: self)
261
+ logger
262
+ end
263
+
264
+ def capture_semantic_logger
265
+ @capture_semantic_logger ||= Logging.install_semantic_logger(client: self)
266
+ end
267
+
268
+ def with_request_trigger(request)
269
+ poll_remote_config_if_due! if capture_enabled?
270
+
271
+ directives = TriggerToken.resolve_request_directives(
272
+ request: request,
273
+ trigger_token_key: @remote_config.trigger_token_key
274
+ )
275
+ previous = Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY]
276
+ Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY] = directives
277
+ yield
278
+ ensure
279
+ Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY] = previous
280
+ end
281
+
282
+ def refresh_remote_config!
283
+ return false unless capture_enabled?
284
+ return false unless @config_fetcher
285
+
286
+ response = @config_fetcher.call(@remote_config_etag)
287
+ status_code = response.fetch(:status_code, 500)
288
+ if status_code == 304
289
+ schedule_next_remote_config_poll
290
+ return true
291
+ end
292
+ unless status_code == 200
293
+ schedule_next_remote_config_poll
294
+ return false
295
+ end
296
+
297
+ snapshot = RemoteConfig.parse(response.fetch(:body, {}), config.probes_poll_interval)
298
+ unless snapshot
299
+ schedule_next_remote_config_poll
300
+ return false
301
+ end
302
+
303
+ @remote_config = snapshot
304
+ @capture_policy = snapshot.capture_policy
305
+ @remote_config_etag = response[:etag]
306
+ schedule_next_remote_config_poll
307
+ true
308
+ rescue StandardError
309
+ schedule_next_remote_config_poll
310
+ false
311
+ end
312
+
313
+ def with_exception_capture(context: nil)
314
+ yield
315
+ rescue StandardError => e
316
+ capture_exception(e, context: context, handled: false)
317
+ raise
318
+ end
319
+
320
+ def flush
321
+ # rubocop:disable Metrics/BlockLength
322
+ @flush_mutex.synchronize do
323
+ append_suppression_aggregates
324
+ batch = buffered_batch
325
+ return true if batch.empty?
326
+ return false if @transport.nil?
327
+ return false if rate_limited?
328
+
329
+ result = Transport.coerce_result(
330
+ @transport.call(
331
+ project_token: config.project_token,
332
+ service_name: service_name,
333
+ events: batch.map(&:dup)
334
+ )
335
+ )
336
+
337
+ case result.status_code
338
+ when 200..299
339
+ remove_buffered_events(batch)
340
+ @retry_at = nil
341
+ @consecutive_failures = 0
342
+ @last_event_at = now
343
+ true
344
+ when 429
345
+ @consecutive_failures += 1
346
+ retry_after_seconds = (result.retry_after_seconds || 1).clamp(1, RETRY_AFTER_CAP_SECONDS)
347
+ @retry_at = now + retry_after_seconds
348
+ false
349
+ when 400..499
350
+ remove_buffered_events(batch)
351
+ @retry_at = nil
352
+ @consecutive_failures = 0
353
+ false
354
+ else
355
+ @consecutive_failures += 1
356
+ false
357
+ end
358
+ end
359
+ # rubocop:enable Metrics/BlockLength
360
+ rescue StandardError
361
+ @consecutive_failures += 1
362
+ false
363
+ end
364
+
365
+ def status
366
+ return :disconnected unless config.enabled?
367
+ return :degraded unless config.configured?
368
+ return :disconnected if @consecutive_failures >= 3
369
+ return :degraded if rate_limited?
370
+
371
+ :healthy
372
+ end
373
+
374
+ def buffered_event_count
375
+ @buffer_mutex.synchronize { @buffer.length }
376
+ end
377
+
378
+ private
379
+
380
+ def build_default_transport
381
+ return nil unless config.enabled?
382
+
383
+ if config.project_mode == :local_only || local_environment?
384
+ Transport::FileTransport.new(config.local_events_dir)
385
+ elsif config.configured?
386
+ Transport::HttpTransport.new(config.endpoint)
387
+ end
388
+ end
389
+
390
+ def build_default_config_fetcher(custom_transport:)
391
+ return nil if custom_transport
392
+ return nil unless config.enabled? && config.configured?
393
+ return nil if config.project_mode == :local_only || local_environment?
394
+
395
+ Transport::HttpConfigFetcher.new(
396
+ config.endpoint,
397
+ project_token: config.project_token,
398
+ sdk_name: SDK_NAME,
399
+ sdk_version: DebugBundle::VERSION
400
+ )
401
+ end
402
+
403
+ def capture_enabled?
404
+ config.enabled? && config.configured?
405
+ end
406
+
407
+ def merge_context(context)
408
+ merged = @context.merge(stringify_hash(context || {}))
409
+ @redactor.redact_value(merged)
410
+ end
411
+
412
+ def stringify_hash(value)
413
+ return {} unless value.is_a?(Hash)
414
+
415
+ value.each_with_object({}) do |(key, nested_value), result|
416
+ result[key.to_s] = nested_value
417
+ end
418
+ end
419
+
420
+ def request_payload(request)
421
+ source = object_to_hash(request)
422
+ {
423
+ 'method' => source['method'],
424
+ 'path' => source['path'],
425
+ 'query' => @redactor.redact_value(source['query'] || {}),
426
+ 'headers' => sanitized_headers(source['headers'] || {}),
427
+ 'body' => @redactor.redact_value(source['body'] || {})
428
+ }
429
+ end
430
+
431
+ def response_payload(response)
432
+ source = object_to_hash(response)
433
+ {
434
+ 'status_code' => source['status_code'] || source['status'] || 0,
435
+ 'headers' => sanitized_headers(source['headers'] || {}),
436
+ 'body' => @redactor.redact_value(source['body'] || {})
437
+ }
438
+ end
439
+
440
+ def runtime_payload
441
+ Runtime.payload
442
+ end
443
+
444
+ def exception_causes(error)
445
+ causes = []
446
+ current = error.cause
447
+
448
+ while current
449
+ causes << {
450
+ 'name' => current.class.name,
451
+ 'message' => current.message.to_s,
452
+ 'stack' => Array(current.backtrace).join("\n")
453
+ }
454
+ current = current.cause
455
+ end
456
+
457
+ causes
458
+ end
459
+
460
+ def probe_snapshot
461
+ items = @probe_buffers.values.flatten.map do |entry|
462
+ entry.merge('activation_id' => nil)
463
+ end
464
+ return {} if items.empty?
465
+
466
+ { 'version' => 1, 'items' => items }
467
+ end
468
+
469
+ def enqueue_event(event)
470
+ return unless sampled_in?
471
+
472
+ @buffer_mutex.synchronize do
473
+ @buffer << event
474
+ @buffer.shift while @buffer.length > MAX_BUFFER_SIZE
475
+ end
476
+ end
477
+
478
+ def buffered_batch
479
+ @buffer_mutex.synchronize { @buffer.dup }
480
+ end
481
+
482
+ def remove_buffered_events(events)
483
+ event_ids = events.map { |event| event['event_id'] }
484
+ @buffer_mutex.synchronize do
485
+ @buffer.reject! { |event| event_ids.include?(event['event_id']) }
486
+ end
487
+ end
488
+
489
+ def sampled_in?
490
+ return false if config.sample_rate <= 0.0
491
+ return true if config.sample_rate >= 1.0
492
+
493
+ @random_provider.call.to_f < config.sample_rate
494
+ rescue StandardError
495
+ true
496
+ end
497
+
498
+ def append_suppression_aggregates
499
+ @suppression.drain_aggregates(now: monotonic_now).each do |aggregate|
500
+ enqueue_event(base_event('error_suppressed', aggregate, {}))
501
+ end
502
+ end
503
+
504
+ def base_event(event_type, payload, context)
505
+ {
506
+ 'schema_version' => SCHEMA_VERSION,
507
+ 'event_id' => SecureRandom.uuid,
508
+ 'event_type' => event_type,
509
+ 'project_token' => config.project_token,
510
+ 'sdk_name' => SDK_NAME,
511
+ 'sdk_version' => DebugBundle::VERSION,
512
+ 'service' => {
513
+ 'name' => service_name,
514
+ 'runtime' => 'ruby',
515
+ 'framework' => context['framework'],
516
+ 'environment' => environment_name
517
+ },
518
+ 'occurred_at' => now.iso8601,
519
+ 'correlation' => correlation_payload(context),
520
+ 'payload' => @redactor.redact_value(payload)
521
+ }
522
+ end
523
+
524
+ def service_name
525
+ config.service || DEFAULT_SERVICE_NAME
526
+ end
527
+
528
+ def environment_name
529
+ config.environment || DEFAULT_ENVIRONMENT
530
+ end
531
+
532
+ def correlation_payload(context)
533
+ request = object_to_hash(context['request'])
534
+ correlation = object_to_hash(context['correlation'])
535
+ {
536
+ 'request_id' => correlation['request_id'] || request['request_id'] || context['request_id'],
537
+ 'trace_id' => correlation['trace_id'] || request['trace_id'] || context['trace_id'],
538
+ 'session_id' => correlation['session_id'] || context['session_id'],
539
+ 'user_id_hash' => correlation['user_id_hash'] || context['user_id_hash']
540
+ }
541
+ end
542
+
543
+ def object_to_hash(value)
544
+ case value
545
+ when Hash
546
+ stringify_hash(value)
547
+ else
548
+ if value.respond_to?(:to_h)
549
+ stringify_hash(value.to_h)
550
+ elsif value.respond_to?(:to_hash)
551
+ stringify_hash(value.to_hash)
552
+ else
553
+ {}
554
+ end
555
+ end
556
+ rescue StandardError
557
+ {}
558
+ end
559
+
560
+ def sanitized_headers(headers)
561
+ stringify_hash(headers).each_with_object({}) do |(key, value), result|
562
+ normalized_key = key.to_s.downcase
563
+ next unless DEFAULT_HEADER_ALLOWLIST.include?(normalized_key)
564
+
565
+ result[normalized_key] = @redactor.redact_value(value)
566
+ end
567
+ end
568
+
569
+ def normalize_level(level)
570
+ candidate = level.to_s.strip.downcase.to_sym
571
+ return candidate if LOG_LEVEL_RANKS.key?(candidate)
572
+
573
+ :warning
574
+ end
575
+
576
+ def level_enabled?(level)
577
+ threshold = [normalize_level(config.log_level), policy_log_level].max_by { |entry| LOG_LEVEL_RANKS.fetch(entry) }
578
+ LOG_LEVEL_RANKS.fetch(level) >= LOG_LEVEL_RANKS.fetch(threshold)
579
+ end
580
+
581
+ def policy_log_level
582
+ case @capture_policy.capture_logs
583
+ when 'off'
584
+ :fatal
585
+ when 'error'
586
+ :error
587
+ when 'info'
588
+ :info
589
+ else
590
+ :warning
591
+ end
592
+ end
593
+
594
+ def capture_request_event?(status_code)
595
+ mode = @capture_policy.capture_request_events
596
+ immediate_statuses = immediate_request_statuses
597
+ anomaly_statuses = anomaly_request_statuses
598
+
599
+ return true if mode == 'all'
600
+ return true if immediate_statuses.include?(status_code)
601
+ return true if mode == 'failures_only' && anomaly_statuses.include?(status_code)
602
+
603
+ false
604
+ end
605
+
606
+ def immediate_request_statuses
607
+ statuses = case @capture_policy.preset
608
+ when 'minimal'
609
+ []
610
+ when 'investigative'
611
+ INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
612
+ else
613
+ BALANCED_IMMEDIATE_REQUEST_STATUSES
614
+ end
615
+
616
+ statuses + Array(@capture_policy.immediate_client_error_statuses)
617
+ end
618
+
619
+ def anomaly_request_statuses
620
+ return [] if @capture_policy.preset == 'minimal'
621
+
622
+ BALANCED_ANOMALY_REQUEST_STATUSES
623
+ end
624
+
625
+ def matching_probe_directives(label)
626
+ active_directives = @remote_config.directives + current_request_trigger_directives
627
+
628
+ active_directives.select do |directive|
629
+ directive.active?(label: label.to_s, service: service_name, environment: environment_name, now: now)
630
+ end
631
+ end
632
+
633
+ def current_request_trigger_directives
634
+ Array(Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY])
635
+ end
636
+
637
+ def matching_request_trigger_directives(label)
638
+ current_request_trigger_directives.select do |directive|
639
+ directive.active?(label: label.to_s, service: service_name, environment: environment_name, now: now)
640
+ end
641
+ end
642
+
643
+ def emit_probe_events(label, data, matching_directives)
644
+ allowed_directives = if @capture_policy.capture_probe_events == 'standalone_when_activated'
645
+ matching_directives
646
+ else
647
+ matching_request_trigger_directives(label)
648
+ end
649
+ return if allowed_directives.empty?
650
+
651
+ allowed_directives.each do |directive|
652
+ enqueue_event(
653
+ base_event(
654
+ 'probe_event',
655
+ {
656
+ 'label' => label,
657
+ 'data' => data,
658
+ 'activation_id' => directive.id,
659
+ 'probe_label_pattern' => directive.label_pattern
660
+ },
661
+ {}
662
+ )
663
+ )
664
+ end
665
+ end
666
+
667
+ def extract_duration_ms(context, response)
668
+ duration = context['duration_ms'] || response['duration_ms']
669
+ return duration.to_i if duration
670
+
671
+ 0
672
+ end
673
+
674
+ def rate_limited?
675
+ @retry_at && @retry_at > now
676
+ end
677
+
678
+ def local_environment?
679
+ LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
680
+ end
681
+
682
+ def poll_remote_config_if_due!
683
+ return unless @config_fetcher
684
+ return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
685
+
686
+ refresh_remote_config!
687
+ end
688
+
689
+ def schedule_next_remote_config_poll
690
+ interval_seconds = if @remote_config.remote_probes_enabled
691
+ @remote_config.poll_interval_seconds
692
+ elsif @remote_config_etag.nil?
693
+ config.probes_poll_interval
694
+ end
695
+
696
+ @next_remote_config_poll_at = interval_seconds ? now + interval_seconds : nil
697
+ end
698
+
699
+ def capture_thread_exceptions
700
+ return false if @thread_exception_registered
701
+
702
+ self.class.thread_exception_client = self
703
+ Thread.report_on_exception = true if Thread.respond_to?(:report_on_exception=)
704
+ self.class.install_thread_exception_hook!
705
+ @thread_exception_registered = true
706
+ true
707
+ end
708
+
709
+ def capture_thread_exception(error)
710
+ capture_exception(error, handled: false)
711
+ flush
712
+ rescue StandardError
713
+ nil
714
+ end
715
+
716
+ def now
717
+ @time_provider.call
718
+ end
719
+
720
+ def monotonic_now
721
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
722
+ end
723
+ end
724
+ end