debugbundle 2.0.1 → 2.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 +4 -4
- data/README.md +127 -0
- data/lib/debugbundle/before_send.rb +58 -30
- data/lib/debugbundle/bounded_http_response.rb +24 -0
- data/lib/debugbundle/canonical_event_schema.rb +137 -0
- data/lib/debugbundle/client.rb +41 -67
- data/lib/debugbundle/client_event_support.rb +11 -12
- data/lib/debugbundle/client_evidence_support.rb +169 -0
- data/lib/debugbundle/client_exception_support.rb +54 -0
- data/lib/debugbundle/client_process_support.rb +5 -12
- data/lib/debugbundle/client_queue_support.rb +59 -4
- data/lib/debugbundle/config.rb +15 -2
- data/lib/debugbundle/diagnostic_budget.rb +155 -0
- data/lib/debugbundle/event-envelope.schema.json +4904 -0
- data/lib/debugbundle/event_batch.rb +48 -0
- data/lib/debugbundle/evidence_delivery.rb +50 -0
- data/lib/debugbundle/evidence_graph.rb +73 -0
- data/lib/debugbundle/evidence_json.rb +55 -0
- data/lib/debugbundle/evidence_resources.rb +60 -0
- data/lib/debugbundle/evidence_value.rb +107 -0
- data/lib/debugbundle/evidence_wire.rb +193 -0
- data/lib/debugbundle/exception_evidence.rb +179 -0
- data/lib/debugbundle/framework_operation.rb +13 -0
- data/lib/debugbundle/operation_context.rb +90 -0
- data/lib/debugbundle/rack/middleware.rb +24 -21
- data/lib/debugbundle/rack/relay_middleware.rb +4 -1
- data/lib/debugbundle/rails/railtie.rb +4 -1
- data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
- data/lib/debugbundle/relay/forwarder.rb +83 -0
- data/lib/debugbundle/relay/handler.rb +83 -19
- data/lib/debugbundle/remote_config.rb +5 -0
- data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
- data/lib/debugbundle/telemetry_privacy.rb +9 -1
- data/lib/debugbundle/transport.rb +43 -17
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +11 -1
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa08dbcf220cb84131821cf546272d4d08d6f15f01ba4e8c7f41f77fbc53a1c0
|
|
4
|
+
data.tar.gz: fe1a46ab795df53b5dd1ac5bee6684b43ea35501d031a345b52c576e9a8881f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69e7d24b4c4615dc8ff2da958c90427c0a19d9f781629ff31f9e2918912e97f4298d6a44bba5fd2f19e56cef5fcb426796ed26e7f71b73eefb6384eaa26e0ca7
|
|
7
|
+
data.tar.gz: 190296bc690b90b3113a59b99c7062ed5f3f95705bb633b4998577d884743f5f64570a727ca81334fb45fe7c3d02f13be4d38cda714244cca79998ad94a2887d
|
data/README.md
CHANGED
|
@@ -10,6 +10,114 @@ Version 2.0 changes capture and hook timing. Existing installed 1.5.x applicatio
|
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
|
13
|
+
`make evidence-performance` compares the same exception/recovery calls with
|
|
14
|
+
evidence disabled and enabled, including a full warning queue. It reports capture
|
|
15
|
+
time, allocated Ruby objects and retained bytes, and verifies original-error
|
|
16
|
+
priority and the existing queue/diagnostic ceilings. Setup and cleanup are excluded;
|
|
17
|
+
the existing `sdk-safety-perf` timing limits remain unchanged.
|
|
18
|
+
|
|
19
|
+
## Unreleased evidence candidate
|
|
20
|
+
|
|
21
|
+
The working candidate adds structured exception evidence and explicit recovery;
|
|
22
|
+
it is not a published release. Enrichment requires a valid capability descriptor
|
|
23
|
+
from the destination on an existing config/ingestion response. No extra discovery
|
|
24
|
+
request is made. Unknown paths keep ordinary exception capture. The optional
|
|
25
|
+
`evidence_capabilities: { version: 1, features: [...] }` pin asserts that every
|
|
26
|
+
receiver, relay, spool/local processor and reader on that path has been qualified;
|
|
27
|
+
it does not upgrade any receiver. `capture_exception_evidence: false` disables
|
|
28
|
+
the new exception fields independently.
|
|
29
|
+
|
|
30
|
+
`capture_recovery_observations` defaults to `false`. With destination proof and
|
|
31
|
+
the option enabled, report application-observed outcomes inside an operation:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "securerandom"
|
|
35
|
+
|
|
36
|
+
client = DebugBundle::Client.new(
|
|
37
|
+
project_token: ENV.fetch("DEBUGBUNDLE_TOKEN"),
|
|
38
|
+
capture_recovery_observations: true
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
client.with_operation_context(kind: "job", name: "asset.prepare", limit_ms: 5000) do
|
|
42
|
+
begin
|
|
43
|
+
prepare_asset
|
|
44
|
+
rescue => error
|
|
45
|
+
failure_ref = SecureRandom.uuid
|
|
46
|
+
attempt_id = SecureRandom.uuid
|
|
47
|
+
client.capture_exception(error, context: { failure_ref: failure_ref })
|
|
48
|
+
client.capture_recovery(failure_ref: failure_ref, attempt_id: attempt_id, state: "attempt_started")
|
|
49
|
+
result = application_recovery
|
|
50
|
+
client.capture_recovery(failure_ref: failure_ref, attempt_id: attempt_id, state: "attempt_completed")
|
|
51
|
+
client.capture_recovery(
|
|
52
|
+
failure_ref: failure_ref, attempt_id: attempt_id,
|
|
53
|
+
state: result.usable? ? "recovery_observed" : "failure_persisted"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The application owns recovery and its success check. `attempt_completed` and a
|
|
60
|
+
successful HTTP status do not establish recovery. `attempt_failed` records a
|
|
61
|
+
failed attempt. Observations need no subsequent exception and do not open or
|
|
62
|
+
resolve incidents. Use fresh UUID-v4 failure, attempt and scope IDs. For reporting
|
|
63
|
+
outside the active operation, provide the same explicit `evidence_scope` at
|
|
64
|
+
failure capture and in each observation. Page scopes additionally require the
|
|
65
|
+
same nonblank session correlation; the string `"0"` is valid. Recovery accepts a
|
|
66
|
+
Hash or keyword fields; scope/context Hash keys may be strings or symbols.
|
|
67
|
+
|
|
68
|
+
Operations retain only bounded kind/name/elapsed/known-limit and correlation
|
|
69
|
+
metadata. They preserve the block's return value and exceptions. Rack/Rails
|
|
70
|
+
request and Sidekiq job capture runs inside its owned scope; other Fibers/threads
|
|
71
|
+
do not inherit it. Completed scopes and process resets revoke ownership. Graphs
|
|
72
|
+
use retained scalar exception snapshots, with no additional custom rendering or
|
|
73
|
+
source/local-variable inspection. Ruby's linear cause path has at most five
|
|
74
|
+
nodes and 32 frames per node, within a combined 32 KiB evidence budget. Fatal
|
|
75
|
+
hooks use at most two nodes, eight frames per node and 4 KiB of total new evidence;
|
|
76
|
+
delivery remains best-effort at process exit. Original sanitised stacks remain.
|
|
77
|
+
Engine metadata is preserved under `payload.runtime.framework_extras`.
|
|
78
|
+
|
|
79
|
+
Diagnostic metadata and pending events share 64 KiB, at most 50 failure windows,
|
|
80
|
+
five attempts and 20 observations per window, and a five-minute capture window.
|
|
81
|
+
Policy `capture_diagnostic_context: false` and the minimal preset disable recovery.
|
|
82
|
+
Canonical `before_send` replacements may change identity and event type; the SDK
|
|
83
|
+
reapplies privacy, policy, path proof and bounded ownership. An acknowledged
|
|
84
|
+
capability downgrade may retry an original exception once without optional
|
|
85
|
+
evidence, using the same identity and no second hook invocation.
|
|
86
|
+
|
|
87
|
+
Hook replacements pass the complete vendored canonical schema and its conditional
|
|
88
|
+
frontend identity checks before and after mandatory privacy. Invalid replacements
|
|
89
|
+
fall back to the protected original; explicit `nil` still drops an event. Existing
|
|
90
|
+
queue-pressure aggregate `reason` and `level` fields remain supported.
|
|
91
|
+
|
|
92
|
+
Outgoing requests stay within the receiver's 256 KiB body limit, counting actual
|
|
93
|
+
escaped UTF-8 JSON. The sender keeps ordered byte-sized batches and schedules
|
|
94
|
+
remaining work on its existing worker. Acknowledgement indices apply to each
|
|
95
|
+
submitted chunk. Oversized optional evidence is removed first; a valid hook
|
|
96
|
+
replacement that still cannot fit is withheld without restoring pre-hook content.
|
|
97
|
+
Invalid or throwing hooks retain the established safe-original fallback. Flush remains a bounded,
|
|
98
|
+
best-effort wait; queued tails can continue after it returns. Local file transport
|
|
99
|
+
keeps its existing bounded ownership and file semantics; the HTTP body limit
|
|
100
|
+
does not narrow admissible local captures.
|
|
101
|
+
|
|
102
|
+
Native HTTP config/ACK replies request identity encoding and retain at most
|
|
103
|
+
256 KiB before JSON parsing. Existing native connect/read timeouts remain five
|
|
104
|
+
seconds; relay chunks and downgrade retries share one five-second monotonic budget,
|
|
105
|
+
passed to native response reads and remaining connect/read timeouts. A body deadline
|
|
106
|
+
is checked between streamed chunks. Native DNS/socket waits remain advisory. Explicit
|
|
107
|
+
`flush` still waits at most five seconds. `close` stops this process's sender,
|
|
108
|
+
discards queued work and retains active-send accounting until completion. A
|
|
109
|
+
forked child follows the existing fresh-worker contract. `dispose` permanently
|
|
110
|
+
retires a client across process generations; singleton `DebugBundle.init` disposes
|
|
111
|
+
its superseded client. Reinstall explicitly configured logger/integration
|
|
112
|
+
bindings for a replacement client. Independently constructed clients remain
|
|
113
|
+
available for applications that intentionally own multiple projects.
|
|
114
|
+
|
|
115
|
+
Rack and Rails browser relays preserve validated evidence and server-owned
|
|
116
|
+
credentials. Downstream chunks account for server metadata expansion; a durable
|
|
117
|
+
spool is marked delivered only when every chunk has a complete canonical
|
|
118
|
+
acknowledgement. Legacy custom transport success remains compatible and cannot
|
|
119
|
+
create a delivered marker. Final installed/runtime qualification is in progress.
|
|
120
|
+
|
|
13
121
|
## Automatic capture and application filtering
|
|
14
122
|
|
|
15
123
|
`capture_logger(logger)` respects the logger level, Rails silence and disabled output. Lazy message blocks are evaluated by the native logger: zero times when suppressed and once when accepted. Native return values and application block exceptions are preserved. DebugBundle applies its own configured minimum capture level after the logger accepts a message.
|
|
@@ -374,3 +482,22 @@ Apache-2.0. See LICENSE.
|
|
|
374
482
|
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
483
|
|
|
376
484
|
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.
|
|
485
|
+
|
|
486
|
+
## Probe checkpoint guidance
|
|
487
|
+
|
|
488
|
+
Application instrumentation supplies probe labels and bounded data. Deploy a new
|
|
489
|
+
checkpoint before activating it. Remote activation affects future matching calls
|
|
490
|
+
when policy permits; it does not inject code or recover unretained measurements.
|
|
491
|
+
Built-in runtime/error/device collectors keep their documented SDK settings.
|
|
492
|
+
|
|
493
|
+
Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
|
|
494
|
+
(stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
|
|
495
|
+
and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
|
|
496
|
+
`cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
|
|
497
|
+
tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
|
|
498
|
+
alone does not establish semantic recovery.
|
|
499
|
+
|
|
500
|
+
Empty probe data means no matching retained observation for the selected sample
|
|
501
|
+
window. It does not confirm activation delivery to every SDK or execution of a
|
|
502
|
+
label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
|
|
503
|
+
policy, sampling and delivery receipts. See the [probe recipes](https://debugbundle.com/docs/probes#bounded-checkpoint-recipes).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'time'
|
|
4
|
+
require 'debugbundle/evidence_wire'
|
|
5
|
+
require 'debugbundle/canonical_event_schema'
|
|
4
6
|
|
|
5
7
|
module DebugBundle
|
|
6
8
|
module BeforeSend
|
|
@@ -12,10 +14,12 @@ module DebugBundle
|
|
|
12
14
|
'frontend_exception' => %w[name message stack],
|
|
13
15
|
'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
|
|
14
16
|
'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen],
|
|
15
|
-
'probe_event' => %w[label data activation_id probe_label_pattern]
|
|
17
|
+
'probe_event' => %w[label data activation_id probe_label_pattern],
|
|
18
|
+
'diagnostic_context' => %w[version kind failure_ref scope]
|
|
16
19
|
}.freeze
|
|
17
20
|
ALLOWED_PAYLOAD_FIELDS = {
|
|
18
|
-
'backend_exception' => %w[name message stack handled request response runtime probe_data
|
|
21
|
+
'backend_exception' => %w[name message stack handled request response runtime probe_data exception_evidence
|
|
22
|
+
execution],
|
|
19
23
|
'request_event' => %w[
|
|
20
24
|
method path query headers body response_status duration_ms route_template
|
|
21
25
|
response_headers response_body device
|
|
@@ -24,17 +28,20 @@ module DebugBundle
|
|
|
24
28
|
'frontend_breadcrumb' => %w[breadcrumb_type route data device],
|
|
25
29
|
'frontend_exception' => %w[
|
|
26
30
|
name message stack route browser breadcrumbs device browser_event
|
|
27
|
-
rejection_reason dom_context probe_data
|
|
31
|
+
rejection_reason dom_context probe_data exception_evidence resource_observations
|
|
28
32
|
],
|
|
29
33
|
'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
|
|
30
34
|
'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen reason level device],
|
|
31
|
-
'probe_event' => %w[label data activation_id probe_label_pattern device]
|
|
35
|
+
'probe_event' => %w[label data activation_id probe_label_pattern device],
|
|
36
|
+
'diagnostic_context' => %w[version kind failure_ref scope attempt_id state operation status_code duration_ms
|
|
37
|
+
source_event_id observations]
|
|
32
38
|
}.freeze
|
|
33
39
|
ROOT_FIELDS = %w[
|
|
34
40
|
schema_version event_id event_type project_token project_id sdk_name sdk_version
|
|
35
41
|
service occurred_at correlation context payload
|
|
36
42
|
].freeze
|
|
37
|
-
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[
|
|
43
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
44
|
+
MOBILE_SDK_NAMES = %w[@debugbundle/sdk-android @debugbundle/sdk-swift @debugbundle/sdk-react-native].freeze
|
|
38
45
|
|
|
39
46
|
module_function
|
|
40
47
|
|
|
@@ -44,35 +51,56 @@ module DebugBundle
|
|
|
44
51
|
result = hook.call(Marshal.load(Marshal.dump(event)))
|
|
45
52
|
return nil if result.nil?
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
checked = normalization_result(result)
|
|
55
|
+
return nil if checked.equal?(EvidenceValue::EXHAUSTED)
|
|
56
|
+
|
|
57
|
+
checked || event
|
|
48
58
|
rescue StandardError
|
|
49
59
|
event
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def valid?(event)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
!normalized(event).nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalized(event)
|
|
67
|
+
result = normalization_result(event)
|
|
68
|
+
EvidenceValue.type?(result, Hash) ? result : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalization_result(event)
|
|
72
|
+
return nil unless EvidenceValue.type?(event, Hash)
|
|
73
|
+
return nil if Hash.instance_method(:size).bind_call(event) > ROOT_FIELDS.length
|
|
74
|
+
|
|
75
|
+
# Local/queued events keep their established ceiling independently of
|
|
76
|
+
# the smaller evidence DTO and HTTP budgets enforced at those boundaries.
|
|
77
|
+
owned = EvidenceValue.copy_with_budget(event, symbol_keys: true, max_bytes: 8 * 1024 * 1024,
|
|
78
|
+
max_string_bytes: 8 * 1024 * 1024)
|
|
79
|
+
return owned if owned.equal?(EvidenceValue::EXHAUSTED)
|
|
80
|
+
return nil unless EvidenceValue.type?(owned, Hash)
|
|
81
|
+
return nil unless CanonicalEventSchema.valid?(owned) && EvidenceWire.valid_optional?(owned)
|
|
82
|
+
return nil unless required_frontend_context?(owned)
|
|
83
|
+
|
|
84
|
+
owned
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The canonical Zod contract refines this conditional outside its shape;
|
|
90
|
+
# the generated JSON Schema alone cannot establish frontend identity.
|
|
91
|
+
def required_frontend_context?(event)
|
|
92
|
+
return true unless event['event_type'] == 'frontend_exception'
|
|
59
93
|
|
|
60
|
-
Time.iso8601(event['occurred_at'])
|
|
61
|
-
service = event['service']
|
|
62
94
|
payload = event['payload']
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fields.all? { |field| payload.key?(field) } &&
|
|
73
|
-
valid_payload_shape?(event['event_type'], payload)
|
|
74
|
-
rescue ArgumentError
|
|
75
|
-
false
|
|
95
|
+
MOBILE_SDK_NAMES.include?(event['sdk_name']) ? payload['device'].is_a?(Hash) : payload.key?('browser')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Existing shape helpers stay available to integrations. Full acceptance
|
|
99
|
+
# above uses the vendored canonical contract, including nested fields.
|
|
100
|
+
def valid_identity?(event)
|
|
101
|
+
%w[schema_version event_id event_type occurred_at sdk_name sdk_version].all? do |field|
|
|
102
|
+
event[field].is_a?(String) && !event[field].empty?
|
|
103
|
+
end && UUID_PATTERN.match?(event['event_id']) && EvidenceWire.timestamp(event['occurred_at'])
|
|
76
104
|
end
|
|
77
105
|
|
|
78
106
|
def valid_payload_shape?(event_type, payload)
|
|
@@ -93,6 +121,8 @@ module DebugBundle
|
|
|
93
121
|
valid_error_suppressed?(payload)
|
|
94
122
|
when 'probe_event'
|
|
95
123
|
valid_probe_event?(payload)
|
|
124
|
+
when 'diagnostic_context'
|
|
125
|
+
EvidenceWire.diagnostic(payload)
|
|
96
126
|
else
|
|
97
127
|
false
|
|
98
128
|
end
|
|
@@ -160,9 +190,7 @@ module DebugBundle
|
|
|
160
190
|
end
|
|
161
191
|
|
|
162
192
|
def timestamp?(value)
|
|
163
|
-
|
|
164
|
-
rescue ArgumentError
|
|
165
|
-
false
|
|
193
|
+
EvidenceWire.timestamp(value)
|
|
166
194
|
end
|
|
167
195
|
|
|
168
196
|
def nullable_uuid?(value)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
# Native HTTP adapters request identity encoding and stream existing replies.
|
|
5
|
+
# This cap precedes JSON parsing; no extra discovery request is introduced.
|
|
6
|
+
module BoundedHttpResponse
|
|
7
|
+
class LimitExceeded < StandardError; end
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5)
|
|
12
|
+
body = +''
|
|
13
|
+
response = yield lambda { |chunk|
|
|
14
|
+
raise LimitExceeded if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
15
|
+
raise LimitExceeded if body.bytesize + chunk.bytesize > 262_144
|
|
16
|
+
|
|
17
|
+
body << chunk
|
|
18
|
+
}
|
|
19
|
+
raise LimitExceeded if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
20
|
+
|
|
21
|
+
[response, body]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'debugbundle/evidence_wire'
|
|
5
|
+
|
|
6
|
+
module DebugBundle
|
|
7
|
+
# A small closed interpreter for the vendored accepting Draft 7 contract.
|
|
8
|
+
# No remote refs, application serializers or runtime dependency are needed.
|
|
9
|
+
# Unknown schema keywords fail closed when a future contract is generated.
|
|
10
|
+
class CanonicalEventSchema
|
|
11
|
+
KEYWORDS = %w[$schema $id $comment $ref type anyOf const enum properties required additionalProperties
|
|
12
|
+
items minimum maximum exclusiveMinimum minLength maxLength minItems maxItems format pattern].freeze
|
|
13
|
+
TYPES = %w[null boolean number integer string array object].freeze
|
|
14
|
+
LOAD_MUTEX = Mutex.new
|
|
15
|
+
|
|
16
|
+
def self.valid?(value)
|
|
17
|
+
unless defined?(@engine)
|
|
18
|
+
LOAD_MUTEX.synchronize do
|
|
19
|
+
unless defined?(@engine)
|
|
20
|
+
begin
|
|
21
|
+
@engine = new(JSON.parse(File.read(File.join(__dir__, 'event-envelope.schema.json'))))
|
|
22
|
+
rescue StandardError
|
|
23
|
+
@engine = nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
!!(@engine && @engine.accepts?(value))
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(schema)
|
|
34
|
+
@root = schema
|
|
35
|
+
@references = {}
|
|
36
|
+
@patterns = {}
|
|
37
|
+
compile(schema)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def compile(node)
|
|
41
|
+
return if [true, false].include?(node)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, 'unsupported schema keyword' unless (node.keys - KEYWORDS).empty?
|
|
44
|
+
raise ArgumentError, 'unsupported schema type' unless (Array(node['type']) - TYPES).empty?
|
|
45
|
+
raise ArgumentError, 'unsupported schema format' unless [nil, 'uuid', 'date-time'].include?(node['format'])
|
|
46
|
+
|
|
47
|
+
@patterns[node['pattern']] ||= Regexp.new(node['pattern']) if node.key?('pattern')
|
|
48
|
+
resolve(node['$ref']) if node.key?('$ref')
|
|
49
|
+
node.fetch('properties', {}).each_value { |child| compile(child) }
|
|
50
|
+
node.fetch('anyOf', []).each { |child| compile(child) }
|
|
51
|
+
%w[items additionalProperties].each { |key| compile(node[key]) if node.key?(key) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve(reference)
|
|
55
|
+
raise ArgumentError, 'external schema ref' unless reference.start_with?('#/')
|
|
56
|
+
|
|
57
|
+
@references[reference] ||= reference.delete_prefix('#/').split('/').reduce(@root) do |node, part|
|
|
58
|
+
node.fetch(node.is_a?(Array) ? Integer(part) : part.gsub('~1', '/').gsub('~0', '~'))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def accepts?(value)
|
|
63
|
+
check(@root, value, { steps: 100_000 }, 0)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check(node, value, budget, depth)
|
|
69
|
+
budget[:steps] -= 1
|
|
70
|
+
return false if depth > 64 || budget[:steps].negative?
|
|
71
|
+
return node if [true, false].include?(node)
|
|
72
|
+
return check(resolve(node['$ref']), value, budget, depth + 1) if node.key?('$ref')
|
|
73
|
+
|
|
74
|
+
return false if node.key?('anyOf') && node['anyOf'].none? { |child| check(child, value, budget, depth + 1) }
|
|
75
|
+
return false if node.key?('type') && Array(node['type']).none? { |type| matches_type?(value, type) }
|
|
76
|
+
return false if node.key?('const') && value != node['const']
|
|
77
|
+
return false if node.key?('enum') && !node['enum'].include?(value)
|
|
78
|
+
|
|
79
|
+
case value
|
|
80
|
+
when Hash then object?(node, value, budget, depth)
|
|
81
|
+
when Array then array?(node, value, budget, depth)
|
|
82
|
+
when String then string?(node, value)
|
|
83
|
+
when Integer, Float then number?(node, value)
|
|
84
|
+
else true
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def matches_type?(value, type)
|
|
89
|
+
case type
|
|
90
|
+
when 'null' then value.nil?
|
|
91
|
+
when 'boolean' then [true, false].include?(value)
|
|
92
|
+
when 'string' then value.is_a?(String)
|
|
93
|
+
when 'object' then value.is_a?(Hash)
|
|
94
|
+
when 'array' then value.is_a?(Array)
|
|
95
|
+
when 'number' then (value.is_a?(Integer) || value.is_a?(Float)) && value.to_f.finite?
|
|
96
|
+
when 'integer' then matches_type?(value, 'number') && value == value.floor
|
|
97
|
+
else false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def object?(node, value, budget, depth)
|
|
102
|
+
return false unless node.fetch('required', []).all? { |key| value.key?(key) }
|
|
103
|
+
|
|
104
|
+
properties = node.fetch('properties', {})
|
|
105
|
+
value.all? do |key, child|
|
|
106
|
+
if properties.key?(key)
|
|
107
|
+
check(properties[key], child, budget, depth + 1)
|
|
108
|
+
else
|
|
109
|
+
check(node.fetch('additionalProperties', true), child, budget, depth + 1)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def array?(node, value, budget, depth)
|
|
115
|
+
return false if node.key?('minItems') && value.length < node['minItems']
|
|
116
|
+
return false if node.key?('maxItems') && value.length > node['maxItems']
|
|
117
|
+
|
|
118
|
+
!node.key?('items') || value.all? { |child| check(node['items'], child, budget, depth + 1) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def string?(node, value)
|
|
122
|
+
return false if node.key?('minLength') && value.length < node['minLength']
|
|
123
|
+
return false if node.key?('maxLength') && value.length > node['maxLength']
|
|
124
|
+
return false if node.key?('pattern') && !@patterns.fetch(node['pattern']).match?(value)
|
|
125
|
+
return EvidenceWire.uuid(value) if node['format'] == 'uuid'
|
|
126
|
+
return EvidenceWire.timestamp(value) if node['format'] == 'date-time'
|
|
127
|
+
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def number?(node, value)
|
|
132
|
+
(!node.key?('minimum') || value >= node['minimum']) &&
|
|
133
|
+
(!node.key?('maximum') || value <= node['maximum']) &&
|
|
134
|
+
(!node.key?('exclusiveMinimum') || value > node['exclusiveMinimum'])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/debugbundle/client.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require 'English'
|
|
4
5
|
require 'json'
|
|
5
6
|
require 'set'
|
|
6
7
|
require 'time'
|
|
7
8
|
|
|
8
9
|
require 'debugbundle/client_event_support'
|
|
10
|
+
require 'debugbundle/client_exception_support'
|
|
11
|
+
require 'debugbundle/client_evidence_support'
|
|
9
12
|
require 'debugbundle/client_process_support'
|
|
10
13
|
require 'debugbundle/client_queue_support'
|
|
11
14
|
require 'debugbundle/config_worker'
|
|
@@ -91,6 +94,9 @@ module DebugBundle
|
|
|
91
94
|
def initialize(transport: nil, time_provider: nil, random_provider: nil, config_fetcher: nil, **options)
|
|
92
95
|
@owner_pid = Process.pid
|
|
93
96
|
@config = Config.new(**options)
|
|
97
|
+
@closed = false
|
|
98
|
+
@retired = false
|
|
99
|
+
initialize_evidence
|
|
94
100
|
@time_provider = time_provider || -> { Time.now.utc }
|
|
95
101
|
@random_provider = random_provider || -> { rand }
|
|
96
102
|
@redactor = Redaction::Redactor.new(
|
|
@@ -99,17 +105,7 @@ module DebugBundle
|
|
|
99
105
|
@transport = transport || build_default_transport
|
|
100
106
|
@config_fetcher = config_fetcher || build_default_config_fetcher(custom_transport: !transport.nil?)
|
|
101
107
|
@context = {}
|
|
102
|
-
|
|
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 = {}
|
|
111
|
-
@buffer_mutex = Mutex.new
|
|
112
|
-
@flush_mutex = Mutex.new
|
|
108
|
+
initialize_buffer_ownership
|
|
113
109
|
@probe_buffers = {}
|
|
114
110
|
@suppression = Suppression::Tracker.new
|
|
115
111
|
@last_event_at = nil
|
|
@@ -145,46 +141,6 @@ module DebugBundle
|
|
|
145
141
|
capture_exception_internal(error, context: context, handled: handled, run_before_send: true)
|
|
146
142
|
end
|
|
147
143
|
|
|
148
|
-
def capture_exception_internal(error, context:, handled:, run_before_send:)
|
|
149
|
-
return unless capture_enabled?
|
|
150
|
-
return unless preflight_capacity?(3, 'exception')
|
|
151
|
-
|
|
152
|
-
request_remote_config_poll_if_due
|
|
153
|
-
|
|
154
|
-
merged_context = merge_context(context)
|
|
155
|
-
payload = {
|
|
156
|
-
'name' => safe_exception_name(error),
|
|
157
|
-
'message' => safe_exception_message(error),
|
|
158
|
-
'stack' => safe_exception_stack(error),
|
|
159
|
-
'handled' => handled,
|
|
160
|
-
'request' => request_payload(merged_context['request']),
|
|
161
|
-
'response' => response_payload(merged_context['response']),
|
|
162
|
-
'runtime' => runtime_payload
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
causes = exception_causes(error)
|
|
166
|
-
|
|
167
|
-
probe_data = probe_snapshot
|
|
168
|
-
payload['probe_data'] = probe_data unless probe_data.empty?
|
|
169
|
-
|
|
170
|
-
extra_context = merged_context.except('request', 'response', 'correlation')
|
|
171
|
-
extra_context['causes'] = causes unless causes.empty?
|
|
172
|
-
|
|
173
|
-
event = base_event('backend_exception', payload, extra_context)
|
|
174
|
-
|
|
175
|
-
event_payload = event.fetch('payload')
|
|
176
|
-
suppression_key = [
|
|
177
|
-
event['event_type'],
|
|
178
|
-
event_payload['name'],
|
|
179
|
-
event_payload['message'],
|
|
180
|
-
event_payload['stack']
|
|
181
|
-
].join(':')
|
|
182
|
-
return unless @suppression.should_capture(suppression_key, now: monotonic_now)
|
|
183
|
-
|
|
184
|
-
enqueue_event(event, skip_before_send: !run_before_send)
|
|
185
|
-
end
|
|
186
|
-
private :capture_exception_internal
|
|
187
|
-
|
|
188
144
|
def capture_error(error, context: nil, handled: true) = capture_exception(error, context: context, handled: handled)
|
|
189
145
|
|
|
190
146
|
def capture_log(message, level: :warning, context: nil)
|
|
@@ -363,6 +319,8 @@ module DebugBundle
|
|
|
363
319
|
return false unless @config_fetcher
|
|
364
320
|
|
|
365
321
|
response = @config_fetcher.call(@remote_config_etag)
|
|
322
|
+
return false if @closed
|
|
323
|
+
|
|
366
324
|
status_code = response.fetch(:status_code, 500)
|
|
367
325
|
if status_code == 304
|
|
368
326
|
schedule_next_remote_config_poll
|
|
@@ -373,7 +331,9 @@ module DebugBundle
|
|
|
373
331
|
return false
|
|
374
332
|
end
|
|
375
333
|
|
|
376
|
-
|
|
334
|
+
body = response.fetch(:body, {})
|
|
335
|
+
snapshot = RemoteConfig.parse(body, config.probes_poll_interval)
|
|
336
|
+
@evidence.observe(body, required: true, valid: !snapshot.nil? && @evidence.valid_config?(body))
|
|
377
337
|
unless snapshot
|
|
378
338
|
schedule_next_remote_config_poll
|
|
379
339
|
return false
|
|
@@ -403,8 +363,17 @@ module DebugBundle
|
|
|
403
363
|
|
|
404
364
|
def close
|
|
405
365
|
ensure_current_process!
|
|
366
|
+
@closed = true
|
|
367
|
+
@operations.clear
|
|
368
|
+
@evidence = EvidenceDelivery.new
|
|
406
369
|
@delivery_worker&.close
|
|
407
370
|
@config_worker&.close
|
|
371
|
+
@buffer_mutex.synchronize { discard_closed_buffer_locked }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def dispose
|
|
375
|
+
@retired = true
|
|
376
|
+
close
|
|
408
377
|
end
|
|
409
378
|
|
|
410
379
|
def flush_now
|
|
@@ -417,20 +386,12 @@ module DebugBundle
|
|
|
417
386
|
return false if @transport.nil?
|
|
418
387
|
return false if rate_limited?
|
|
419
388
|
|
|
420
|
-
|
|
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
|
|
389
|
+
prepared = prepare_wire_batch(candidates)
|
|
430
390
|
return true if prepared.empty?
|
|
431
391
|
|
|
432
392
|
batch = prepared.map(&:first)
|
|
433
393
|
wire_events = prepared.map(&:last)
|
|
394
|
+
return true if @closed
|
|
434
395
|
|
|
435
396
|
result = Transport.coerce_result(
|
|
436
397
|
@transport.call(
|
|
@@ -442,7 +403,9 @@ module DebugBundle
|
|
|
442
403
|
|
|
443
404
|
case result.status_code
|
|
444
405
|
when 200..299
|
|
445
|
-
handle_successful_result(result, batch)
|
|
406
|
+
accepted = handle_successful_result(result, batch)
|
|
407
|
+
@delivery_worker&.wake if @retry_at.nil? && buffered_event_count.positive?
|
|
408
|
+
accepted
|
|
446
409
|
when 429, 500..599
|
|
447
410
|
@consecutive_failures += 1
|
|
448
411
|
retry_after_seconds = bounded_retry_hint(result.retry_after_seconds)
|
|
@@ -468,22 +431,30 @@ module DebugBundle
|
|
|
468
431
|
private :flush_now
|
|
469
432
|
|
|
470
433
|
def finalized_event_for(event)
|
|
434
|
+
return nil if @closed
|
|
435
|
+
|
|
471
436
|
event_id = event['event_id']
|
|
472
437
|
cached, bypass = @buffer_mutex.synchronize do
|
|
473
438
|
[@finalized_events[event_id], @hook_bypass.include?(event_id)]
|
|
474
439
|
end
|
|
475
|
-
|
|
440
|
+
finalized = cached || (bypass || !config.before_send ? event : apply_before_send(event))
|
|
441
|
+
return nil if finalized.nil? || @closed
|
|
476
442
|
|
|
477
|
-
finalized =
|
|
478
|
-
return nil
|
|
443
|
+
finalized = filter_evidence(finalized)
|
|
444
|
+
return nil unless finalized
|
|
479
445
|
return nil unless post_hook_event_allowed?(finalized)
|
|
446
|
+
return nil unless reconcile_diagnostic(cached || event, finalized)
|
|
480
447
|
|
|
481
|
-
cache_finalized_event(event, finalized)
|
|
448
|
+
retained = cache_finalized_event(event, finalized)
|
|
449
|
+
@buffer_mutex.synchronize { release_diagnostics_locked(event, finalized) } unless retained
|
|
450
|
+
retained
|
|
482
451
|
end
|
|
483
452
|
private :finalized_event_for
|
|
484
453
|
|
|
485
454
|
def post_hook_event_allowed?(event)
|
|
486
455
|
case event['event_type']
|
|
456
|
+
when 'diagnostic_context'
|
|
457
|
+
recovery_allowed? && event.dig('payload', 'kind') == 'recovery'
|
|
487
458
|
when 'log_event'
|
|
488
459
|
return false if @capture_policy.capture_logs == 'off'
|
|
489
460
|
|
|
@@ -523,6 +494,8 @@ module DebugBundle
|
|
|
523
494
|
|
|
524
495
|
def handle_successful_result(result, batch)
|
|
525
496
|
decision = Acknowledgement.decide(result.body, batch.length, required: @transport.is_a?(Transport::HttpTransport))
|
|
497
|
+
lost_evidence = !@closed && @evidence.observe(result.body, required: @transport.is_a?(Transport::HttpTransport),
|
|
498
|
+
valid: decision[:kind] == :acknowledged)
|
|
526
499
|
return handle_protocol_failure(result.retry_after_seconds) if decision[:kind] == :protocol_failure
|
|
527
500
|
|
|
528
501
|
if decision[:kind] == :legacy
|
|
@@ -533,6 +506,7 @@ module DebugBundle
|
|
|
533
506
|
|
|
534
507
|
retryable_indices = decision.fetch(:retryable_indices)
|
|
535
508
|
retryable_events = retryable_indices.map { |index| batch.fetch(index) }
|
|
509
|
+
retryable_events |= downgrade_evidence_errors(batch, decision, lost_evidence)
|
|
536
510
|
remove_buffered_events(batch - retryable_events)
|
|
537
511
|
@last_event_at = now if decision.fetch(:accepted).positive?
|
|
538
512
|
|