bitfab 0.51.2 → 0.51.3
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 +49 -0
- data/lib/bitfab/client.rb +117 -21
- data/lib/bitfab/span_context.rb +6 -2
- data/lib/bitfab/subtree.rb +774 -0
- data/lib/bitfab/traceable.rb +253 -6
- data/lib/bitfab/version.rb +1 -1
- metadata +2 -1
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Bitfab
|
|
8
|
+
# Captures Ruby method calls beneath one traced root via TracePoint.
|
|
9
|
+
module Subtree
|
|
10
|
+
DEFAULT_MAX_DEPTH = 30
|
|
11
|
+
DEFAULT_MAX_SPANS = 500
|
|
12
|
+
SESSION_KEY = :bitfab_subtree_session
|
|
13
|
+
DISPATCHER_KEY = :bitfab_subtree_dispatcher
|
|
14
|
+
FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
|
|
15
|
+
REALPATH_CACHE = {}
|
|
16
|
+
REALPATH_MUTEX = Mutex.new
|
|
17
|
+
SDK_PREFIX = "#{File.realpath(File.expand_path("..", __dir__))}#{File::SEPARATOR}".freeze
|
|
18
|
+
|
|
19
|
+
CaptureConfig = Struct.new(:root, :root_path, :exclude, keyword_init: true)
|
|
20
|
+
FiberContext = Struct.new(:session, :fiber, :thread, :span_stack, :inherited_span, :parent, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
Boundary = Struct.new(
|
|
23
|
+
:receiver,
|
|
24
|
+
:method_name,
|
|
25
|
+
:span_id,
|
|
26
|
+
:counts_toward_depth,
|
|
27
|
+
:skip_next_call,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
MethodMetadata = Struct.new(
|
|
32
|
+
:identity,
|
|
33
|
+
:lineno,
|
|
34
|
+
:name,
|
|
35
|
+
:excluded,
|
|
36
|
+
:passthrough,
|
|
37
|
+
keyword_init: true
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
OpenSpan = Struct.new(
|
|
41
|
+
:identity,
|
|
42
|
+
:span_id,
|
|
43
|
+
:parent_span_id,
|
|
44
|
+
:name,
|
|
45
|
+
:args,
|
|
46
|
+
:kwargs,
|
|
47
|
+
:started_at,
|
|
48
|
+
:error,
|
|
49
|
+
keyword_init: true
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def prepare(root_source:, exclude:)
|
|
55
|
+
path = root_source[0]
|
|
56
|
+
root = first_party_root(path)
|
|
57
|
+
CaptureConfig.new(
|
|
58
|
+
root: root && with_separator(realpath(root)),
|
|
59
|
+
root_path: path,
|
|
60
|
+
exclude: exclude.to_h { |name| [name.to_s, true] }.freeze
|
|
61
|
+
).freeze
|
|
62
|
+
rescue
|
|
63
|
+
CaptureConfig.new(root: nil, root_path: nil, exclude: {}.freeze).freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def capture(client:, trace_function_key:, config:, max_depth:, max_spans:, include_wrappers:,
|
|
67
|
+
root_receiver:, root_method_name:)
|
|
68
|
+
return yield unless config.root
|
|
69
|
+
|
|
70
|
+
span = SpanContext.current || ReplayContext.current_span
|
|
71
|
+
return yield unless span
|
|
72
|
+
|
|
73
|
+
session = begin
|
|
74
|
+
replay = ReplayContext.current
|
|
75
|
+
Session.new(
|
|
76
|
+
client:,
|
|
77
|
+
trace_function_key:,
|
|
78
|
+
trace_id: span[:trace_id],
|
|
79
|
+
root_span_id: span[:span_id],
|
|
80
|
+
test_run_id: replay&.dig(:test_run_id),
|
|
81
|
+
input_source_span_id: replay&.dig(:input_source_span_id),
|
|
82
|
+
config:,
|
|
83
|
+
max_depth:,
|
|
84
|
+
max_spans:,
|
|
85
|
+
include_wrappers:,
|
|
86
|
+
root_receiver:,
|
|
87
|
+
root_method_name:
|
|
88
|
+
)
|
|
89
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
90
|
+
return yield
|
|
91
|
+
end
|
|
92
|
+
result = session.run(defer_enumerator: true) { yield }
|
|
93
|
+
result.is_a?(Enumerator) ? session.wrap_enumerator(result) : result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def current_session
|
|
97
|
+
Thread.current[SESSION_KEY] || fiber_context&.session
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fiber_context
|
|
101
|
+
context = Fiber[FIBER_CONTEXT_KEY]
|
|
102
|
+
context = context.parent while context && !context.session&.active?
|
|
103
|
+
context if context && context.thread.equal?(Thread.current)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def with_borrowed_span(trace_id:, span_id:, receiver:, method_name:)
|
|
107
|
+
sessions = active_sessions.select { |session| session.trace_id == trace_id }
|
|
108
|
+
return yield if sessions.empty?
|
|
109
|
+
|
|
110
|
+
with_boundaries(
|
|
111
|
+
sessions,
|
|
112
|
+
receiver:,
|
|
113
|
+
method_name:,
|
|
114
|
+
span_id:,
|
|
115
|
+
counts_toward_depth: true
|
|
116
|
+
) { yield }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def with_suppressed_node(receiver:, method_name:, sessions: active_sessions)
|
|
120
|
+
return yield if sessions.empty?
|
|
121
|
+
|
|
122
|
+
with_boundaries(
|
|
123
|
+
sessions,
|
|
124
|
+
receiver:,
|
|
125
|
+
method_name:,
|
|
126
|
+
span_id: nil,
|
|
127
|
+
counts_toward_depth: false
|
|
128
|
+
) { yield }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def active_sessions
|
|
132
|
+
sessions = []
|
|
133
|
+
session = current_session
|
|
134
|
+
while session
|
|
135
|
+
sessions << session
|
|
136
|
+
session = session.parent_session
|
|
137
|
+
end
|
|
138
|
+
sessions
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def current_parent_span_id(trace_id)
|
|
142
|
+
session = active_sessions.find { |candidate| candidate.trace_id == trace_id }
|
|
143
|
+
session&.current_parent_span_id
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def with_boundaries(sessions, receiver:, method_name:, span_id:, counts_toward_depth:)
|
|
147
|
+
boundaries = sessions.map do |session|
|
|
148
|
+
[session, session.push_boundary(
|
|
149
|
+
receiver:,
|
|
150
|
+
method_name:,
|
|
151
|
+
span_id:,
|
|
152
|
+
counts_toward_depth:
|
|
153
|
+
)]
|
|
154
|
+
end
|
|
155
|
+
yield
|
|
156
|
+
ensure
|
|
157
|
+
boundaries&.reverse_each { |session, boundary| session.pop_boundary(boundary) }
|
|
158
|
+
end
|
|
159
|
+
private_class_method :with_boundaries
|
|
160
|
+
|
|
161
|
+
def active_trace?(trace_function_key)
|
|
162
|
+
session = current_session
|
|
163
|
+
while session
|
|
164
|
+
return true if session.trace_function_key == trace_function_key
|
|
165
|
+
|
|
166
|
+
session = session.parent_session
|
|
167
|
+
end
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def dispatcher
|
|
172
|
+
Thread.current.thread_variable_get(DISPATCHER_KEY) || begin
|
|
173
|
+
value = Dispatcher.new
|
|
174
|
+
Thread.current.thread_variable_set(DISPATCHER_KEY, value)
|
|
175
|
+
value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def first_party_root(path)
|
|
180
|
+
return nil unless path && !path.start_with?("<")
|
|
181
|
+
|
|
182
|
+
resolved = realpath(path)
|
|
183
|
+
return nil unless resolved
|
|
184
|
+
|
|
185
|
+
if defined?(Gem)
|
|
186
|
+
gem_root = Gem.loaded_specs.values.map(&:full_gem_path).filter_map { |entry| realpath(entry) }
|
|
187
|
+
.select { |entry| resolved.start_with?(with_separator(entry)) }.max_by(&:length)
|
|
188
|
+
return gem_root if gem_root
|
|
189
|
+
|
|
190
|
+
Gem.path.each do |home|
|
|
191
|
+
prefix = with_separator(realpath(File.join(home, "gems")))
|
|
192
|
+
next unless prefix && resolved.start_with?(prefix)
|
|
193
|
+
|
|
194
|
+
return File.join(prefix, resolved.delete_prefix(prefix).split(File::SEPARATOR).first)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
directory = File.dirname(resolved)
|
|
199
|
+
candidate = directory
|
|
200
|
+
loop do
|
|
201
|
+
if File.file?(File.join(candidate, "Gemfile")) || !Dir.glob(File.join(candidate, "*.gemspec")).empty?
|
|
202
|
+
return candidate
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
parent = File.dirname(candidate)
|
|
206
|
+
break if parent == candidate
|
|
207
|
+
|
|
208
|
+
candidate = parent
|
|
209
|
+
end
|
|
210
|
+
directory
|
|
211
|
+
rescue SystemCallError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def realpath(path)
|
|
216
|
+
REALPATH_CACHE.fetch(path) do
|
|
217
|
+
REALPATH_MUTEX.synchronize do
|
|
218
|
+
REALPATH_CACHE.fetch(path) { REALPATH_CACHE[path] = File.realpath(path) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
rescue SystemCallError, TypeError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def with_separator(path)
|
|
226
|
+
return nil unless path
|
|
227
|
+
|
|
228
|
+
path.end_with?(File::SEPARATOR) ? path : "#{path}#{File::SEPARATOR}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
class Dispatcher
|
|
232
|
+
def initialize
|
|
233
|
+
@sessions = []
|
|
234
|
+
@frames = []
|
|
235
|
+
@current_sessions = []
|
|
236
|
+
@trace = TracePoint.new(:call, :return, :raise, :rescue, :fiber_switch) do |event|
|
|
237
|
+
if event.event == :fiber_switch
|
|
238
|
+
switch_fiber
|
|
239
|
+
else
|
|
240
|
+
@current_sessions.each { |session| session.handle(event) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def run(session)
|
|
246
|
+
previous_frame = Fiber[FIBER_CONTEXT_KEY]
|
|
247
|
+
# Fiber storage inherits the frame by reference; stacks remain local.
|
|
248
|
+
# Expired frames retain only their enclosing context, so children can
|
|
249
|
+
# keep participating in an outer capture without reviving an inner one.
|
|
250
|
+
frame = FiberContext.new(session:, fiber: Fiber.current, thread: Thread.current,
|
|
251
|
+
span_stack: SpanContext.stack, inherited_span: SpanContext.current, parent: Subtree.fiber_context)
|
|
252
|
+
Fiber[FIBER_CONTEXT_KEY] = frame
|
|
253
|
+
@frames.concat(session.deferred_frames)
|
|
254
|
+
session.deferred_frames.clear
|
|
255
|
+
@frames << frame
|
|
256
|
+
@sessions << session
|
|
257
|
+
@current_sessions = Subtree.active_sessions.reverse
|
|
258
|
+
@trace.enable(target_thread: Thread.current) unless @trace.enabled?
|
|
259
|
+
yield
|
|
260
|
+
ensure
|
|
261
|
+
@sessions.delete(session)
|
|
262
|
+
@sessions.each { |entry| entry.remove_parent(session) }
|
|
263
|
+
@frames.delete_if do |entry|
|
|
264
|
+
next false unless entry.session.equal?(session)
|
|
265
|
+
|
|
266
|
+
if session.deferred?
|
|
267
|
+
session.deferred_frames << entry
|
|
268
|
+
else
|
|
269
|
+
entry.session = entry.span_stack = entry.inherited_span = nil
|
|
270
|
+
end
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
Fiber[FIBER_CONTEXT_KEY] = previous_frame
|
|
274
|
+
@current_sessions = @current_sessions.reject { |entry| entry.equal?(session) }
|
|
275
|
+
@trace.disable if @sessions.empty?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def switch_fiber
|
|
281
|
+
context = Subtree.fiber_context
|
|
282
|
+
if context && !context.fiber.equal?(Fiber.current)
|
|
283
|
+
parent_fiber_id = context.fiber.object_id
|
|
284
|
+
inherited_span = context.span_stack.reverse_each.find { |span| span[:trace_id] == context.session.trace_id } ||
|
|
285
|
+
context.inherited_span
|
|
286
|
+
context = FiberContext.new(session: context.session, fiber: Fiber.current,
|
|
287
|
+
thread: Thread.current, span_stack: SpanContext.stack, inherited_span:, parent: context.parent)
|
|
288
|
+
Fiber[FIBER_CONTEXT_KEY] = context
|
|
289
|
+
@frames << context
|
|
290
|
+
Subtree.active_sessions.each { |session| session.inherit_parent(parent_fiber_id) }
|
|
291
|
+
end
|
|
292
|
+
@current_sessions = Subtree.active_sessions.reverse
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
class Session
|
|
297
|
+
attr_reader :client, :parent_session, :trace_function_key, :trace_id, :deferred_frames
|
|
298
|
+
|
|
299
|
+
def active? = @active
|
|
300
|
+
def deferred? = @deferred
|
|
301
|
+
|
|
302
|
+
def remove_parent(session)
|
|
303
|
+
@parent_session = session.parent_session if @parent_session.equal?(session)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def initialize(client:, trace_function_key:, trace_id:, root_span_id:, test_run_id:,
|
|
307
|
+
input_source_span_id:, config:, max_depth:, max_spans:, include_wrappers:,
|
|
308
|
+
root_receiver:, root_method_name:)
|
|
309
|
+
@client = client
|
|
310
|
+
@trace_function_key = trace_function_key
|
|
311
|
+
@trace_id = trace_id
|
|
312
|
+
@root_span_id = root_span_id
|
|
313
|
+
@test_run_id = test_run_id
|
|
314
|
+
@input_source_span_id = input_source_span_id
|
|
315
|
+
@root = config.root
|
|
316
|
+
@root_path = config.root_path
|
|
317
|
+
@root_receiver = root_receiver
|
|
318
|
+
@root_method_name = root_method_name.to_sym
|
|
319
|
+
@skip_root_call = true
|
|
320
|
+
@max_depth = max_depth
|
|
321
|
+
@max_spans = max_spans
|
|
322
|
+
@exclude = config.exclude
|
|
323
|
+
@include_wrappers = include_wrappers
|
|
324
|
+
@stacks = {}
|
|
325
|
+
@fiber_parents = {}
|
|
326
|
+
@suppressed_depths = {}
|
|
327
|
+
@owned_paths = {}
|
|
328
|
+
@method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
|
|
329
|
+
@spans_used = 0
|
|
330
|
+
@truncated = false
|
|
331
|
+
@exhausted = false
|
|
332
|
+
@drained = false
|
|
333
|
+
@deferred_frames = []
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def run(defer_enumerator: false)
|
|
337
|
+
previous = Thread.current[SESSION_KEY]
|
|
338
|
+
@parent_session = Subtree.current_session
|
|
339
|
+
@active = true
|
|
340
|
+
@deferred = false
|
|
341
|
+
Thread.current[SESSION_KEY] = self
|
|
342
|
+
entered = false
|
|
343
|
+
completed = false
|
|
344
|
+
result = Subtree.dispatcher.run(self) do
|
|
345
|
+
entered = true
|
|
346
|
+
value = yield
|
|
347
|
+
@deferred = defer_enumerator && value.is_a?(Enumerator)
|
|
348
|
+
completed = true
|
|
349
|
+
value
|
|
350
|
+
end
|
|
351
|
+
result
|
|
352
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
353
|
+
return result if completed
|
|
354
|
+
raise if entered
|
|
355
|
+
|
|
356
|
+
yield
|
|
357
|
+
ensure
|
|
358
|
+
Thread.current[SESSION_KEY] = previous
|
|
359
|
+
@active = false
|
|
360
|
+
@parent_session = nil
|
|
361
|
+
unless @deferred
|
|
362
|
+
@fiber_parents.clear
|
|
363
|
+
finish_open_spans
|
|
364
|
+
warn_if_truncated
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def wrap_enumerator(source)
|
|
369
|
+
Enumerator.new do |yielder|
|
|
370
|
+
run do
|
|
371
|
+
source.each { |value| yielder << value }
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def handle(event)
|
|
377
|
+
return if @drained
|
|
378
|
+
|
|
379
|
+
if event.event == :raise
|
|
380
|
+
record_error(event)
|
|
381
|
+
return
|
|
382
|
+
elsif event.event == :rescue
|
|
383
|
+
clear_rescued_error(event)
|
|
384
|
+
return
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
return if event.event == :call && skip_boundary_call?(event)
|
|
388
|
+
|
|
389
|
+
resolved_path = owned_path(event.path)
|
|
390
|
+
return unless resolved_path
|
|
391
|
+
|
|
392
|
+
case event.event
|
|
393
|
+
when :call
|
|
394
|
+
open_span(event, resolved_path)
|
|
395
|
+
when :return
|
|
396
|
+
close(event, resolved_path)
|
|
397
|
+
end
|
|
398
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
399
|
+
nil
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def excluded?(method_name)
|
|
403
|
+
@exclude[method_name.to_s] == true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def reserve_node
|
|
407
|
+
stack = current_stack(Fiber.current.object_id)
|
|
408
|
+
if captured_depth(stack) >= @max_depth
|
|
409
|
+
@truncated = true
|
|
410
|
+
return false
|
|
411
|
+
end
|
|
412
|
+
if @spans_used >= @max_spans
|
|
413
|
+
@truncated = true
|
|
414
|
+
@exhausted = true
|
|
415
|
+
return false
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
@spans_used += 1
|
|
419
|
+
true
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def current_parent_span_id
|
|
423
|
+
stack = @stacks[Fiber.current.object_id] || []
|
|
424
|
+
stack.reverse_each.find { |entry| entry.span_id }&.span_id ||
|
|
425
|
+
@fiber_parents.dig(Fiber.current.object_id, :span_id) || @root_span_id
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def inherit_parent(parent_fiber_id)
|
|
429
|
+
fiber_id = Fiber.current.object_id
|
|
430
|
+
return if @fiber_parents.key?(fiber_id)
|
|
431
|
+
|
|
432
|
+
stack = @stacks[parent_fiber_id] || []
|
|
433
|
+
parent = @fiber_parents[parent_fiber_id]
|
|
434
|
+
@fiber_parents[fiber_id] = {
|
|
435
|
+
span_id: stack.reverse_each.find { |entry| entry.span_id }&.span_id || parent&.dig(:span_id) || @root_span_id,
|
|
436
|
+
depth: local_depth(stack) + parent&.dig(:depth).to_i
|
|
437
|
+
}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def push_boundary(receiver:, method_name:, span_id:, counts_toward_depth:)
|
|
441
|
+
boundary = Boundary.new(
|
|
442
|
+
receiver:,
|
|
443
|
+
method_name: method_name.to_sym,
|
|
444
|
+
span_id:,
|
|
445
|
+
counts_toward_depth:,
|
|
446
|
+
skip_next_call: true
|
|
447
|
+
)
|
|
448
|
+
current_stack(Fiber.current.object_id) << boundary
|
|
449
|
+
boundary
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def pop_boundary(boundary)
|
|
453
|
+
fiber_id = Fiber.current.object_id
|
|
454
|
+
stack = @stacks[fiber_id]
|
|
455
|
+
return unless stack
|
|
456
|
+
|
|
457
|
+
if stack.last.equal?(boundary)
|
|
458
|
+
stack.pop
|
|
459
|
+
else
|
|
460
|
+
stack.delete(boundary)
|
|
461
|
+
end
|
|
462
|
+
@stacks.delete(fiber_id) if stack.empty?
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
private
|
|
466
|
+
|
|
467
|
+
def finish_open_spans
|
|
468
|
+
@stacks.each_value do |stack|
|
|
469
|
+
stack.reverse_each do |entry|
|
|
470
|
+
next unless entry.is_a?(OpenSpan)
|
|
471
|
+
|
|
472
|
+
entry.error ||= "Subtree capture ended before the call returned"
|
|
473
|
+
emit(entry, result: nil)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
ensure
|
|
477
|
+
@stacks.clear
|
|
478
|
+
@suppressed_depths.clear
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def open_span(event, resolved_path)
|
|
482
|
+
fiber_id = Fiber.current.object_id
|
|
483
|
+
if @suppressed_depths[fiber_id].to_i.positive?
|
|
484
|
+
@suppressed_depths[fiber_id] += 1
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
if @exhausted
|
|
489
|
+
suppress(fiber_id)
|
|
490
|
+
return
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
metadata = metadata_for(event, resolved_path)
|
|
494
|
+
return if metadata.excluded
|
|
495
|
+
return if !@include_wrappers && metadata.passthrough
|
|
496
|
+
|
|
497
|
+
stack = current_stack(fiber_id)
|
|
498
|
+
if captured_depth(stack) >= @max_depth
|
|
499
|
+
@truncated = true
|
|
500
|
+
suppress(fiber_id)
|
|
501
|
+
return
|
|
502
|
+
end
|
|
503
|
+
if @spans_used >= @max_spans
|
|
504
|
+
@truncated = true
|
|
505
|
+
@exhausted = true
|
|
506
|
+
if captured_calls_drained?
|
|
507
|
+
@drained = true
|
|
508
|
+
else
|
|
509
|
+
suppress(fiber_id)
|
|
510
|
+
end
|
|
511
|
+
return
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
active_parent = SpanContext.current || ReplayContext.current_span
|
|
515
|
+
explicit_receiver = active_parent && active_parent[:explicit_span_receiver]
|
|
516
|
+
if explicit_receiver && active_parent[:trace_id] == @trace_id && explicit_receiver.equal?(event.self) &&
|
|
517
|
+
active_parent[:explicit_span_method_name] == called_method_id(event)
|
|
518
|
+
return
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
@spans_used += 1
|
|
522
|
+
span_id = SecureRandom.uuid
|
|
523
|
+
args, kwargs = inputs(event)
|
|
524
|
+
# A nested subtree root temporarily owns the ambient span context, but
|
|
525
|
+
# an outer session must never parent its spans across trace boundaries.
|
|
526
|
+
active_parent_span_id = if active_parent&.dig(:trace_id) == @trace_id
|
|
527
|
+
active_parent[:span_id]
|
|
528
|
+
end
|
|
529
|
+
subtree_parent_span_id = stack.reverse_each.find { |entry| entry.span_id }&.span_id
|
|
530
|
+
stack << OpenSpan.new(
|
|
531
|
+
identity: metadata.identity,
|
|
532
|
+
span_id:,
|
|
533
|
+
parent_span_id: subtree_parent_span_id || @fiber_parents.dig(fiber_id, :span_id) || active_parent_span_id || @root_span_id,
|
|
534
|
+
name: metadata.name,
|
|
535
|
+
args:,
|
|
536
|
+
kwargs:,
|
|
537
|
+
started_at: now
|
|
538
|
+
)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def close(event, resolved_path)
|
|
542
|
+
fiber_id = Fiber.current.object_id
|
|
543
|
+
suppressed_depth = @suppressed_depths[fiber_id].to_i
|
|
544
|
+
if suppressed_depth.positive?
|
|
545
|
+
if suppressed_depth == 1
|
|
546
|
+
@suppressed_depths.delete(fiber_id)
|
|
547
|
+
@drained = true if @exhausted && captured_calls_drained?
|
|
548
|
+
else
|
|
549
|
+
@suppressed_depths[fiber_id] = suppressed_depth - 1
|
|
550
|
+
end
|
|
551
|
+
return
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
stack = @stacks[fiber_id]
|
|
555
|
+
return unless stack
|
|
556
|
+
|
|
557
|
+
entry = stack.last
|
|
558
|
+
return unless entry.is_a?(OpenSpan) && matching_identity?(entry.identity, event, resolved_path)
|
|
559
|
+
|
|
560
|
+
stack.pop
|
|
561
|
+
emit(entry, result: event.return_value)
|
|
562
|
+
parent = stack.last
|
|
563
|
+
parent.error ||= entry.error if entry.error && parent.is_a?(OpenSpan)
|
|
564
|
+
@stacks.delete(fiber_id) if stack.empty?
|
|
565
|
+
@drained = true if @exhausted && captured_calls_drained?
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def record_error(event)
|
|
569
|
+
entry = @stacks[Fiber.current.object_id]&.last
|
|
570
|
+
return unless entry.is_a?(OpenSpan)
|
|
571
|
+
|
|
572
|
+
exception = event.raised_exception
|
|
573
|
+
entry.error = "#{exception.class}: #{exception.message}"
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def clear_rescued_error(event)
|
|
577
|
+
entry = @stacks[Fiber.current.object_id]&.last
|
|
578
|
+
return unless entry.is_a?(OpenSpan)
|
|
579
|
+
|
|
580
|
+
entry.error = nil
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def emit(entry, result:)
|
|
584
|
+
# Recording a lazy return value must not execute the caller's iteration.
|
|
585
|
+
result = Serialize.unserializable_stub(result, "lazy_enumerator") if result.is_a?(Enumerator)
|
|
586
|
+
@client.send(
|
|
587
|
+
:send_span,
|
|
588
|
+
trace_function_key: @trace_function_key,
|
|
589
|
+
trace_id: @trace_id,
|
|
590
|
+
span_id: entry.span_id,
|
|
591
|
+
parent_span_id: entry.parent_span_id,
|
|
592
|
+
span_name: entry.name,
|
|
593
|
+
span_type: "function",
|
|
594
|
+
function_name: entry.name,
|
|
595
|
+
contexts: nil,
|
|
596
|
+
prompt: nil,
|
|
597
|
+
args: entry.args,
|
|
598
|
+
kwargs: entry.kwargs,
|
|
599
|
+
result:,
|
|
600
|
+
error: entry.error,
|
|
601
|
+
started_at: entry.started_at,
|
|
602
|
+
ended_at: now,
|
|
603
|
+
test_run_id: @test_run_id,
|
|
604
|
+
input_source_span_id: @input_source_span_id
|
|
605
|
+
)
|
|
606
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
607
|
+
nil
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def inputs(event)
|
|
611
|
+
args = []
|
|
612
|
+
kwargs = {}
|
|
613
|
+
event.parameters.each do |kind, name|
|
|
614
|
+
next unless name
|
|
615
|
+
|
|
616
|
+
value = event.binding.local_variable_get(name)
|
|
617
|
+
case kind
|
|
618
|
+
when :req, :opt
|
|
619
|
+
args << value
|
|
620
|
+
when :rest
|
|
621
|
+
args.concat(value)
|
|
622
|
+
when :keyreq, :key
|
|
623
|
+
kwargs[name] = value
|
|
624
|
+
when :keyrest
|
|
625
|
+
kwargs.merge!(value)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
[args, kwargs]
|
|
629
|
+
rescue NameError, TypeError
|
|
630
|
+
[[], {}]
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def span_name(event, method_id)
|
|
634
|
+
if @root_receiver.equal?(event.self) && @root_method_name == method_id
|
|
635
|
+
return method_id.to_s
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
owner = event.defined_class
|
|
639
|
+
owner_name = if owner.respond_to?(:singleton_class?) && owner.singleton_class?
|
|
640
|
+
event.self.respond_to?(:name) ? event.self.name : nil
|
|
641
|
+
elsif owner.respond_to?(:name)
|
|
642
|
+
owner.name
|
|
643
|
+
end
|
|
644
|
+
return method_id.to_s if !owner_name || owner_name.empty? || owner_name == "Object"
|
|
645
|
+
|
|
646
|
+
"#{owner_name}.#{method_id}"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def skip_boundary_call?(event)
|
|
650
|
+
boundary = @stacks[Fiber.current.object_id]&.last
|
|
651
|
+
if boundary.is_a?(Boundary) && boundary.skip_next_call &&
|
|
652
|
+
boundary.receiver.equal?(event.self) && boundary.method_name == called_method_id(event)
|
|
653
|
+
boundary.skip_next_call = false
|
|
654
|
+
return true
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
return false unless @skip_root_call
|
|
658
|
+
return false unless @root_receiver.equal?(event.self) && @root_method_name == called_method_id(event)
|
|
659
|
+
|
|
660
|
+
@skip_root_call = false
|
|
661
|
+
if event.path != @root_path
|
|
662
|
+
root = Subtree.first_party_root(event.path)
|
|
663
|
+
resolved_root = root && Subtree.with_separator(Subtree.realpath(root))
|
|
664
|
+
if resolved_root
|
|
665
|
+
@root = resolved_root
|
|
666
|
+
@root_path = event.path
|
|
667
|
+
@owned_paths.clear
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
true
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def passthrough_wrapper?(parameters)
|
|
674
|
+
kinds = parameters.map(&:first)
|
|
675
|
+
kinds.any? { |kind| kind == :rest || kind == :keyrest } &&
|
|
676
|
+
kinds.all? { |kind| %i[rest keyrest block].include?(kind) }
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def metadata_for(event, resolved_path)
|
|
680
|
+
method_id = called_method_id(event)
|
|
681
|
+
definitions = @method_metadata[event.defined_class]
|
|
682
|
+
cached = definitions[method_id]
|
|
683
|
+
return cached if cached && cached.identity[0] == resolved_path && cached.lineno == event.lineno
|
|
684
|
+
|
|
685
|
+
definitions[method_id] = MethodMetadata.new(
|
|
686
|
+
identity: [resolved_path, method_id, event.defined_class].freeze,
|
|
687
|
+
lineno: event.lineno,
|
|
688
|
+
name: span_name(event, method_id),
|
|
689
|
+
excluded: @exclude[method_id.to_s],
|
|
690
|
+
passthrough: passthrough_wrapper?(event.parameters)
|
|
691
|
+
).freeze
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def matching_identity?(identity, event, resolved_path)
|
|
695
|
+
identity[0] == resolved_path && identity[1] == called_method_id(event) &&
|
|
696
|
+
identity[2] == event.defined_class
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def called_method_id(event)
|
|
700
|
+
event.callee_id || event.method_id
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def owned_path(path)
|
|
704
|
+
@owned_paths.fetch(path) do
|
|
705
|
+
resolved = Subtree.realpath(path)
|
|
706
|
+
# The selected application may itself be installed under Gem.path.
|
|
707
|
+
# Its own boundary wins over ancestor exclusions, never the SDK's.
|
|
708
|
+
@owned_paths[path] = if resolved&.start_with?(@root) &&
|
|
709
|
+
!resolved.start_with?(SDK_PREFIX) &&
|
|
710
|
+
!Subtree.library_prefixes.any? { |prefix| resolved.start_with?(prefix) && !@root.start_with?(prefix) }
|
|
711
|
+
resolved
|
|
712
|
+
else
|
|
713
|
+
false
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def current_stack(fiber_id)
|
|
719
|
+
@stacks[fiber_id] ||= []
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def captured_depth(stack)
|
|
723
|
+
local_depth(stack) + @fiber_parents.dig(Fiber.current.object_id, :depth).to_i
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def local_depth(stack)
|
|
727
|
+
stack.count do |entry|
|
|
728
|
+
!entry.is_a?(Boundary) || entry.counts_toward_depth
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def captured_calls_drained?
|
|
733
|
+
@suppressed_depths.empty? && @stacks.values.all? do |stack|
|
|
734
|
+
stack.none? { |entry| entry.is_a?(OpenSpan) }
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def suppress(fiber_id)
|
|
739
|
+
@suppressed_depths[fiber_id] = 1
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def warn_if_truncated
|
|
743
|
+
return unless @truncated
|
|
744
|
+
|
|
745
|
+
Bitfab.warn_once(
|
|
746
|
+
"subtree-truncated:#{@trace_function_key}",
|
|
747
|
+
"'#{@trace_function_key}' hit a subtree capture limit (max_depth=#{@max_depth}, " \
|
|
748
|
+
"max_spans=#{@max_spans}) and its trace is incomplete. Raise the limits, or narrow " \
|
|
749
|
+
"what is traced with exclude: [...]."
|
|
750
|
+
)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def now
|
|
754
|
+
Bitfab.now_iso_timestamp
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def library_prefixes
|
|
759
|
+
@library_prefixes ||= begin
|
|
760
|
+
paths = [
|
|
761
|
+
RbConfig::CONFIG["rubylibdir"],
|
|
762
|
+
RbConfig::CONFIG["archdir"],
|
|
763
|
+
RbConfig::CONFIG["vendorlibdir"],
|
|
764
|
+
RbConfig::CONFIG["sitelibdir"],
|
|
765
|
+
File.expand_path("..", __dir__)
|
|
766
|
+
]
|
|
767
|
+
paths.concat(Gem.path) if defined?(Gem)
|
|
768
|
+
paths.compact.filter_map do |path|
|
|
769
|
+
with_separator(realpath(path))
|
|
770
|
+
end.freeze
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
end
|