debugbundle 2.0.0 → 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 +133 -0
- data/lib/debugbundle/acknowledgement.rb +6 -2
- 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 +55 -75
- 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 +49 -19
- 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.
|
|
@@ -368,3 +476,28 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
|
|
|
368
476
|
## License
|
|
369
477
|
|
|
370
478
|
Apache-2.0. See LICENSE.
|
|
479
|
+
|
|
480
|
+
## Delivery acknowledgement and retry limits
|
|
481
|
+
|
|
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`.
|
|
483
|
+
|
|
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).
|
|
@@ -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
|
|
@@ -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
|