debugbundle 2.0.1 → 2.1.1

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +131 -1
  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 +50 -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 +64 -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/exception_identity.rb +42 -0
  24. data/lib/debugbundle/framework_operation.rb +13 -0
  25. data/lib/debugbundle/logging.rb +2 -1
  26. data/lib/debugbundle/operation_context.rb +90 -0
  27. data/lib/debugbundle/rack/middleware.rb +24 -21
  28. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  29. data/lib/debugbundle/rails/railtie.rb +4 -1
  30. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  31. data/lib/debugbundle/relay/forwarder.rb +83 -0
  32. data/lib/debugbundle/relay/handler.rb +83 -19
  33. data/lib/debugbundle/remote_config.rb +5 -0
  34. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  35. data/lib/debugbundle/telemetry_privacy.rb +43 -5
  36. data/lib/debugbundle/transport.rb +43 -17
  37. data/lib/debugbundle/version.rb +1 -1
  38. data/lib/debugbundle.rb +11 -1
  39. metadata +20 -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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'weakref'
4
+
5
+ module DebugBundle
6
+ class ExceptionIdentity
7
+ NATIVE_EQUAL = BasicObject.instance_method(:equal?)
8
+
9
+ def initialize
10
+ @entries = []
11
+ @lock = Mutex.new
12
+ end
13
+
14
+ def admit?(error, scope, automatic, accepted: false)
15
+ return true if scope.nil?
16
+ return true unless @lock.try_lock
17
+
18
+ begin
19
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ @entries.reject! { |entry| !entry[0].weakref_alive? || now - entry[3] >= 30 }
21
+ @entries.each do |entry|
22
+ next unless NATIVE_EQUAL.bind_call(entry[0].__getobj__, error) && entry[1] == scope
23
+
24
+ if !accepted && automatic != entry[2]
25
+ @entries.delete(entry)
26
+ return false
27
+ end
28
+ @entries.delete(entry) if accepted
29
+ end
30
+ if accepted
31
+ @entries.shift if @entries.length >= 128
32
+ @entries << [WeakRef.new(error), scope, automatic, now]
33
+ end
34
+ true
35
+ rescue WeakRef::RefError
36
+ true
37
+ ensure
38
+ @lock.unlock
39
+ end
40
+ end
41
+ end
42
+ 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
@@ -44,7 +44,7 @@ module DebugBundle
44
44
  was_capturing = Thread.current[RECURSION_GUARD_KEY]
45
45
  Thread.current[RECURSION_GUARD_KEY] = true
46
46
  client.capture_log(
47
- message.to_s,
47
+ message.is_a?(Exception) ? message : message.to_s,
48
48
  level: LOGGER_LEVEL_NAMES.fetch(severity, :warning),
49
49
  context: { logger_name: progname }
50
50
  )
@@ -79,6 +79,7 @@ module DebugBundle
79
79
  level: log.level || :info,
80
80
  context: {
81
81
  logger_name: log.name,
82
+ exception: log.respond_to?(:exception) ? log.exception : nil,
82
83
  payload: log.payload,
83
84
  tags: log.tags
84
85
  }
@@ -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