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.
- checksums.yaml +4 -4
- data/README.md +131 -1
- 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 +50 -67
- 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 +64 -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/exception_identity.rb +42 -0
- data/lib/debugbundle/framework_operation.rb +13 -0
- data/lib/debugbundle/logging.rb +2 -1
- 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 +43 -5
- data/lib/debugbundle/transport.rb +43 -17
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +11 -1
- 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
|
data/lib/debugbundle/logging.rb
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|