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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +133 -0
  3. data/lib/debugbundle/acknowledgement.rb +6 -2
  4. data/lib/debugbundle/before_send.rb +58 -30
  5. data/lib/debugbundle/bounded_http_response.rb +24 -0
  6. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  7. data/lib/debugbundle/client.rb +55 -75
  8. data/lib/debugbundle/client_event_support.rb +11 -12
  9. data/lib/debugbundle/client_evidence_support.rb +169 -0
  10. data/lib/debugbundle/client_exception_support.rb +54 -0
  11. data/lib/debugbundle/client_process_support.rb +5 -12
  12. data/lib/debugbundle/client_queue_support.rb +59 -4
  13. data/lib/debugbundle/config.rb +15 -2
  14. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  15. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  16. data/lib/debugbundle/event_batch.rb +48 -0
  17. data/lib/debugbundle/evidence_delivery.rb +50 -0
  18. data/lib/debugbundle/evidence_graph.rb +73 -0
  19. data/lib/debugbundle/evidence_json.rb +55 -0
  20. data/lib/debugbundle/evidence_resources.rb +60 -0
  21. data/lib/debugbundle/evidence_value.rb +107 -0
  22. data/lib/debugbundle/evidence_wire.rb +193 -0
  23. data/lib/debugbundle/exception_evidence.rb +179 -0
  24. data/lib/debugbundle/framework_operation.rb +13 -0
  25. data/lib/debugbundle/operation_context.rb +90 -0
  26. data/lib/debugbundle/rack/middleware.rb +24 -21
  27. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  28. data/lib/debugbundle/rails/railtie.rb +4 -1
  29. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  30. data/lib/debugbundle/relay/forwarder.rb +83 -0
  31. data/lib/debugbundle/relay/handler.rb +83 -19
  32. data/lib/debugbundle/remote_config.rb +5 -0
  33. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  34. data/lib/debugbundle/telemetry_privacy.rb +9 -1
  35. data/lib/debugbundle/transport.rb +49 -19
  36. data/lib/debugbundle/version.rb +1 -1
  37. data/lib/debugbundle.rb +11 -1
  38. 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