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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +127 -0
  3. data/lib/debugbundle/before_send.rb +58 -30
  4. data/lib/debugbundle/bounded_http_response.rb +24 -0
  5. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  6. data/lib/debugbundle/client.rb +41 -67
  7. data/lib/debugbundle/client_event_support.rb +11 -12
  8. data/lib/debugbundle/client_evidence_support.rb +169 -0
  9. data/lib/debugbundle/client_exception_support.rb +54 -0
  10. data/lib/debugbundle/client_process_support.rb +5 -12
  11. data/lib/debugbundle/client_queue_support.rb +59 -4
  12. data/lib/debugbundle/config.rb +15 -2
  13. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  14. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  15. data/lib/debugbundle/event_batch.rb +48 -0
  16. data/lib/debugbundle/evidence_delivery.rb +50 -0
  17. data/lib/debugbundle/evidence_graph.rb +73 -0
  18. data/lib/debugbundle/evidence_json.rb +55 -0
  19. data/lib/debugbundle/evidence_resources.rb +60 -0
  20. data/lib/debugbundle/evidence_value.rb +107 -0
  21. data/lib/debugbundle/evidence_wire.rb +193 -0
  22. data/lib/debugbundle/exception_evidence.rb +179 -0
  23. data/lib/debugbundle/framework_operation.rb +13 -0
  24. data/lib/debugbundle/operation_context.rb +90 -0
  25. data/lib/debugbundle/rack/middleware.rb +24 -21
  26. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  27. data/lib/debugbundle/rails/railtie.rb +4 -1
  28. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  29. data/lib/debugbundle/relay/forwarder.rb +83 -0
  30. data/lib/debugbundle/relay/handler.rb +83 -19
  31. data/lib/debugbundle/remote_config.rb +5 -0
  32. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  33. data/lib/debugbundle/telemetry_privacy.rb +9 -1
  34. data/lib/debugbundle/transport.rb +43 -17
  35. data/lib/debugbundle/version.rb +1 -1
  36. data/lib/debugbundle.rb +11 -1
  37. metadata +19 -2
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'debugbundle/telemetry_privacy'
6
+ require 'debugbundle/evidence_value'
7
+
8
+ module DebugBundle
9
+ # Consume the existing protected native snapshots, never an Exception or its
10
+ # formatter. Even MRI's bound Exception#to_s converts a non-string message;
11
+ # new inspection must not introduce another such application callback.
12
+ module ExceptionEvidence
13
+ module_function
14
+
15
+ def native_type?(value, type)
16
+ EvidenceValue.type?(value, type)
17
+ end
18
+
19
+ def owned_text(value, limit, scrub: true, state: nil)
20
+ return nil unless Object.instance_method(:is_a?).bind_call(value, String)
21
+
22
+ copy = String.new(value)
23
+ state[:truncated] = true if state && (copy.bytesize > limit || !copy.valid_encoding?)
24
+ copy = copy.byteslice(0, limit).force_encoding(Encoding::UTF_8).scrub
25
+ # A split UTF-8 sequence can expand to a replacement character.
26
+ copy = copy.byteslice(0, limit).force_encoding(Encoding::UTF_8).scrub('')
27
+ copy = TelemetryPrivacy.protect(copy) if scrub
28
+ state[:truncated] = true if state && copy.bytesize > limit
29
+ copy.byteslice(0, limit).force_encoding(Encoding::UTF_8).scrub('')
30
+ end
31
+
32
+ def retained_snapshot(value, state)
33
+ return nil unless native_type?(value, Hash)
34
+ return nil if Hash.instance_method(:size).bind_call(value) > 32
35
+
36
+ keys = Hash.instance_method(:keys).bind_call(value)
37
+ return nil unless keys.all? { |key| native_type?(key, String) }
38
+
39
+ result = {}
40
+ { 'name' => 256, 'message' => 2048, 'stack' => 16_384 }.each do |key, limit|
41
+ source = Hash.instance_method(:fetch).bind_call(value, key, nil)
42
+ # Parse coordinates before a URL scrub can discard a fragment containing
43
+ # the native line suffix. Only scrubbed fields/text enter the graph.
44
+ result[key] = owned_text(source, limit, scrub: key != 'stack', state: state)
45
+ return nil if result[key].nil?
46
+ end
47
+ result['name'] = 'Exception' if result['name'].empty?
48
+ result['message'] = '[message unavailable]' if result['message'].empty?
49
+ result
50
+ end
51
+
52
+ def from_retained(root:, causes:, fatal: false, budget_bytes: 32_768)
53
+ maximum = [fatal ? 4096 : 32_768, budget_bytes].min
54
+ return nil if maximum < 512
55
+
56
+ state = { truncated: false }
57
+ outer = retained_snapshot(root, state)
58
+ return nil unless outer && native_type?(causes, Array)
59
+
60
+ limit = fatal ? 2 : 5 # A linear cause path has at most four edges.
61
+ count = Array.instance_method(:length).bind_call(causes)
62
+ snapshots = [outer]
63
+ [count, limit - 1].min.times do |index|
64
+ cause = retained_snapshot(Array.instance_method(:[]).bind_call(causes, index), state)
65
+ break unless cause
66
+
67
+ snapshots << cause
68
+ end
69
+ graph = build_graph(snapshots, count > snapshots.length - 1 || state[:truncated])
70
+ shrink_metadata(graph, maximum)
71
+ return nil if JSON.generate(graph).bytesize > maximum
72
+
73
+ append_traces(graph, snapshots, maximum, fatal ? 8 : 32)
74
+ graph
75
+ rescue StandardError
76
+ nil
77
+ end
78
+
79
+ def build_graph(snapshots, truncated)
80
+ nodes = snapshots.each_with_index.map do |snapshot, index|
81
+ {
82
+ 'id' => index, 'name' => snapshot['name'], 'message' => snapshot['message'], 'stack' => nil,
83
+ 'trace_origin' => snapshot['stack'].empty? ? 'unavailable' : 'runtime_reported',
84
+ 'parsing_status' => snapshot['stack'].empty? ? 'unavailable' : 'unparsed', 'frames' => [],
85
+ 'relationships' => index + 1 < snapshots.length ? [{ 'kind' => 'cause', 'target_id' => index + 1 }] : []
86
+ }
87
+ end
88
+ { 'version' => 1, 'runtime' => 'ruby', 'root_id' => 0, 'nodes' => nodes,
89
+ 'truncated' => truncated, 'unavailable_reason' => nil }
90
+ end
91
+
92
+ def shrink_metadata(graph, maximum)
93
+ return if JSON.generate(graph).bytesize <= maximum - 32
94
+
95
+ mark_budget(graph)
96
+ graph['nodes'].each do |node|
97
+ node['name'] = owned_text(node['name'], 64)
98
+ node['message'] = owned_text(node['message'], 128)
99
+ end
100
+ while graph['nodes'].length > 1 && JSON.generate(graph).bytesize > maximum - 32
101
+ graph['nodes'].pop
102
+ graph['nodes'].last['relationships'] = []
103
+ end
104
+ end
105
+
106
+ def append_traces(graph, snapshots, maximum, frame_limit)
107
+ # Charge exact JSON fragments incrementally. Re-encoding the whole graph
108
+ # for every rejected frame would amplify work on adversarial long stacks.
109
+ used = JSON.generate(graph).bytesize + 32
110
+ # A bounded graph often repeats one file/function across many frames.
111
+ # Reuse protected text only within this projection; returned strings remain
112
+ # independently owned so a hook cannot mutate another frame through aliases.
113
+ text_cache = {}
114
+ graph['nodes'].each_with_index do |node, index|
115
+ stack = snapshots[index]['stack']
116
+ stack.each_line do |line|
117
+ frame = parse_frame(line, text_cache)
118
+ next unless frame
119
+
120
+ cost = JSON.generate(frame).bytesize + (node['frames'].empty? ? 0 : 1)
121
+ if node['frames'].length >= frame_limit || used + cost > maximum
122
+ node['parsing_status'] = 'truncated'
123
+ mark_budget(graph)
124
+ break
125
+ end
126
+ node['frames'] << frame
127
+ node['parsing_status'] = 'parsed'
128
+ used += cost
129
+ end
130
+ next unless node['parsing_status'] == 'unparsed'
131
+
132
+ retained = owned_text(stack, 16_384).gsub(%r{\bhttps?://[^\s<>"']+}i) { |url| frame_file(url) }
133
+ cost = JSON.generate(retained).bytesize - 4 # Replaces JSON null.
134
+ if used + cost <= maximum
135
+ node['stack'] = retained
136
+ used += cost
137
+ else
138
+ mark_budget(graph)
139
+ end
140
+ end
141
+ end
142
+
143
+ def parse_frame(line, text_cache = nil)
144
+ match = /\A(.+):([1-9]\d*)(?::in [`']([^`'\r\n]*)[`'])?\z/.match(line.strip)
145
+ return nil unless match
146
+
147
+ { 'function' => match[3] && frame_text(match[3], 256, text_cache),
148
+ 'file' => frame_text(match[1], 2048, text_cache),
149
+ 'line' => match[2].to_i, 'column' => nil, 'classification' => 'unknown' }
150
+ end
151
+
152
+ def frame_text(value, limit, cache)
153
+ return limit == 2048 ? frame_file(value) : owned_text(value, limit) unless cache
154
+
155
+ key = [limit, value]
156
+ cache[key] ||= (limit == 2048 ? frame_file(value) : owned_text(value, limit)).freeze
157
+ cache[key].dup
158
+ end
159
+
160
+ def frame_file(value)
161
+ result = owned_text(value, 2048)
162
+ return result unless result.match?(%r{\Ahttps?://}i)
163
+
164
+ uri = URI.parse(result)
165
+ uri.password = nil
166
+ uri.user = nil
167
+ uri.query = nil
168
+ uri.fragment = nil
169
+ uri.to_s
170
+ rescue URI::InvalidURIError
171
+ '[withheld]'
172
+ end
173
+
174
+ def mark_budget(graph)
175
+ graph['truncated'] = true
176
+ graph['unavailable_reason'] = 'budget_exceeded'
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ module FrameworkOperation
5
+ def self.run(client, ...)
6
+ # Custom clients implementing the older injection surface keep their
7
+ # established application behavior without claiming scoped evidence.
8
+ return yield unless client.respond_to?(:with_operation_context)
9
+
10
+ client.with_operation_context(...)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'debugbundle/evidence_wire'
5
+ require 'debugbundle/telemetry_privacy'
6
+
7
+ module DebugBundle
8
+ # Thread#[] is Fiber-local on the supported Ruby floor. New Fibers and threads
9
+ # do not inherit request ownership; completed/closed generations are revoked.
10
+ class OperationContext
11
+ def initialize
12
+ @key = :"__debugbundle_operation_#{object_id}"
13
+ @generation = 0
14
+ end
15
+
16
+ def current
17
+ lease = Thread.current[@key]
18
+ return nil unless lease && lease[:generation] == @generation && lease[:active]
19
+ return nil unless lease[:parents].all? { |parent| parent[:active] }
20
+
21
+ lease
22
+ end
23
+
24
+ def begin_scope(kind:, name: nil, limit_ms: nil, correlation: nil)
25
+ previous = current
26
+ parents = previous ? previous[:parents] + [previous] : []
27
+ return nil if parents.length > 15
28
+
29
+ owned_kind = EvidenceValue.options(kind)
30
+ owned_name = EvidenceValue.copy(name)
31
+ owned_limit = EvidenceValue.copy(limit_ms)
32
+ owned_kind = 'unknown' unless %w[request command job unknown].include?(owned_kind)
33
+ unless EvidenceWire.text(owned_name,
34
+ 128) && !owned_name.empty? && TelemetryPrivacy.protect(owned_name) == owned_name
35
+ owned_name = nil
36
+ end
37
+ owned_limit = nil unless EvidenceWire.number(owned_limit)
38
+ lease = { generation: @generation, active: true, parents: parents, previous: previous,
39
+ scope: { 'kind' => 'operation', 'operation_id' => SecureRandom.uuid },
40
+ kind: owned_kind, name: owned_name, limit: owned_limit,
41
+ correlation: scoped_correlation(previous, correlation),
42
+ started: Process.clock_gettime(Process::CLOCK_MONOTONIC) }
43
+ Thread.current[@key] = lease
44
+ lease
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def end_scope(lease)
50
+ return unless lease
51
+
52
+ lease[:active] = false
53
+ return unless Thread.current[@key].equal?(lease)
54
+
55
+ Thread.current[@key] = lease[:generation] == @generation ? lease[:previous] : nil
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def snapshot
61
+ lease = current
62
+ return nil unless lease
63
+
64
+ { 'version' => 1, 'kind' => lease[:kind], 'name' => lease[:name], 'limit_ms' => lease[:limit],
65
+ 'elapsed_ms' => [(Process.clock_gettime(Process::CLOCK_MONOTONIC) - lease[:started]) * 1000, 0].max }
66
+ end
67
+
68
+ def scope = current&.fetch(:scope)&.dup
69
+
70
+ def scoped_correlation(previous, input)
71
+ owned = EvidenceValue.options(input)
72
+ selected = {}
73
+ if owned.is_a?(Hash)
74
+ %w[request_id trace_id session_id user_id_hash].each do |field|
75
+ value = owned[field]
76
+ next unless EvidenceWire.text(value, 256) && !value.strip.empty?
77
+ next unless TelemetryPrivacy.protect(value) == value
78
+
79
+ selected[field] = value
80
+ end
81
+ end
82
+ (previous ? previous[:correlation] : {}).merge(selected)
83
+ end
84
+
85
+ def clear
86
+ @generation += 1
87
+ Thread.current[@key] = nil
88
+ end
89
+ end
90
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
+ require 'debugbundle/framework_operation'
4
5
 
5
6
  module DebugBundle
6
7
  module Rack
@@ -13,30 +14,32 @@ module DebugBundle
13
14
  def call(env)
14
15
  request_context = build_request_context(env)
15
16
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
- @client.with_request_trigger(request_context.fetch(:request)) do
17
- status, headers, body = @app.call(env)
18
- duration_ms = elapsed_ms(started_at)
17
+ FrameworkOperation.run(@client, kind: 'request', correlation: request_context.slice(:request_id, :trace_id)) do
18
+ @client.with_request_trigger(request_context.fetch(:request)) do
19
+ status, headers, body = @app.call(env)
20
+ duration_ms = elapsed_ms(started_at)
19
21
 
20
- @client.capture_request(
21
- request_context.fetch(:request),
22
- { status_code: status, headers: headers.to_h },
23
- context: request_context.merge(duration_ms: duration_ms, route_template: route_template(env))
24
- )
22
+ @client.capture_request(
23
+ request_context.fetch(:request),
24
+ { status_code: status, headers: headers.to_h },
25
+ context: request_context.merge(duration_ms: duration_ms, route_template: route_template(env))
26
+ )
25
27
 
26
- [status, headers, body]
28
+ [status, headers, body]
29
+ end
30
+ rescue StandardError => e
31
+ duration_ms = elapsed_ms(started_at)
32
+ @client.capture_exception(
33
+ e,
34
+ context: request_context.merge(
35
+ response: { status_code: 500, headers: {} },
36
+ duration_ms: duration_ms,
37
+ route_template: route_template(env)
38
+ ),
39
+ handled: false
40
+ )
41
+ raise
27
42
  end
28
- rescue StandardError => e
29
- duration_ms = elapsed_ms(started_at)
30
- @client.capture_exception(
31
- e,
32
- context: request_context.merge(
33
- response: { status_code: 500, headers: {} },
34
- duration_ms: duration_ms,
35
- route_template: route_template(env)
36
- ),
37
- handled: false
38
- )
39
- raise
40
43
  end
41
44
 
42
45
  private
@@ -11,10 +11,13 @@ module DebugBundle
11
11
  end
12
12
 
13
13
  def call(env)
14
+ limit = @handler.respond_to?(:max_body_bytes) ? @handler.max_body_bytes : Relay::DEFAULT_MAX_BODY_BYTES
15
+ limit = Relay::DEFAULT_MAX_BODY_BYTES unless limit.is_a?(Integer) && limit.positive?
14
16
  response = @handler.handle(
15
17
  method: env['REQUEST_METHOD'],
18
+ url: "#{env['PATH_INFO']}?#{env['QUERY_STRING']}",
16
19
  headers: relay_headers(env),
17
- body: env.fetch('rack.input', StringIO.new).read,
20
+ body: env.fetch('rack.input', StringIO.new).read(limit + 1),
18
21
  ip_address: env['REMOTE_ADDR']
19
22
  )
20
23
 
@@ -17,7 +17,10 @@ if defined?(Rails::Railtie)
17
17
  project_mode: options.project_mode || :connected,
18
18
  local_events_dir: options.local_events_dir || DebugBundle::Config::DEFAULT_LOCAL_EVENTS_DIR,
19
19
  endpoint: options.endpoint || DebugBundle::Config::DEFAULT_ENDPOINT,
20
- redact_fields: Array(options.redact_fields) + Array(app.config.filter_parameters)
20
+ redact_fields: Array(options.redact_fields) + Array(app.config.filter_parameters),
21
+ capture_exception_evidence: options.capture_exception_evidence != false,
22
+ capture_recovery_observations: options.capture_recovery_observations == true,
23
+ evidence_capabilities: options.evidence_capabilities
21
24
  )
22
25
 
23
26
  app.middleware.use(DebugBundle::Rack::Middleware, client: client)
@@ -36,7 +36,8 @@ module DebugBundle
36
36
  max_body_bytes: relay_option(options, :relay_max_body_bytes, Relay::DEFAULT_MAX_BODY_BYTES),
37
37
  rate_limit_per_minute: relay_rate_limit(options),
38
38
  rate_limit_store: relay_option(options, :relay_rate_limit_store, nil),
39
- forward_transport: relay_option(options, :relay_forward_transport, nil)
39
+ forward_transport: relay_option(options, :relay_forward_transport, nil),
40
+ evidence_capabilities: relay_option(options, :relay_evidence_capabilities, nil)
40
41
  )
41
42
  end
42
43
 
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'debugbundle/evidence_delivery'
4
+ require 'debugbundle/event_batch'
5
+
6
+ module DebugBundle
7
+ module Relay
8
+ class Forwarder
9
+ def initialize(transport, token, evidence)
10
+ @transport = transport
11
+ @token = token
12
+ @evidence = evidence
13
+ end
14
+
15
+ # The second result permits marking a spool delivered only after complete
16
+ # canonical acceptance. Legacy custom-transport acceptance stays compatible.
17
+ def send(events, discover:)
18
+ return [false, false] unless @token.is_a?(String) && !@token.empty?
19
+
20
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
21
+ complete = true
22
+ features = EvidenceWire::FEATURES.dup
23
+ owned = events.map { |event| event.merge('project_token' => @token) }
24
+ EventBatch.chunks(owned).each do |chunk|
25
+ chunk = chunk.map { |event| EvidenceWire.supported(event, @evidence.features) }
26
+ return [false, false] if chunk.any?(&:nil?)
27
+
28
+ return [false, false] if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
29
+
30
+ success, canonical = send_chunk(chunk, discover: discover, deadline: deadline)
31
+ features &= @evidence.features
32
+ @evidence.restrict(features)
33
+ return [false, false] unless success && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
34
+
35
+ complete &&= canonical
36
+ end
37
+ [true, complete]
38
+ rescue StandardError
39
+ [false, false]
40
+ end
41
+
42
+ def send_chunk(events, discover:, deadline:)
43
+ required = discover || @transport.is_a?(Transport::HttpTransport)
44
+ response, ack, lost = forward(events, required, deadline)
45
+ return [false, false] unless response && ack
46
+ return [true, false] if ack[:kind] == :legacy
47
+ return [false, false] unless ack[:kind] == :acknowledged
48
+ return [true, true] if ack[:accepted] == events.length
49
+ return [false, false] unless lost
50
+
51
+ retry_events = ack.fetch(:terminal_errors).filter_map do |index, reason|
52
+ original = events.fetch(index)
53
+ next unless reason == 'invalid_event' && original['event_type'] == 'frontend_exception'
54
+
55
+ reduced = EvidenceWire.supported(original, @evidence.features)
56
+ reduced if reduced != original
57
+ end
58
+ return [false, false] if retry_events.empty?
59
+
60
+ _reply, retry_ack, = forward(retry_events, true, deadline)
61
+ complete = retry_ack && retry_ack[:kind] == :acknowledged &&
62
+ ack[:accepted] + retry_ack[:accepted] == events.length
63
+ [!!complete, !!complete]
64
+ rescue StandardError
65
+ [false, false]
66
+ end
67
+
68
+ def forward(events, required, deadline)
69
+ return [nil, nil, false] if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
70
+
71
+ owned = events.map { |event| event.merge('project_token' => @token) }
72
+ request = { project_token: @token, events: owned }
73
+ request[:deadline] = deadline if @transport.is_a?(Transport::HttpTransport)
74
+ response = Transport.coerce_result(@transport.call(request))
75
+ return [nil, nil, false] unless response.status_code.between?(200, 299)
76
+
77
+ ack = Acknowledgement.decide(response.body, events.length, required: required)
78
+ lost = @evidence.observe(response.body, required: required, valid: ack[:kind] == :acknowledged)
79
+ [response, ack, lost]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'uri'
5
+ require 'debugbundle/relay/forwarder'
5
6
 
6
7
  module DebugBundle
7
8
  module Relay
@@ -12,6 +13,7 @@ module DebugBundle
12
13
  request_event
13
14
  probe_event
14
15
  analytics_event
16
+ diagnostic_context
15
17
  ].freeze
16
18
  BROWSER_SDK_NAME = '@debugbundle/sdk-browser'
17
19
  DEFAULT_MAX_BODY_BYTES = 262_144
@@ -20,6 +22,8 @@ module DebugBundle
20
22
  Response = Struct.new(:status, :body, :headers, keyword_init: true)
21
23
 
22
24
  class Handler
25
+ attr_reader :max_body_bytes
26
+
23
27
  def initialize(
24
28
  project_mode: :connected,
25
29
  project_token: nil,
@@ -33,7 +37,8 @@ module DebugBundle
33
37
  max_body_bytes: DEFAULT_MAX_BODY_BYTES,
34
38
  rate_limit_per_minute: DEFAULT_RATE_LIMIT_PER_MINUTE,
35
39
  rate_limit_store: nil,
36
- forward_transport: nil
40
+ forward_transport: nil,
41
+ evidence_capabilities: nil
37
42
  )
38
43
  @project_mode = project_mode.to_sym
39
44
  @project_token = project_token
@@ -48,16 +53,24 @@ module DebugBundle
48
53
  @rate_limit_per_minute = rate_limit_per_minute
49
54
  @rate_limit_store = rate_limit_store
50
55
  @forward_transport = forward_transport || Transport::HttpTransport.new(@endpoint)
56
+ @evidence = EvidenceDelivery.new(evidence_capabilities)
57
+ @forwarder = Forwarder.new(@forward_transport, @project_token, @evidence)
51
58
  @rate_limit_state = Hash.new { |hash, key| hash[key] = [] }
52
59
  end
53
60
 
54
61
  def handle(request)
55
62
  method = request.fetch(:method, 'POST').to_s.upcase
56
63
  headers = normalize_headers(request[:headers] || {})
64
+ discover = requests_evidence?(request, headers)
57
65
  raw_body = request[:body].to_s
58
66
  return Response.new(status: 403, body: nil, headers: {}) unless origin_allowed?(headers)
59
67
 
60
- response_headers = source_origin(headers) ? cors_headers(source_origin(headers)) : {}
68
+ response_headers = if source_origin(headers)
69
+ cors_headers(source_origin(headers),
70
+ headers['access-control-request-headers'])
71
+ else
72
+ {}
73
+ end
61
74
  early_response = request_validation_response(
62
75
  method: method,
63
76
  headers: headers,
@@ -66,11 +79,17 @@ module DebugBundle
66
79
  )
67
80
  return with_headers(early_response, response_headers) if early_response
68
81
 
69
- accepted, errors = sanitize_batch(raw_body)
82
+ accepted, errors, indexed = sanitize_batch(raw_body)
70
83
 
71
- deliver(accepted) unless accepted.empty?
84
+ deliver(accepted, discover: discover) unless accepted.empty?
72
85
 
73
- with_headers(batch_response(accepted, errors), response_headers)
86
+ response = batch_response(accepted, errors)
87
+ if discover
88
+ response.status = 202
89
+ response.body['evidence_capabilities'] = @evidence.descriptor
90
+ response.body['evidence_errors'] = indexed unless indexed.empty?
91
+ end
92
+ with_headers(response, response_headers)
74
93
  rescue JSON::ParserError
75
94
  with_headers(invalid_json_response, response_headers || {})
76
95
  rescue KeyError
@@ -130,27 +149,44 @@ module DebugBundle
130
149
  )
131
150
  end
132
151
 
133
- def cors_headers(origin)
152
+ def cors_headers(origin, requested = nil)
153
+ allow_evidence = requested.to_s.downcase.split(',').map(&:strip).include?('x-debugbundle-evidence')
134
154
  {
135
155
  'Access-Control-Allow-Origin' => origin,
136
156
  'Access-Control-Allow-Methods' => 'POST, OPTIONS',
137
- 'Access-Control-Allow-Headers' => 'content-type',
157
+ 'Access-Control-Allow-Headers' => allow_evidence ? 'content-type, x-debugbundle-evidence' : 'content-type',
138
158
  'Access-Control-Max-Age' => '600',
139
- 'Vary' => 'Origin'
159
+ 'Vary' => allow_evidence ? 'Origin, Access-Control-Request-Headers' : 'Origin'
140
160
  }
141
161
  end
142
162
 
143
- def deliver(events)
163
+ def requests_evidence?(request, headers)
164
+ return true if headers['x-debugbundle-evidence'] == '1'
165
+
166
+ url = request[:url]
167
+ return false unless url.is_a?(String) && url.bytesize <= 4096
168
+
169
+ URI.decode_www_form(URI.parse(url).query || '').include?(%w[debugbundle_evidence 1])
170
+ rescue URI::InvalidURIError, ArgumentError
171
+ false
172
+ end
173
+
174
+ def deliver(events, discover:)
144
175
  service_name = @service || events.first.dig('service', 'name') || 'service'
145
176
 
146
177
  case @project_mode
147
178
  when :local_only
148
- Transport::FileTransport.new(@local_events_dir).call(service_name: service_name, events: events)
179
+ result = Transport::FileTransport.new(@local_events_dir).call(service_name: service_name, events: events)
180
+ raise 'relay_write_failed' unless result.status_code == 202
149
181
  when :connected
150
- Transport::FileTransport.new(@spool_dir).call(service_name: service_name, events: events) if @durable_write
151
-
152
- result = Transport.coerce_result(@forward_transport.call(project_token: @project_token, events: events))
153
- raise 'relay_forward_failed' unless result.status_code.between?(200, 299)
182
+ if @durable_write
183
+ spool = Transport::FileTransport.new(@spool_dir)
184
+ written = spool.call(service_name: service_name, events: events)
185
+ raise 'relay_write_failed' unless written.status_code == 202
186
+ end
187
+ success, complete = @forwarder.send(events, discover: discover)
188
+ spool.mark_delivered(written.written_file_path) if complete && spool && written.written_file_path
189
+ raise 'relay_forward_failed' unless success || @durable_write
154
190
  else
155
191
  raise ArgumentError, 'unsupported relay project mode'
156
192
  end
@@ -161,12 +197,16 @@ module DebugBundle
161
197
  batch = decoded.fetch('batch')
162
198
  raise KeyError unless batch.is_a?(Array)
163
199
 
164
- batch.each_with_index.with_object([[], []]) do |(candidate, index), (accepted, errors)|
200
+ batch.each_with_index.with_object([[], [], []]) do |(candidate, index), (accepted, errors, indexed)|
165
201
  sanitized = sanitize_event(candidate)
202
+ if sanitized && sanitized['event_type'] != 'analytics_event'
203
+ sanitized = EvidenceWire.supported(sanitized, @evidence.features)
204
+ end
166
205
  if sanitized
167
206
  accepted << sanitized
168
207
  else
169
208
  errors << "batch[#{index}]: Invalid browser relay event payload."
209
+ indexed << { 'index' => index, 'reason' => 'invalid_event' }
170
210
  end
171
211
  end
172
212
  end
@@ -194,19 +234,43 @@ module DebugBundle
194
234
  'environment' => @environment || service['environment'].to_s
195
235
  },
196
236
  'correlation' => sanitize_correlation(correlation, event_type),
197
- 'payload' => payload,
198
- 'project_token' => @project_token
237
+ 'payload' => payload
199
238
  }
239
+ sanitized['project_token'] = @project_token if @project_token.is_a?(String) && !@project_token.empty?
240
+ sanitized['context'] = candidate['context'] if candidate['context'].is_a?(Hash)
241
+ return nil unless EvidenceWire.valid_optional?(sanitized)
242
+
243
+ new_contract = uses_evidence_contract?(sanitized)
244
+ return nil if new_contract && !BeforeSend.valid?(sanitized)
245
+
246
+ protect_relay_event(sanitized, new_contract)
247
+ rescue StandardError
248
+ nil
249
+ end
250
+
251
+ def protect_relay_event(sanitized, new_contract)
200
252
  return nil unless TelemetryPrivacy.safe_event_identity?(sanitized)
201
253
 
202
- fields = TelemetryPrivacy.protect({ 'service' => sanitized['service'], 'payload' => sanitized['payload'] })
254
+ fields = TelemetryPrivacy.protect(sanitized.slice('service', 'payload', 'context'))
203
255
  return nil unless fields['service'].is_a?(Hash) && fields['payload'].is_a?(Hash)
204
256
 
205
- sanitized.merge('service' => fields['service'], 'payload' => fields['payload'])
257
+ protected = sanitized.merge(fields)
258
+ if EvidenceWire.valid_optional?(protected) && (!new_contract || BeforeSend.valid?(protected))
259
+ return EventBatch.fit(protected)
260
+ end
261
+
262
+ reduced = EvidenceWire.without_optional(protected)
263
+ EventBatch.fit(reduced) if reduced && BeforeSend.valid?(reduced)
206
264
  rescue StandardError
207
265
  nil
208
266
  end
209
267
 
268
+ def uses_evidence_contract?(event)
269
+ event['event_type'] == 'diagnostic_context' ||
270
+ %w[exception_evidence resource_observations execution].any? { |field| event['payload'].key?(field) } ||
271
+ !EvidenceWire.reference(event.dig('context', 'evidence_reference')).nil?
272
+ end
273
+
210
274
  def sanitize_correlation(value, event_type)
211
275
  correlation = value.is_a?(Hash) ? value : {}
212
276
  keys = if event_type == 'analytics_event'