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.
- checksums.yaml +7 -0
- data/Gemfile +17 -0
- data/Makefile +43 -0
- data/README.md +168 -0
- data/debugbundle.gemspec +30 -0
- data/lib/debugbundle/client.rb +724 -0
- data/lib/debugbundle/config.rb +144 -0
- data/lib/debugbundle/logging.rb +77 -0
- data/lib/debugbundle/rack/middleware.rb +94 -0
- data/lib/debugbundle/rack/relay_middleware.rb +37 -0
- data/lib/debugbundle/rails/railtie.rb +35 -0
- data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
- data/lib/debugbundle/rails.rb +10 -0
- data/lib/debugbundle/redaction.rb +151 -0
- data/lib/debugbundle/relay/handler.rb +231 -0
- data/lib/debugbundle/relay.rb +4 -0
- data/lib/debugbundle/remote_config.rb +153 -0
- data/lib/debugbundle/runtime.rb +22 -0
- data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
- data/lib/debugbundle/suppression.rb +121 -0
- data/lib/debugbundle/transport.rb +190 -0
- data/lib/debugbundle/trigger_token.rb +122 -0
- data/lib/debugbundle/version.rb +5 -0
- data/lib/debugbundle.rb +93 -0
- data/spec/client_spec.rb +236 -0
- data/spec/debugbundle_spec.rb +54 -0
- data/spec/file_transport_spec.rb +54 -0
- data/spec/logger_integration_spec.rb +118 -0
- data/spec/rack_integration_spec.rb +44 -0
- data/spec/rack_middleware_spec.rb +206 -0
- data/spec/rails_railtie_spec.rb +96 -0
- data/spec/rails_relay_spec.rb +121 -0
- data/spec/redaction_spec.rb +42 -0
- data/spec/relay_spec.rb +178 -0
- data/spec/remote_config_spec.rb +402 -0
- data/spec/sidekiq_integration_spec.rb +66 -0
- data/spec/sidekiq_middleware_spec.rb +50 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/suppression_spec.rb +16 -0
- 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
|