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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'debugbundle/evidence_wire'
|
|
5
|
+
|
|
6
|
+
module DebugBundle
|
|
7
|
+
module EventBatch
|
|
8
|
+
MAX_BODY_BYTES = 256 * 1024
|
|
9
|
+
WRAPPER_BYTES = 13 # {"events":[]} plus a conservative comma allowance
|
|
10
|
+
MAX_EVENT_BYTES = MAX_BODY_BYTES - WRAPPER_BYTES
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def bytes(event) = JSON.generate(event).bytesize
|
|
15
|
+
def fits?(event) = bytes(event) <= MAX_EVENT_BYTES
|
|
16
|
+
|
|
17
|
+
# Added metadata must not strand a primary that fits on the legacy wire.
|
|
18
|
+
# A historically open same-named custom context value remains untouched.
|
|
19
|
+
def fit(event)
|
|
20
|
+
return nil unless event
|
|
21
|
+
return event if fits?(event)
|
|
22
|
+
|
|
23
|
+
reduced = EvidenceWire.without_optional(event) || event
|
|
24
|
+
if EvidenceWire.reference(reduced.dig('context', 'evidence_reference'))
|
|
25
|
+
reduced = reduced.merge('context' => reduced['context'].except('evidence_reference'))
|
|
26
|
+
end
|
|
27
|
+
reduced if reduced != event && fits?(reduced)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def chunks(events)
|
|
31
|
+
result = [[]]
|
|
32
|
+
size = WRAPPER_BYTES
|
|
33
|
+
events.each do |event|
|
|
34
|
+
cost = bytes(event) + (result.last.empty? ? 0 : 1)
|
|
35
|
+
raise ArgumentError, 'event exceeds the ingestion request limit' unless fits?(event)
|
|
36
|
+
|
|
37
|
+
if size + cost > MAX_BODY_BYTES
|
|
38
|
+
result << []
|
|
39
|
+
size = WRAPPER_BYTES
|
|
40
|
+
cost -= 1
|
|
41
|
+
end
|
|
42
|
+
result.last << event
|
|
43
|
+
size += cost
|
|
44
|
+
end
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'debugbundle/evidence_wire'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
class EvidenceDelivery
|
|
7
|
+
attr_reader :features
|
|
8
|
+
|
|
9
|
+
def initialize(pin = nil)
|
|
10
|
+
@features = EvidenceWire.descriptor(EvidenceValue.options(pin))['features']
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def supports?(feature) = @features.include?(feature)
|
|
14
|
+
def descriptor = { 'version' => 1, 'features' => @features.dup }
|
|
15
|
+
def restrict(features) = @features &= features
|
|
16
|
+
|
|
17
|
+
def observe(body, required: false, valid: true)
|
|
18
|
+
owned = EvidenceValue.copy(body)
|
|
19
|
+
supplied = owned.is_a?(Hash) && owned.key?('evidence_capabilities')
|
|
20
|
+
return false unless required || supplied
|
|
21
|
+
|
|
22
|
+
previous = @features
|
|
23
|
+
@features = EvidenceWire.descriptor(valid && supplied ? owned['evidence_capabilities'] : nil)['features']
|
|
24
|
+
(previous - @features).any?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid_config?(body)
|
|
28
|
+
owned = EvidenceValue.copy(body)
|
|
29
|
+
return false unless owned.is_a?(Hash)
|
|
30
|
+
return false unless %w[probes_enabled remote_probes_enabled].all? do |field|
|
|
31
|
+
!owned.key?(field) || [true, false].include?(owned[field])
|
|
32
|
+
end
|
|
33
|
+
return false if owned.key?('active_probes') && !owned['active_probes'].is_a?(Array)
|
|
34
|
+
|
|
35
|
+
policy = owned['capture_policy']
|
|
36
|
+
return true if policy.nil?
|
|
37
|
+
return false unless policy.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
diagnostic = policy['capture_diagnostic_context']
|
|
40
|
+
return false if policy.key?('capture_diagnostic_context') && ![true, false].include?(diagnostic)
|
|
41
|
+
|
|
42
|
+
{ 'preset' => %w[minimal balanced investigative], 'capture_logs' => %w[off error warning info],
|
|
43
|
+
'capture_request_events' => %w[off failures_only filtered all],
|
|
44
|
+
'capture_breadcrumbs' => %w[local_only exception_only standalone],
|
|
45
|
+
'capture_probe_events' => %w[buffer_only standalone_when_activated] }.all? do |field, values|
|
|
46
|
+
!policy.key?(field) || values.include?(policy[field])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
module EvidenceGraph
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def valid?(value)
|
|
8
|
+
wire = EvidenceWire
|
|
9
|
+
return false unless wire.closed(value, %w[version runtime root_id nodes truncated unavailable_reason])
|
|
10
|
+
return false unless wire.integer(value['version'], 1, 1) && wire.integer(value['root_id'], 0, 7)
|
|
11
|
+
return false unless %w[node browser python php jvm go ruby dotnet android swift react_native
|
|
12
|
+
unknown].include?(value['runtime'])
|
|
13
|
+
return false unless [true, false].include?(value['truncated']) &&
|
|
14
|
+
[nil, 'unknown', 'unsupported', 'unsafe',
|
|
15
|
+
'budget_exceeded'].include?(value['unavailable_reason'])
|
|
16
|
+
|
|
17
|
+
nodes = value['nodes']
|
|
18
|
+
return false unless nodes.is_a?(Array) && nodes.length.between?(1, 8)
|
|
19
|
+
|
|
20
|
+
adjacency = {}
|
|
21
|
+
nodes.each do |node|
|
|
22
|
+
return false unless valid_node?(node) && !adjacency.key?(node['id'])
|
|
23
|
+
|
|
24
|
+
adjacency[node['id']] = node['relationships'].map { |edge| edge['target_id'] }
|
|
25
|
+
end
|
|
26
|
+
visited = {}
|
|
27
|
+
visit(value['root_id'], adjacency, [], visited) && visited.length == nodes.length &&
|
|
28
|
+
EvidenceJson.bytes(value) <= 32_768
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def valid_node?(node)
|
|
32
|
+
wire = EvidenceWire
|
|
33
|
+
return false unless wire.closed(node, %w[id name message stack trace_origin parsing_status frames relationships])
|
|
34
|
+
return false unless wire.integer(node['id'], 0,
|
|
35
|
+
7) && wire.text(node['name'], 256) && wire.text(node['message'], 2048)
|
|
36
|
+
return false unless node['stack'].nil? || wire.text(node['stack'], 16_384)
|
|
37
|
+
return false unless %w[throw native_fatal capture runtime_reported unavailable
|
|
38
|
+
unknown].include?(node['trace_origin'])
|
|
39
|
+
return false unless %w[parsed partial unparsed unavailable truncated].include?(node['parsing_status'])
|
|
40
|
+
|
|
41
|
+
frames = node['frames']
|
|
42
|
+
frames.is_a?(Array) && frames.length <= 32 && frames.all? { |frame| valid_frame?(frame) } &&
|
|
43
|
+
valid_edges?(node['relationships'])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid_frame?(frame)
|
|
47
|
+
wire = EvidenceWire
|
|
48
|
+
wire.closed(frame, %w[function file line column classification]) &&
|
|
49
|
+
(frame['function'].nil? || wire.text(frame['function'], 256)) &&
|
|
50
|
+
(frame['file'].nil? || wire.text(frame['file'], 2048)) &&
|
|
51
|
+
(frame['line'].nil? || wire.integer(frame['line'], 1)) &&
|
|
52
|
+
(frame['column'].nil? || wire.integer(frame['column'], 0)) &&
|
|
53
|
+
%w[application framework sdk unknown].include?(frame['classification'])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_edges?(edges)
|
|
57
|
+
return false unless edges.is_a?(Array) && edges.length <= 5 && edges.uniq.length == edges.length
|
|
58
|
+
return false if edges.count { |edge| edge.is_a?(Hash) && %w[cause context].include?(edge['kind']) } > 1
|
|
59
|
+
|
|
60
|
+
edges.all? do |edge|
|
|
61
|
+
EvidenceWire.closed(edge, %w[kind target_id]) &&
|
|
62
|
+
%w[cause context aggregate suppressed].include?(edge['kind']) && EvidenceWire.integer(edge['target_id'], 0, 7)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def visit(id, adjacency, path, visited)
|
|
67
|
+
return false if path.length > 4 || path.include?(id) || !adjacency.key?(id)
|
|
68
|
+
|
|
69
|
+
visited[id] = true
|
|
70
|
+
adjacency[id].all? { |target| visit(target, adjacency, path + [id], visited) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'debugbundle/evidence_value'
|
|
5
|
+
|
|
6
|
+
module DebugBundle
|
|
7
|
+
module EvidenceJson
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def bytes(value)
|
|
11
|
+
owned = EvidenceValue.copy(value)
|
|
12
|
+
owned.equal?(EvidenceValue::INVALID) ? 32_769 : count(owned)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
32_769
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def count(value)
|
|
18
|
+
case value
|
|
19
|
+
when Hash
|
|
20
|
+
2 + [0, value.length - 1].max + value.sum { |key, item| count(key) + 1 + count(item) }
|
|
21
|
+
when Array
|
|
22
|
+
2 + [0, value.length - 1].max + value.sum { |item| count(item) }
|
|
23
|
+
when Integer, Float then number_bytes(value)
|
|
24
|
+
else JSON.generate(value).bytesize
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def number_bytes(value)
|
|
29
|
+
return value.to_s.length if value.is_a?(Integer) && value.abs <= 9_007_199_254_740_991
|
|
30
|
+
|
|
31
|
+
number = value.to_f
|
|
32
|
+
return 32_769 unless number.finite?
|
|
33
|
+
return 1 if number.zero?
|
|
34
|
+
|
|
35
|
+
mantissa, exponent = number.abs.to_s.downcase.split('e', 2)
|
|
36
|
+
raw = mantissa.delete('.')
|
|
37
|
+
leading = raw.length - raw.sub(/\A0+/, '').length
|
|
38
|
+
digits = raw.sub(/\A0+/, '').sub(/0+\z/, '')
|
|
39
|
+
position = mantissa.split('.', 2).first.length + (exponent || '0').to_i - leading
|
|
40
|
+
power = position - 1
|
|
41
|
+
length = if power.between?(-6, 20)
|
|
42
|
+
decimal_bytes(position, digits.length)
|
|
43
|
+
else
|
|
44
|
+
digits.length + (digits.length > 1 ? 1 : 0) + 2 + power.abs.to_s.length
|
|
45
|
+
end
|
|
46
|
+
length + (number.negative? ? 1 : 0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def decimal_bytes(position, digits)
|
|
50
|
+
return 2 - position + digits if position <= 0
|
|
51
|
+
|
|
52
|
+
[position, digits].max + (position < digits ? 1 : 0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
module EvidenceResources
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def valid?(value)
|
|
8
|
+
wire = EvidenceWire
|
|
9
|
+
wire.closed(value, %w[version items]) && wire.integer(value['version'], 1, 1) &&
|
|
10
|
+
value['items'].is_a?(Array) && value['items'].length.between?(1, 5) &&
|
|
11
|
+
value['items'].all? { |item| valid_item?(item) } && EvidenceJson.bytes(value) <= 32_768
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def valid_item?(item)
|
|
15
|
+
wire = EvidenceWire
|
|
16
|
+
return false unless item.is_a?(Hash) && wire.timestamp(item['observed_at']) && item.key?('source_url')
|
|
17
|
+
return false unless item['source_url'].nil? || url?(item['source_url'])
|
|
18
|
+
|
|
19
|
+
case item['kind']
|
|
20
|
+
when 'timing' then valid_timing?(item)
|
|
21
|
+
when 'csp' then valid_csp?(item)
|
|
22
|
+
else false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def valid_timing?(item)
|
|
27
|
+
wire = EvidenceWire
|
|
28
|
+
wire.closed(item,
|
|
29
|
+
%w[kind observed_at source_url availability response_status duration_ms transfer_size_bytes]) &&
|
|
30
|
+
%w[observed unsupported no_match withheld ambiguous unknown].include?(item['availability']) &&
|
|
31
|
+
(item['response_status'].nil? || wire.integer(item['response_status'], 100, 599)) &&
|
|
32
|
+
(item['duration_ms'].nil? || wire.number(item['duration_ms'])) &&
|
|
33
|
+
(item['transfer_size_bytes'].nil? || wire.integer(item['transfer_size_bytes'], 0))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def valid_csp?(item)
|
|
37
|
+
wire = EvidenceWire
|
|
38
|
+
wire.closed(item, %w[kind observed_at source_url effective_directive disposition document_status_code]) &&
|
|
39
|
+
wire.text(item['effective_directive'], 64) && /\A[a-z-]{1,64}\z/.match?(item['effective_directive']) &&
|
|
40
|
+
%w[enforce report].include?(item['disposition']) &&
|
|
41
|
+
(item['document_status_code'].nil? || wire.integer(item['document_status_code'], 100, 599))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def url?(value)
|
|
45
|
+
return false unless EvidenceWire.text(value, 2048) && !value.empty?
|
|
46
|
+
return false if value.start_with?('//') || value.match?(/[\x00-\x20\x7f\\?#]/)
|
|
47
|
+
return false unless value.start_with?('/') || value.match?(%r{\Ahttps?://}i)
|
|
48
|
+
return true if value.start_with?('/')
|
|
49
|
+
|
|
50
|
+
# URI validates an ASCII representation; retain the original resource identity.
|
|
51
|
+
ascii = value.gsub(/[^\x00-\x7f]/) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join }
|
|
52
|
+
uri = URI.parse(ascii)
|
|
53
|
+
return false if !value.start_with?('/') && (uri.host.nil? || uri.host.empty?)
|
|
54
|
+
|
|
55
|
+
[nil, ''].include?(uri.user) && [nil, ''].include?(uri.password) && uri.query.nil? && uri.fragment.nil?
|
|
56
|
+
rescue URI::InvalidURIError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
# Copy only bounded native JSON values. No custom iterators, serializers,
|
|
5
|
+
# default procs or coercions may run at the optional evidence boundary.
|
|
6
|
+
module EvidenceValue
|
|
7
|
+
INVALID = Object.new.freeze
|
|
8
|
+
EXHAUSTED = Object.new.freeze
|
|
9
|
+
NATIVE_CLASS = Object.instance_method(:class)
|
|
10
|
+
NATIVE_EQUAL = Object.instance_method(:equal?)
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def type?(value, type) = NATIVE_EQUAL.bind_call(NATIVE_CLASS.bind_call(value), type)
|
|
15
|
+
|
|
16
|
+
def copy(value, symbol_keys: false)
|
|
17
|
+
result = copy_with_budget(value, symbol_keys: symbol_keys)
|
|
18
|
+
result.equal?(EXHAUSTED) ? INVALID : result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Hooks must distinguish an invalid value from validation that stopped at
|
|
22
|
+
# its resource limit; the latter must not restore pre-hook content.
|
|
23
|
+
def copy_with_budget(value, symbol_keys: false, max_bytes: 262_144, max_string_bytes: 32_768)
|
|
24
|
+
budget = { nodes: 4096, bytes: max_bytes, string_bytes: max_string_bytes, symbol_keys: symbol_keys }
|
|
25
|
+
result = visit(value, 0, budget)
|
|
26
|
+
budget[:exhausted] ? EXHAUSTED : result
|
|
27
|
+
rescue StandardError
|
|
28
|
+
INVALID
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def options(value)
|
|
32
|
+
visit(value, 0, { nodes: 4096, bytes: 262_144, symbols: true })
|
|
33
|
+
rescue StandardError
|
|
34
|
+
INVALID
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit(value, depth, budget)
|
|
38
|
+
budget[:nodes] -= 1
|
|
39
|
+
return exhausted(budget) if depth > 16 || budget[:nodes].negative? || budget[:bytes].negative?
|
|
40
|
+
return value if type?(value, NilClass) || type?(value, TrueClass) || type?(value, FalseClass)
|
|
41
|
+
return copy_string(value, budget) if type?(value, String)
|
|
42
|
+
|
|
43
|
+
if budget[:symbols] && type?(value, Symbol)
|
|
44
|
+
return exhausted(budget) if Symbol.instance_method(:length).bind_call(value) > 32_768
|
|
45
|
+
|
|
46
|
+
return copy_string(Symbol.instance_method(:to_s).bind_call(value), budget)
|
|
47
|
+
end
|
|
48
|
+
return value if type?(value, Integer) && value.bit_length <= 1024
|
|
49
|
+
return value if type?(value, Float) && value.finite?
|
|
50
|
+
return copy_array(value, depth, budget) if type?(value, Array)
|
|
51
|
+
return copy_hash(value, depth, budget) if type?(value, Hash)
|
|
52
|
+
|
|
53
|
+
INVALID
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def exhausted(budget)
|
|
57
|
+
budget[:exhausted] = true
|
|
58
|
+
INVALID
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def copy_string(value, budget)
|
|
62
|
+
maximum = budget.fetch(:string_bytes, 32_768)
|
|
63
|
+
return exhausted(budget) if String.instance_method(:bytesize).bind_call(value) > [maximum, budget[:bytes]].min
|
|
64
|
+
|
|
65
|
+
text = String.new(value).encode(Encoding::UTF_8)
|
|
66
|
+
budget[:bytes] -= text.bytesize
|
|
67
|
+
return exhausted(budget) if text.bytesize > maximum || budget[:bytes].negative?
|
|
68
|
+
|
|
69
|
+
text.valid_encoding? ? text : INVALID
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def copy_array(value, depth, budget)
|
|
73
|
+
return exhausted(budget) if Array.instance_method(:length).bind_call(value) > 256
|
|
74
|
+
|
|
75
|
+
result = []
|
|
76
|
+
Array.instance_method(:each).bind_call(value) do |item|
|
|
77
|
+
child = visit(item, depth + 1, budget)
|
|
78
|
+
return INVALID if child.equal?(INVALID)
|
|
79
|
+
|
|
80
|
+
result << child
|
|
81
|
+
end
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def copy_hash(value, depth, budget)
|
|
86
|
+
return exhausted(budget) if Hash.instance_method(:size).bind_call(value) > 256
|
|
87
|
+
|
|
88
|
+
result = {}
|
|
89
|
+
Hash.instance_method(:each_pair).bind_call(value) do |key, item|
|
|
90
|
+
if (budget[:symbols] || budget[:symbol_keys]) && type?(key, Symbol)
|
|
91
|
+
return exhausted(budget) if Symbol.instance_method(:length).bind_call(key) > 32_768
|
|
92
|
+
|
|
93
|
+
key = Symbol.instance_method(:to_s).bind_call(key)
|
|
94
|
+
end
|
|
95
|
+
return INVALID unless type?(key, String)
|
|
96
|
+
|
|
97
|
+
name = copy_string(key, budget)
|
|
98
|
+
child = visit(item, depth + 1, budget)
|
|
99
|
+
return INVALID if name.equal?(INVALID) || child.equal?(INVALID)
|
|
100
|
+
return INVALID if result.key?(name)
|
|
101
|
+
|
|
102
|
+
result[name] = child
|
|
103
|
+
end
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'debugbundle/evidence_value'
|
|
7
|
+
require 'debugbundle/evidence_json'
|
|
8
|
+
require 'debugbundle/evidence_graph'
|
|
9
|
+
require 'debugbundle/evidence_resources'
|
|
10
|
+
|
|
11
|
+
module DebugBundle
|
|
12
|
+
# Only additive evidence is closed here. Historical arbitrary context remains
|
|
13
|
+
# open, including an application-defined invalid same-named reference field.
|
|
14
|
+
module EvidenceWire
|
|
15
|
+
FEATURES = %w[exception_evidence_v1 resource_observations_v1 recovery_v1 evidence_provenance_v1].freeze
|
|
16
|
+
UUID = /\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/i
|
|
17
|
+
FRESH_UUID = /\A[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\z/i
|
|
18
|
+
UTC_TIME = /\A\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z\z/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def closed(value, required, optional = [])
|
|
23
|
+
value.is_a?(Hash) && (required - value.keys).empty? && (value.keys - required - optional).empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def number(value)
|
|
27
|
+
(value.is_a?(Integer) || value.is_a?(Float)) && value >= 0 && value.to_f.finite?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def integer(value, minimum, maximum = nil)
|
|
31
|
+
number(value) && value == value.floor && value >= minimum && (!maximum || value <= maximum)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def text(value, maximum) = value.is_a?(String) && value.valid_encoding? && value.bytesize <= maximum
|
|
35
|
+
def uuid(value, fresh: false) = text(value, 36) && (fresh ? FRESH_UUID : UUID).match?(value)
|
|
36
|
+
|
|
37
|
+
def timestamp(value)
|
|
38
|
+
text(value, 32_768) && UTC_TIME.match?(value) && !!Date.iso8601(value[0, 10])
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def descriptor(value)
|
|
44
|
+
owned = EvidenceValue.copy(value)
|
|
45
|
+
empty = { 'version' => 1, 'features' => [] }
|
|
46
|
+
return empty unless closed(owned, %w[version features]) && integer(owned['version'], 1, 1)
|
|
47
|
+
|
|
48
|
+
features = owned['features']
|
|
49
|
+
return empty unless features.is_a?(Array) && features.length <= 4 && (features - FEATURES).empty?
|
|
50
|
+
return empty unless features.uniq.length == features.length
|
|
51
|
+
|
|
52
|
+
{ 'version' => 1, 'features' => FEATURES & features }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scope(value)
|
|
56
|
+
return false unless value.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
field = { 'page' => 'page_id', 'operation' => 'operation_id' }[value['kind']]
|
|
59
|
+
field && closed(value, ['kind', field]) && uuid(value[field], fresh: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reference(value)
|
|
63
|
+
owned = EvidenceValue.copy(value)
|
|
64
|
+
return nil unless closed(owned, %w[version failure_ref scope]) && integer(owned['version'], 1, 1)
|
|
65
|
+
return nil unless uuid(owned['failure_ref'], fresh: true) && scope(owned['scope'])
|
|
66
|
+
|
|
67
|
+
owned
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def capture_reference(value, default_scope = nil)
|
|
71
|
+
owned = EvidenceValue.copy(value)
|
|
72
|
+
return nil unless owned.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
reference('version' => 1, 'failure_ref' => owned['failure_ref'],
|
|
75
|
+
'scope' => owned['evidence_scope'] || default_scope)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def recovery(value, default_scope = nil)
|
|
79
|
+
owned = EvidenceValue.copy(value)
|
|
80
|
+
selected = capture_reference(owned, default_scope)
|
|
81
|
+
return nil unless selected
|
|
82
|
+
|
|
83
|
+
payload = selected.merge('kind' => 'recovery', 'attempt_id' => owned['attempt_id'], 'state' => owned['state'])
|
|
84
|
+
%w[operation status_code duration_ms].each { |field| payload[field] = owned[field] if owned.key?(field) }
|
|
85
|
+
diagnostic(payload) ? payload : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def diagnostic(value)
|
|
89
|
+
return false unless value.is_a?(Hash) && reference(value.slice('version', 'failure_ref', 'scope'))
|
|
90
|
+
|
|
91
|
+
if value['kind'] == 'recovery'
|
|
92
|
+
closed(value, %w[version kind failure_ref scope attempt_id state], %w[operation status_code duration_ms]) &&
|
|
93
|
+
uuid(value['attempt_id'], fresh: true) &&
|
|
94
|
+
%w[attempt_started attempt_completed attempt_failed recovery_observed
|
|
95
|
+
failure_persisted].include?(value['state']) &&
|
|
96
|
+
(!value.key?('operation') || text(value['operation'], 64)) &&
|
|
97
|
+
(!value.key?('status_code') || integer(value['status_code'], 100, 599)) &&
|
|
98
|
+
(!value.key?('duration_ms') || number(value['duration_ms']))
|
|
99
|
+
else
|
|
100
|
+
value['kind'] == 'resource_observation' &&
|
|
101
|
+
closed(value, %w[version kind failure_ref scope source_event_id observations]) &&
|
|
102
|
+
uuid(value['source_event_id']) && EvidenceResources.valid?(value['observations'])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def execution(value)
|
|
107
|
+
closed(value, %w[version kind name elapsed_ms limit_ms]) && integer(value['version'], 1, 1) &&
|
|
108
|
+
%w[request command job unknown].include?(value['kind']) &&
|
|
109
|
+
(value['name'].nil? || text(value['name'], 128)) &&
|
|
110
|
+
(value['elapsed_ms'].nil? || number(value['elapsed_ms'])) &&
|
|
111
|
+
(value['limit_ms'].nil? || number(value['limit_ms']))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def valid_optional?(event)
|
|
115
|
+
return false unless EvidenceValue.type?(event, Hash)
|
|
116
|
+
|
|
117
|
+
payload = Hash.instance_method(:fetch).bind_call(event, 'payload', nil)
|
|
118
|
+
return false unless EvidenceValue.type?(payload, Hash)
|
|
119
|
+
|
|
120
|
+
type = EvidenceValue.copy(Hash.instance_method(:fetch).bind_call(event, 'event_type', nil))
|
|
121
|
+
return valid_context?(event, EvidenceValue.copy(payload)) if type == 'diagnostic_context'
|
|
122
|
+
|
|
123
|
+
additions = {}
|
|
124
|
+
{ 'exception_evidence' => EvidenceGraph, 'execution' => self,
|
|
125
|
+
'resource_observations' => EvidenceResources }.each do |field, validator|
|
|
126
|
+
next unless Hash.instance_method(:key?).bind_call(payload, field)
|
|
127
|
+
return false unless allowed_field?(type, field)
|
|
128
|
+
|
|
129
|
+
owned = EvidenceValue.copy(Hash.instance_method(:fetch).bind_call(payload, field))
|
|
130
|
+
return false unless field == 'execution' ? execution(owned) : validator.valid?(owned)
|
|
131
|
+
|
|
132
|
+
additions[field] = owned
|
|
133
|
+
end
|
|
134
|
+
return true if additions.empty?
|
|
135
|
+
|
|
136
|
+
context = Hash.instance_method(:fetch).bind_call(event, 'context', nil)
|
|
137
|
+
if EvidenceValue.type?(
|
|
138
|
+
context, Hash
|
|
139
|
+
)
|
|
140
|
+
selected = reference(Hash.instance_method(:fetch).bind_call(context, 'evidence_reference',
|
|
141
|
+
nil))
|
|
142
|
+
end
|
|
143
|
+
additions['evidence_reference'] = selected if selected
|
|
144
|
+
EvidenceJson.bytes(additions) <= 32_768
|
|
145
|
+
rescue StandardError
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def allowed_field?(type, field)
|
|
150
|
+
(type == 'backend_exception' && field != 'resource_observations') ||
|
|
151
|
+
(type == 'frontend_exception' && field != 'execution')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def valid_context?(event, payload)
|
|
155
|
+
return false unless diagnostic(payload)
|
|
156
|
+
return true unless payload['scope']['kind'] == 'page'
|
|
157
|
+
|
|
158
|
+
correlation = Hash.instance_method(:fetch).bind_call(event, 'correlation', nil)
|
|
159
|
+
return false unless EvidenceValue.type?(correlation, Hash)
|
|
160
|
+
|
|
161
|
+
session = Hash.instance_method(:fetch).bind_call(correlation, 'session_id', nil)
|
|
162
|
+
EvidenceValue.type?(session, String) && !String.new(session).strip.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def supported(event, features)
|
|
166
|
+
payload = event['payload']
|
|
167
|
+
if event['event_type'] == 'diagnostic_context'
|
|
168
|
+
feature = payload['kind'] == 'recovery' ? 'recovery_v1' : 'resource_observations_v1'
|
|
169
|
+
return features.include?(feature) ? event : nil
|
|
170
|
+
end
|
|
171
|
+
filtered = payload.dup
|
|
172
|
+
filtered = filtered.except('exception_evidence', 'execution') unless features.include?('exception_evidence_v1')
|
|
173
|
+
filtered.delete('resource_observations') unless features.include?('resource_observations_v1')
|
|
174
|
+
result = filtered == payload ? event : event.merge('payload' => filtered)
|
|
175
|
+
context = event['context']
|
|
176
|
+
reference_disabled = !features.intersect?(%w[recovery_v1 resource_observations_v1])
|
|
177
|
+
if reference_disabled && context.is_a?(Hash) && reference(context['evidence_reference'])
|
|
178
|
+
result = result.merge('context' => context.except('evidence_reference'))
|
|
179
|
+
end
|
|
180
|
+
result
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def without_optional(event)
|
|
184
|
+
return nil unless event.is_a?(Hash) && %w[backend_exception frontend_exception].include?(event['event_type'])
|
|
185
|
+
|
|
186
|
+
payload = event['payload']
|
|
187
|
+
return nil unless payload.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
stripped = payload.except('exception_evidence', 'execution', 'resource_observations')
|
|
190
|
+
stripped == payload ? nil : event.merge('payload' => stripped)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|