debugbundle 1.5.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +15 -5
- data/lib/debugbundle/acknowledgement.rb +6 -2
- data/lib/debugbundle/before_send.rb +1 -1
- data/lib/debugbundle/client.rb +193 -71
- data/lib/debugbundle/client_event_support.rb +76 -43
- data/lib/debugbundle/client_process_support.rb +64 -0
- data/lib/debugbundle/client_queue_support.rb +183 -0
- data/lib/debugbundle/config_worker.rb +60 -0
- data/lib/debugbundle/delivery_worker.rb +103 -0
- data/lib/debugbundle/redaction.rb +7 -8
- data/lib/debugbundle/safe_input.rb +40 -0
- data/lib/debugbundle/suppression.rb +62 -20
- data/lib/debugbundle/telemetry_privacy.rb +15 -5
- data/lib/debugbundle/transport.rb +6 -2
- data/lib/debugbundle/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de914935eb26ac5a4a4c7a21e91e13f92fcdef0949c8a19cc9566adf70fa7b5d
|
|
4
|
+
data.tar.gz: ff2d61415de5d618556d00f4918e4d3a29af38ae7be2bddafc088304a7b11e2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7655a43d83ce82e3d7cd6febebc560a2c6c31dde18df9b3a579897fa236c1094c36bc170aa203f1a412caca0ff86c1d6bf529745e9b69e48d55c88799ba84531
|
|
7
|
+
data.tar.gz: 06b7e321fe0bae17dfe91bd4ffecd4288f84c0151a9909def3d18c02fc1e5f4e262171b7318a2bed337876a0ed7740c9d1d31d0ab518b187a05f72e0db0fcf49
|
data/README.md
CHANGED
|
@@ -4,7 +4,11 @@ Ruby SDK for DebugBundle.
|
|
|
4
4
|
|
|
5
5
|
Use this gem to capture Ruby backend exceptions, request metadata, structured logs, runtime context, probe data, and browser relay traffic. It supports a singleton facade plus instance clients for Rack, Rails, Sidekiq, and explicit Ruby instrumentation.
|
|
6
6
|
|
|
7
|
-
The source test matrix covers Ruby 3.1 through 4.0. Rails 7.x remains an installed-base lane; the current Rails 8.1 lane exercises the relay and Rack middleware on Ruby 4.0.
|
|
7
|
+
The source test matrix covers Ruby 3.1 through 4.0. Rails 7.x remains an installed-base lane; the current Rails 8.1 lane exercises the relay and Rack middleware on Ruby 4.0. `make compat` runs the Rack, Rails, and Sidekiq Docker lanes, including Rails 8.1.
|
|
8
|
+
|
|
9
|
+
Version 2.0 changes capture and hook timing. Existing installed 1.5.x applications continue to use their pinned gem until upgraded. Review [the migration guide](MIGRATION-2.0.md) before upgrading.
|
|
10
|
+
|
|
11
|
+
`make sdk-safety-perf` runs a Docker-backed host-safety budget with 10,000 filtered INFO calls, 1,000 accepted ERROR calls with and without custom privacy fields, and 10,000 concurrent ERROR calls against a full queue while transport is held. CI and the release workflow run this gate before publication. These bounded synthetic checks supplement, but do not replace, installed runtime and workload qualification.
|
|
8
12
|
|
|
9
13
|
## Automatic capture and application filtering
|
|
10
14
|
|
|
@@ -40,7 +44,7 @@ DebugBundle.capture_message("worker started", level: :info)
|
|
|
40
44
|
DebugBundle.flush
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
`DebugBundle.init(...)` arms best-effort process exception capture automatically through `at_exit` and thread exception hooks.
|
|
47
|
+
`DebugBundle.init(...)` arms best-effort process exception capture automatically through `at_exit` and thread exception hooks. These hooks wake the sender without waiting for delivery; shutdown exceptions can be lost if the process exits before the sender finishes. Automatic delivery runs on one background sender thread when the configured batch size or flush interval is reached. Remote configuration uses a separate bounded poller so a slow fetch cannot hold delivery. An explicit `flush` waits for the sender for at most five seconds and reports whether delivery completed. Call it during a controlled shutdown window when delivery is required. A forked child starts fresh workers on its first SDK use and drops inherited parent events, context, and probes while retaining the parent's capture restrictions until remote configuration refreshes.
|
|
44
48
|
|
|
45
49
|
## Framework Integrations
|
|
46
50
|
|
|
@@ -75,7 +79,7 @@ Capture-policy fields are server-owned and must not be supplied in local SDK con
|
|
|
75
79
|
| `local_events_dir` | `.debugbundle/local/events` | Local event file destination. |
|
|
76
80
|
| `spool_dir` | `.debugbundle/local/browser-relay-spool` | Relay durable spool destination. |
|
|
77
81
|
| `redact_fields` | `[]` | Additional sensitive field names merged with built-in redaction defaults. Rails `filter_parameters` are added automatically. |
|
|
78
|
-
| `batch_size` | `25` |
|
|
82
|
+
| `batch_size` | `25` | Wake the background sender when this many events are buffered. |
|
|
79
83
|
| `flush_interval` | `5` | Flush interval in seconds. |
|
|
80
84
|
| `sample_rate` | `1.0` | Fraction of events kept before transport. |
|
|
81
85
|
| `log_level` | `warning` | Minimum captured log severity. |
|
|
@@ -323,7 +327,7 @@ make smoke
|
|
|
323
327
|
make smoke-published VERSION=1.5.0
|
|
324
328
|
```
|
|
325
329
|
|
|
326
|
-
`make smoke` builds the gem, installs it into a fresh RubyGems home, drives a Rack request plus a browser relay batch through the public SDK surface, validates event envelope shape, and confirms the mock ingestion endpoint receives the expected service, environment, SDK metadata, and correlation fields.
|
|
330
|
+
`make smoke` builds the gem, installs it into a fresh RubyGems home, drives a Rack request plus a browser relay batch through the public SDK surface, validates event envelope shape, and confirms the mock ingestion endpoint receives the expected service, environment, SDK metadata, and correlation fields. For version 2.0, it also forks the installed client and verifies exactly one child event without replaying the parent's buffered event.
|
|
327
331
|
|
|
328
332
|
## Examples
|
|
329
333
|
|
|
@@ -352,7 +356,7 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
|
|
|
352
356
|
|
|
353
357
|
- Push a `v*` tag or run the workflow manually with a `version` input.
|
|
354
358
|
- Configure the `RUBYGEMS_API_KEY` repository secret before enabling publish.
|
|
355
|
-
- The workflow runs lint, tests, gem build, `make smoke`, RubyGems publish, and `make smoke-published VERSION=<tag>` before creating the GitHub release.
|
|
359
|
+
- The workflow runs lint, tests, the full `make compat` framework/runtime matrix, gem build, `make smoke`, RubyGems publish, and `make smoke-published VERSION=<tag>` before creating the GitHub release.
|
|
356
360
|
|
|
357
361
|
## Documentation
|
|
358
362
|
|
|
@@ -364,3 +368,9 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
|
|
|
364
368
|
## License
|
|
365
369
|
|
|
366
370
|
Apache-2.0. See LICENSE.
|
|
371
|
+
|
|
372
|
+
## Delivery acknowledgement and retry limits
|
|
373
|
+
|
|
374
|
+
The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
|
|
375
|
+
|
|
376
|
+
File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
|
|
@@ -11,8 +11,8 @@ module DebugBundle
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
def decide(body, batch_length)
|
|
15
|
-
return
|
|
14
|
+
def decide(body, batch_length, required: false)
|
|
15
|
+
return missing_acknowledgement(required) unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
|
|
16
16
|
return protocol_failure unless FIELDS.all? { |field| body.key?(field) }
|
|
17
17
|
|
|
18
18
|
accepted = body['accepted']
|
|
@@ -55,5 +55,9 @@ module DebugBundle
|
|
|
55
55
|
def protocol_failure
|
|
56
56
|
{ kind: :protocol_failure }
|
|
57
57
|
end
|
|
58
|
+
|
|
59
|
+
def missing_acknowledgement(required)
|
|
60
|
+
required ? protocol_failure : { kind: :legacy }
|
|
61
|
+
end
|
|
58
62
|
end
|
|
59
63
|
end
|
|
@@ -27,7 +27,7 @@ module DebugBundle
|
|
|
27
27
|
rejection_reason dom_context probe_data
|
|
28
28
|
],
|
|
29
29
|
'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
|
|
30
|
-
'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen device],
|
|
30
|
+
'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen reason level device],
|
|
31
31
|
'probe_event' => %w[label data activation_id probe_label_pattern device]
|
|
32
32
|
}.freeze
|
|
33
33
|
ROOT_FIELDS = %w[
|
data/lib/debugbundle/client.rb
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'set'
|
|
4
6
|
require 'time'
|
|
5
|
-
require 'uri'
|
|
6
7
|
|
|
7
8
|
require 'debugbundle/client_event_support'
|
|
9
|
+
require 'debugbundle/client_process_support'
|
|
10
|
+
require 'debugbundle/client_queue_support'
|
|
11
|
+
require 'debugbundle/config_worker'
|
|
12
|
+
require 'debugbundle/delivery_worker'
|
|
8
13
|
require 'debugbundle/runtime'
|
|
9
14
|
|
|
10
15
|
module DebugBundle
|
|
11
16
|
class Client
|
|
17
|
+
include QueueSupport
|
|
18
|
+
include ProcessSupport
|
|
19
|
+
|
|
12
20
|
SCHEMA_VERSION = '2026-03-01'
|
|
13
21
|
SDK_NAME = '@debugbundle/sdk-ruby'
|
|
14
22
|
DEFAULT_SERVICE_NAME = 'ruby-service'
|
|
15
23
|
DEFAULT_ENVIRONMENT = 'development'
|
|
16
24
|
MAX_BUFFER_SIZE = 1_000
|
|
25
|
+
MAX_BUFFER_BYTES = 8 * 1_024 * 1_024
|
|
17
26
|
RETRY_AFTER_CAP_SECONDS = 300
|
|
18
27
|
DEFAULT_HEADER_ALLOWLIST = %w[
|
|
19
28
|
user-agent
|
|
@@ -80,6 +89,7 @@ module DebugBundle
|
|
|
80
89
|
end
|
|
81
90
|
|
|
82
91
|
def initialize(transport: nil, time_provider: nil, random_provider: nil, config_fetcher: nil, **options)
|
|
92
|
+
@owner_pid = Process.pid
|
|
83
93
|
@config = Config.new(**options)
|
|
84
94
|
@time_provider = time_provider || -> { Time.now.utc }
|
|
85
95
|
@random_provider = random_provider || -> { rand }
|
|
@@ -90,6 +100,14 @@ module DebugBundle
|
|
|
90
100
|
@config_fetcher = config_fetcher || build_default_config_fetcher(custom_transport: !transport.nil?)
|
|
91
101
|
@context = {}
|
|
92
102
|
@buffer = []
|
|
103
|
+
@buffer_bytes = 0
|
|
104
|
+
@buffer_priority_counts = [0, 0, 0, 0]
|
|
105
|
+
@inflight_event_ids = Set.new
|
|
106
|
+
@inflight_priority_counts = [0, 0, 0, 0]
|
|
107
|
+
@event_sizes = {}
|
|
108
|
+
@pressure_drops = {}
|
|
109
|
+
@hook_bypass = Set.new
|
|
110
|
+
@finalized_events = {}
|
|
93
111
|
@buffer_mutex = Mutex.new
|
|
94
112
|
@flush_mutex = Mutex.new
|
|
95
113
|
@probe_buffers = {}
|
|
@@ -105,10 +123,22 @@ module DebugBundle
|
|
|
105
123
|
@next_remote_config_poll_at = nil
|
|
106
124
|
@remote_config_etag = nil
|
|
107
125
|
@remote_config = RemoteConfig::Snapshot.default
|
|
108
|
-
@capture_policy = @remote_config.capture_policy
|
|
126
|
+
@capture_policy = @config_fetcher ? RemoteConfig.minimal_capture_policy : @remote_config.capture_policy
|
|
127
|
+
@initial_remote_config_pending = !@config_fetcher.nil?
|
|
128
|
+
@config_worker = nil
|
|
129
|
+
return unless capture_enabled?
|
|
109
130
|
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
@delivery_worker = DeliveryWorker.new(
|
|
132
|
+
interval: config.flush_interval,
|
|
133
|
+
before_work: -> {},
|
|
134
|
+
&method(:flush_now)
|
|
135
|
+
)
|
|
136
|
+
return unless @config_fetcher
|
|
137
|
+
|
|
138
|
+
@config_worker = ConfigWorker.new(
|
|
139
|
+
next_wait_seconds: method(:next_remote_config_wait_seconds),
|
|
140
|
+
&method(:refresh_remote_config_on_worker)
|
|
141
|
+
)
|
|
112
142
|
end
|
|
113
143
|
|
|
114
144
|
def capture_exception(error, context: nil, handled: true)
|
|
@@ -117,14 +147,15 @@ module DebugBundle
|
|
|
117
147
|
|
|
118
148
|
def capture_exception_internal(error, context:, handled:, run_before_send:)
|
|
119
149
|
return unless capture_enabled?
|
|
150
|
+
return unless preflight_capacity?(3, 'exception')
|
|
120
151
|
|
|
121
|
-
|
|
152
|
+
request_remote_config_poll_if_due
|
|
122
153
|
|
|
123
154
|
merged_context = merge_context(context)
|
|
124
155
|
payload = {
|
|
125
|
-
'name' => error
|
|
126
|
-
'message' => error
|
|
127
|
-
'stack' =>
|
|
156
|
+
'name' => safe_exception_name(error),
|
|
157
|
+
'message' => safe_exception_message(error),
|
|
158
|
+
'stack' => safe_exception_stack(error),
|
|
128
159
|
'handled' => handled,
|
|
129
160
|
'request' => request_payload(merged_context['request']),
|
|
130
161
|
'response' => response_payload(merged_context['response']),
|
|
@@ -140,8 +171,6 @@ module DebugBundle
|
|
|
140
171
|
extra_context['causes'] = causes unless causes.empty?
|
|
141
172
|
|
|
142
173
|
event = base_event('backend_exception', payload, extra_context)
|
|
143
|
-
event = apply_before_send(event) if run_before_send
|
|
144
|
-
return if event.nil?
|
|
145
174
|
|
|
146
175
|
event_payload = event.fetch('payload')
|
|
147
176
|
suppression_key = [
|
|
@@ -152,7 +181,7 @@ module DebugBundle
|
|
|
152
181
|
].join(':')
|
|
153
182
|
return unless @suppression.should_capture(suppression_key, now: monotonic_now)
|
|
154
183
|
|
|
155
|
-
enqueue_event(event)
|
|
184
|
+
enqueue_event(event, skip_before_send: !run_before_send)
|
|
156
185
|
end
|
|
157
186
|
private :capture_exception_internal
|
|
158
187
|
|
|
@@ -161,26 +190,39 @@ module DebugBundle
|
|
|
161
190
|
def capture_log(message, level: :warning, context: nil)
|
|
162
191
|
return unless capture_enabled?
|
|
163
192
|
|
|
164
|
-
poll_remote_config_if_due!
|
|
165
|
-
|
|
166
193
|
normalized_level = normalize_level(level || :warning)
|
|
194
|
+
return unless level_enabled?(normalized_level)
|
|
195
|
+
|
|
196
|
+
priority = LOG_LEVEL_RANKS.fetch(normalized_level) >= LOG_LEVEL_RANKS.fetch(:error) ? 2 : 0
|
|
197
|
+
return unless preflight_capacity?(priority, normalized_level.to_s)
|
|
198
|
+
|
|
199
|
+
request_remote_config_poll_if_due
|
|
167
200
|
|
|
168
201
|
merged_context = merge_context(context)
|
|
169
202
|
payload = {
|
|
170
203
|
'level' => normalized_level.to_s,
|
|
171
|
-
'message' => message
|
|
204
|
+
'message' => safe_log_message(message),
|
|
172
205
|
'attributes' => merged_context
|
|
173
206
|
}
|
|
174
|
-
|
|
175
|
-
return if event.nil? || !level_enabled?(normalized_level)
|
|
176
|
-
|
|
177
|
-
enqueue_event(event)
|
|
207
|
+
enqueue_event(base_event('log_event', payload, merged_context))
|
|
178
208
|
end
|
|
179
209
|
|
|
180
210
|
def capture_request(request, response, context: nil)
|
|
181
211
|
return unless capture_enabled?
|
|
182
212
|
|
|
183
|
-
|
|
213
|
+
status = if response.is_a?(Hash)
|
|
214
|
+
response[:status_code] || response['status_code'] || response[:status] || response['status']
|
|
215
|
+
end
|
|
216
|
+
priority = if status.is_a?(Integer)
|
|
217
|
+
status >= 400 ? 2 : 1
|
|
218
|
+
elsif response.nil?
|
|
219
|
+
1
|
|
220
|
+
else
|
|
221
|
+
3
|
|
222
|
+
end
|
|
223
|
+
return unless preflight_capacity?(priority, 'request')
|
|
224
|
+
|
|
225
|
+
request_remote_config_poll_if_due
|
|
184
226
|
|
|
185
227
|
merged_context = merge_context(context)
|
|
186
228
|
sanitized_request = request_payload(request)
|
|
@@ -199,12 +241,9 @@ module DebugBundle
|
|
|
199
241
|
'response_headers' => sanitized_response['headers'],
|
|
200
242
|
'response_body' => sanitized_response['body']
|
|
201
243
|
}
|
|
202
|
-
|
|
203
|
-
base_event('request_event', payload, merged_context.merge('request' => sanitized_request))
|
|
204
|
-
)
|
|
205
|
-
return if event.nil? || !capture_request_event?(response_status, sanitized_request)
|
|
244
|
+
return unless capture_request_event?(response_status, sanitized_request)
|
|
206
245
|
|
|
207
|
-
enqueue_event(
|
|
246
|
+
enqueue_event(base_event('request_event', payload, merged_context.merge('request' => sanitized_request)))
|
|
208
247
|
end
|
|
209
248
|
|
|
210
249
|
def capture_message(message, level: nil, context: nil)
|
|
@@ -212,10 +251,14 @@ module DebugBundle
|
|
|
212
251
|
end
|
|
213
252
|
|
|
214
253
|
def set_context(key, value)
|
|
254
|
+
ensure_current_process!
|
|
255
|
+
safe_key = SafeInput.key(key)
|
|
256
|
+
return value unless safe_key
|
|
257
|
+
|
|
215
258
|
safe = TelemetryPrivacy.protect(
|
|
216
|
-
{
|
|
259
|
+
{ safe_key => @redactor.redact_value(value) }, additional_fields: config.redact_fields
|
|
217
260
|
)
|
|
218
|
-
@context[
|
|
261
|
+
@context[safe_key] = safe[safe_key] if safe.key?(safe_key)
|
|
219
262
|
value
|
|
220
263
|
rescue StandardError
|
|
221
264
|
value
|
|
@@ -224,7 +267,7 @@ module DebugBundle
|
|
|
224
267
|
def probe(label, data = nil, heavy: false, &block)
|
|
225
268
|
return unless capture_enabled?
|
|
226
269
|
|
|
227
|
-
|
|
270
|
+
request_remote_config_poll_if_due
|
|
228
271
|
return unless @remote_config.probes_enabled
|
|
229
272
|
|
|
230
273
|
matching_directives = matching_probe_directives(label)
|
|
@@ -282,7 +325,9 @@ module DebugBundle
|
|
|
282
325
|
handled: false,
|
|
283
326
|
run_before_send: false
|
|
284
327
|
)
|
|
285
|
-
client.
|
|
328
|
+
client.__send__(:wake_sender)
|
|
329
|
+
rescue StandardError
|
|
330
|
+
nil
|
|
286
331
|
end
|
|
287
332
|
true
|
|
288
333
|
end
|
|
@@ -300,7 +345,7 @@ module DebugBundle
|
|
|
300
345
|
end
|
|
301
346
|
|
|
302
347
|
def with_request_trigger(request)
|
|
303
|
-
|
|
348
|
+
request_remote_config_poll_if_due if capture_enabled?
|
|
304
349
|
|
|
305
350
|
directives = TriggerToken.resolve_request_directives(
|
|
306
351
|
request: request,
|
|
@@ -352,29 +397,56 @@ module DebugBundle
|
|
|
352
397
|
end
|
|
353
398
|
|
|
354
399
|
def flush
|
|
400
|
+
ensure_current_process!
|
|
401
|
+
@delivery_worker&.flush || false
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def close
|
|
405
|
+
ensure_current_process!
|
|
406
|
+
@delivery_worker&.close
|
|
407
|
+
@config_worker&.close
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def flush_now
|
|
355
411
|
# rubocop:disable Metrics/BlockLength
|
|
356
412
|
@flush_mutex.synchronize do
|
|
357
413
|
append_suppression_aggregates
|
|
358
|
-
|
|
359
|
-
|
|
414
|
+
append_pressure_aggregates
|
|
415
|
+
candidates = reserve_buffered_batch
|
|
416
|
+
return true if candidates.empty?
|
|
360
417
|
return false if @transport.nil?
|
|
361
418
|
return false if rate_limited?
|
|
362
419
|
|
|
420
|
+
# Replace consumed snapshot slots so dropped events do not remain owned
|
|
421
|
+
# by this batch while later callbacks run or transport waits.
|
|
422
|
+
prepared = candidates.map! do |event|
|
|
423
|
+
finalized = finalized_event_for(event)
|
|
424
|
+
if finalized.nil?
|
|
425
|
+
remove_buffered_events([event])
|
|
426
|
+
next
|
|
427
|
+
end
|
|
428
|
+
[event, finalized]
|
|
429
|
+
end.compact
|
|
430
|
+
return true if prepared.empty?
|
|
431
|
+
|
|
432
|
+
batch = prepared.map(&:first)
|
|
433
|
+
wire_events = prepared.map(&:last)
|
|
434
|
+
|
|
363
435
|
result = Transport.coerce_result(
|
|
364
436
|
@transport.call(
|
|
365
437
|
project_token: config.project_token,
|
|
366
438
|
service_name: service_name,
|
|
367
|
-
events:
|
|
439
|
+
events: wire_events.map(&:dup)
|
|
368
440
|
)
|
|
369
441
|
)
|
|
370
442
|
|
|
371
443
|
case result.status_code
|
|
372
444
|
when 200..299
|
|
373
445
|
handle_successful_result(result, batch)
|
|
374
|
-
when 429
|
|
446
|
+
when 429, 500..599
|
|
375
447
|
@consecutive_failures += 1
|
|
376
|
-
retry_after_seconds = (result.retry_after_seconds
|
|
377
|
-
@retry_at = now + retry_after_seconds
|
|
448
|
+
retry_after_seconds = bounded_retry_hint(result.retry_after_seconds)
|
|
449
|
+
@retry_at = now + retry_after_seconds if result.status_code == 429 || !result.retry_after_seconds.nil?
|
|
378
450
|
false
|
|
379
451
|
when 400..499
|
|
380
452
|
remove_buffered_events(batch)
|
|
@@ -390,9 +462,43 @@ module DebugBundle
|
|
|
390
462
|
rescue StandardError
|
|
391
463
|
@consecutive_failures += 1
|
|
392
464
|
false
|
|
465
|
+
ensure
|
|
466
|
+
release_buffered_batch
|
|
467
|
+
end
|
|
468
|
+
private :flush_now
|
|
469
|
+
|
|
470
|
+
def finalized_event_for(event)
|
|
471
|
+
event_id = event['event_id']
|
|
472
|
+
cached, bypass = @buffer_mutex.synchronize do
|
|
473
|
+
[@finalized_events[event_id], @hook_bypass.include?(event_id)]
|
|
474
|
+
end
|
|
475
|
+
return cached if cached
|
|
476
|
+
|
|
477
|
+
finalized = bypass || !config.before_send ? event : apply_before_send(event)
|
|
478
|
+
return nil if finalized.nil?
|
|
479
|
+
return nil unless post_hook_event_allowed?(finalized)
|
|
480
|
+
|
|
481
|
+
cache_finalized_event(event, finalized)
|
|
393
482
|
end
|
|
483
|
+
private :finalized_event_for
|
|
484
|
+
|
|
485
|
+
def post_hook_event_allowed?(event)
|
|
486
|
+
case event['event_type']
|
|
487
|
+
when 'log_event'
|
|
488
|
+
return false if @capture_policy.capture_logs == 'off'
|
|
489
|
+
|
|
490
|
+
level_enabled?(normalize_level(event.dig('payload', 'level')))
|
|
491
|
+
when 'request_event'
|
|
492
|
+
payload = event['payload']
|
|
493
|
+
capture_request_event?(payload['response_status'].to_i, payload)
|
|
494
|
+
else
|
|
495
|
+
true
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
private :post_hook_event_allowed?
|
|
394
499
|
|
|
395
500
|
def status
|
|
501
|
+
ensure_current_process!
|
|
396
502
|
return :disconnected unless config.enabled?
|
|
397
503
|
return :degraded unless config.configured?
|
|
398
504
|
return @acknowledgement_state if @acknowledgement_state
|
|
@@ -402,13 +508,22 @@ module DebugBundle
|
|
|
402
508
|
:healthy
|
|
403
509
|
end
|
|
404
510
|
|
|
405
|
-
def buffered_event_count
|
|
511
|
+
def buffered_event_count
|
|
512
|
+
ensure_current_process!
|
|
513
|
+
@buffer_mutex.synchronize { @buffer.length }
|
|
514
|
+
end
|
|
406
515
|
|
|
407
516
|
private
|
|
408
517
|
|
|
518
|
+
def bounded_retry_hint(value)
|
|
519
|
+
return 1 unless value.is_a?(Numeric) && value.real? && value.finite?
|
|
520
|
+
|
|
521
|
+
value.clamp(1, RETRY_AFTER_CAP_SECONDS)
|
|
522
|
+
end
|
|
523
|
+
|
|
409
524
|
def handle_successful_result(result, batch)
|
|
410
|
-
decision = Acknowledgement.decide(result.body, batch.length)
|
|
411
|
-
return handle_protocol_failure if decision[:kind] == :protocol_failure
|
|
525
|
+
decision = Acknowledgement.decide(result.body, batch.length, required: @transport.is_a?(Transport::HttpTransport))
|
|
526
|
+
return handle_protocol_failure(result.retry_after_seconds) if decision[:kind] == :protocol_failure
|
|
412
527
|
|
|
413
528
|
if decision[:kind] == :legacy
|
|
414
529
|
remove_buffered_events(batch)
|
|
@@ -423,7 +538,7 @@ module DebugBundle
|
|
|
423
538
|
|
|
424
539
|
if retryable_events.any?
|
|
425
540
|
@consecutive_failures += 1
|
|
426
|
-
@retry_at = now +
|
|
541
|
+
@retry_at = now + bounded_retry_hint(result.retry_after_seconds)
|
|
427
542
|
@acknowledgement_state = :degraded
|
|
428
543
|
false
|
|
429
544
|
else
|
|
@@ -434,9 +549,9 @@ module DebugBundle
|
|
|
434
549
|
end
|
|
435
550
|
end
|
|
436
551
|
|
|
437
|
-
def handle_protocol_failure
|
|
552
|
+
def handle_protocol_failure(retry_hint)
|
|
438
553
|
@consecutive_failures += 1
|
|
439
|
-
@retry_at = now +
|
|
554
|
+
@retry_at = now + bounded_retry_hint(retry_hint)
|
|
440
555
|
@acknowledgement_state = :degraded
|
|
441
556
|
false
|
|
442
557
|
end
|
|
@@ -505,8 +620,8 @@ module DebugBundle
|
|
|
505
620
|
def matching_immediate_client_error_path_rule?(status_code, request)
|
|
506
621
|
return false unless (400..499).cover?(status_code)
|
|
507
622
|
|
|
508
|
-
path =
|
|
509
|
-
method = request['method']
|
|
623
|
+
path = SafeInput.request_path(request['path'] || request['url'])
|
|
624
|
+
method = SafeInput.key(request['method'])&.upcase || ''
|
|
510
625
|
Array(@capture_policy.immediate_client_error_path_rules).any? do |rule|
|
|
511
626
|
next false unless rule.status_code == status_code
|
|
512
627
|
next false if !rule.http_methods.empty? && !rule.http_methods.include?(method)
|
|
@@ -519,19 +634,6 @@ module DebugBundle
|
|
|
519
634
|
end
|
|
520
635
|
end
|
|
521
636
|
|
|
522
|
-
def normalize_request_path(value)
|
|
523
|
-
begin
|
|
524
|
-
uri = URI.parse(value.to_s)
|
|
525
|
-
return uri.path if uri.path && !uri.path.empty?
|
|
526
|
-
rescue URI::InvalidURIError
|
|
527
|
-
# Fall through to the lightweight path-only fallback.
|
|
528
|
-
end
|
|
529
|
-
fallback = value.to_s.split('?', 2).first.to_s.split('#', 2).first
|
|
530
|
-
return fallback if fallback.start_with?('/') && !fallback.empty?
|
|
531
|
-
|
|
532
|
-
'/'
|
|
533
|
-
end
|
|
534
|
-
|
|
535
637
|
def matching_probe_directives(label)
|
|
536
638
|
active_directives = @remote_config.directives + current_request_trigger_directives
|
|
537
639
|
|
|
@@ -554,21 +656,16 @@ module DebugBundle
|
|
|
554
656
|
return if candidate_directives.empty?
|
|
555
657
|
|
|
556
658
|
candidate_directives.each do |directive|
|
|
557
|
-
event = apply_before_send(
|
|
558
|
-
base_event(
|
|
559
|
-
'probe_event',
|
|
560
|
-
{
|
|
561
|
-
'label' => label,
|
|
562
|
-
'data' => data,
|
|
563
|
-
'activation_id' => directive.id,
|
|
564
|
-
'probe_label_pattern' => directive.label_pattern
|
|
565
|
-
},
|
|
566
|
-
{}
|
|
567
|
-
)
|
|
568
|
-
)
|
|
569
659
|
allowed = request_directives.include?(directive) ||
|
|
570
660
|
@capture_policy.capture_probe_events == 'standalone_when_activated'
|
|
571
|
-
|
|
661
|
+
next unless allowed
|
|
662
|
+
|
|
663
|
+
enqueue_event(base_event('probe_event', {
|
|
664
|
+
'label' => label,
|
|
665
|
+
'data' => data,
|
|
666
|
+
'activation_id' => directive.id,
|
|
667
|
+
'probe_label_pattern' => directive.label_pattern
|
|
668
|
+
}, {}))
|
|
572
669
|
end
|
|
573
670
|
end
|
|
574
671
|
|
|
@@ -583,7 +680,22 @@ module DebugBundle
|
|
|
583
680
|
|
|
584
681
|
def local_environment? = LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
|
|
585
682
|
|
|
586
|
-
def
|
|
683
|
+
def request_remote_config_poll_if_due
|
|
684
|
+
return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
|
|
685
|
+
|
|
686
|
+
@config_worker&.wake
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def refresh_remote_config_on_worker
|
|
690
|
+
if @initial_remote_config_pending
|
|
691
|
+
@initial_remote_config_pending = false
|
|
692
|
+
refresh_remote_config!
|
|
693
|
+
else
|
|
694
|
+
refresh_remote_config_if_due!
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def refresh_remote_config_if_due!
|
|
587
699
|
return unless @config_fetcher
|
|
588
700
|
return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
|
|
589
701
|
|
|
@@ -600,6 +712,12 @@ module DebugBundle
|
|
|
600
712
|
@next_remote_config_poll_at = interval_seconds ? now + interval_seconds : nil
|
|
601
713
|
end
|
|
602
714
|
|
|
715
|
+
def next_remote_config_wait_seconds
|
|
716
|
+
return nil unless @next_remote_config_poll_at
|
|
717
|
+
|
|
718
|
+
[@next_remote_config_poll_at - now, 0].max
|
|
719
|
+
end
|
|
720
|
+
|
|
603
721
|
def capture_thread_exceptions
|
|
604
722
|
return false if @thread_exception_registered
|
|
605
723
|
|
|
@@ -612,11 +730,15 @@ module DebugBundle
|
|
|
612
730
|
|
|
613
731
|
def capture_thread_exception(error)
|
|
614
732
|
capture_exception_internal(error, context: nil, handled: false, run_before_send: false)
|
|
615
|
-
|
|
733
|
+
wake_sender
|
|
616
734
|
rescue StandardError
|
|
617
735
|
nil
|
|
618
736
|
end
|
|
619
737
|
|
|
738
|
+
def wake_sender
|
|
739
|
+
@delivery_worker&.wake
|
|
740
|
+
end
|
|
741
|
+
|
|
620
742
|
def now = @time_provider.call
|
|
621
743
|
|
|
622
744
|
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|