bitfab 0.51.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3dd6fa16ae3ff5f26d94f6b86f9fa702129cfb502e3f94b293e383d107d1c0de
4
- data.tar.gz: 220cdb686e1a5522fdcd9579e897f60b0896360583b49615006f0489723c6d21
3
+ metadata.gz: 571e505294f7f5abe3b1d4c8521c4427cf30c105e03eb87509e70dd929559f43
4
+ data.tar.gz: 2a39fa504e22027f19a845efb156fb6a7c38d4a1187b7df64dad1623b5cccb4b
5
5
  SHA512:
6
- metadata.gz: cf75d4072ac82e17312f40f86c1a0e54c3a60a019818695507a5d53cfc1bfb15db6e705ad6a971c6e77abd67b4b752e180457d483b5f42becc52167323784dd9
7
- data.tar.gz: 8857e35745377b128479948ec4622ef2223f3876015f03e161ac80e43f61ec1c1556cb4d4839dd174c35afba9b3e1fbfcbd0c4da69766b80cd593cd8caf96c07
6
+ metadata.gz: e4146889a3a1f02b3bca7a104810067d59ec1e138cd099dc4822d241bcf34a6cb1514535d0bb902628e18a990bb31b5b1c79074cb946bdae7416bad3121a877c
7
+ data.tar.gz: ab9ab7427fb6065d4a6c1748b780a60d12b70302971b474927741653da400f60deb8a739addb5eb60ef79e7e9c791922538c86cbe53f163a1eb69c4dd175b87b
data/README.md CHANGED
@@ -215,6 +215,51 @@ end
215
215
  # └─ check_fraud (grandchild)
216
216
  ```
217
217
 
218
+ ### Experimental Subtree Tracing
219
+
220
+ Use `bitfab_trace` to record a root method and every first-party Ruby method it calls without wrapping each descendant:
221
+
222
+ ```ruby
223
+ class OrderPipeline
224
+ include Bitfab::Traceable
225
+ bitfab_function "order-pipeline"
226
+
227
+ bitfab_trace :process, type: "agent"
228
+ def process(order_id)
229
+ validate(order_id)
230
+ end
231
+
232
+ def validate(order_id)
233
+ { order_id:, valid: true }
234
+ end
235
+ end
236
+ ```
237
+
238
+ Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting `max_spans` stops new capture but allows already-open spans in every Fiber to finish. `max_depth:`, `max_spans:`, `exclude:`, and `include_wrappers:` control the subtree. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_span` when replay must substitute it: its explicit span records once, and automatic descendants continue beneath it.
239
+
240
+ When capture ends, already-open automatic calls in suspended Fibers are emitted once with a nil output and `Subtree capture ended before the call returned` as the error, unless a real error was already observed. Their inputs and parenting are retained. The SDK does not resume Fibers, and later resumption cannot add spans to the ended capture. If hot reload moves the root to an unresolved source, the last valid first-party boundary and its exclusions remain in use.
241
+
242
+ Returning an `Enumerator` keeps capture pending until iteration finishes or raises, including calls already paused in child Fibers. Capture is inactive between the initial return and consumption. Automatic spans record lazy return values as placeholders without iterating them; the traced root records the yielded values.
243
+
244
+ A nested `bitfab_trace` with a different key starts an independent trace while the outer trace continues recording the overlapping subtree. Both traces have distinct trace and span IDs, including during replay, and the complete structure each root would record alone, so two active roots double span volume in their shared region. A recursive call or nested annotation using the same key stays in the active trace instead of creating another root.
245
+
246
+ Configure a discovered method with `bitfab_node`. A node never creates a span or trace by itself; the active `bitfab_trace` owns its key, limits, parent, and lifecycle:
247
+
248
+ ```ruby
249
+ bitfab_node :summarize,
250
+ name: "Model call",
251
+ type: "llm",
252
+ mock_on_replay: true,
253
+ finalize: ->(response) { {text: response.text} }
254
+ def summarize(order)
255
+ model.generate(order)
256
+ end
257
+ ```
258
+
259
+ Nodes support `name:`, `type:`, `capture:`, `test_run_id:`, `mock_on_replay:`, and `finalize:`. `capture: false` omits the method while attaching captured descendants to its nearest visible parent. `finalize:` changes only the recorded output, never the return value. A finalizer failure is recorded on the node without crashing the caller. The before-`def`, inline, and after-`def` forms all preserve method visibility. For external code, use `Bitfab.client.node(MyClass, :method)`.
260
+
261
+ `bitfab_trace` prepares its project boundary and exclusions when the method is declared, refreshes the boundary if the root definition moves during hot reload, caches canonical source paths, and shares one `TracePoint` dispatcher across active roots on the calling thread. After `max_spans` is exhausted, it stops collecting metadata and values immediately, then stops processing events for that session once its captured calls finish. Each active root still captures and serializes its own copy of every overlapping call, so this remains a discovery tool rather than the production default. Prefer explicit `bitfab_span` declarations on hot paths. Run `mise exec -- ruby -Ilib benchmark/subtree.rb` from this package to measure the no-network caller path on your Ruby build.
262
+
218
263
  ### Input/Output Capture
219
264
 
220
265
  Positional and keyword arguments are automatically captured:
@@ -399,6 +444,8 @@ batch is never re-encoded to measure its size. If Bitfab accepts only part of a
399
444
  the standard OTLP `partialSuccess` response and the SDK warns with the rejected-span count and
400
445
  reason.
401
446
 
447
+ Every root trace also records the commit its code ran at as `commit_ref`. Deploy platforms such as Vercel, GitHub Actions, Railway, Render, Heroku, Cloudflare Pages, GitLab CI, Azure Pipelines, and CircleCI supply it through their build variables, a plain checkout resolves it from `git` once per process in the background, and a container built without either should set `BITFAB_COMMIT_SHA` at build time. Set `BITFAB_DISABLE_COMMIT_REF` to send no `commit_ref` at all.
448
+
402
449
  Before finalizing a replay, the SDK flushes OTel and polls Bitfab's replay-status API until every
403
450
  expected trace completion and span count is persisted.
404
451
 
@@ -425,6 +472,10 @@ See [bitfab-ruby-example/](../bitfab-ruby-example/) for complete working example
425
472
 
426
473
  - [test_span.rb](../bitfab-ruby-example/scripts/test_span.rb) - Basic span creation
427
474
  - [test_nested_spans.rb](../bitfab-ruby-example/scripts/test_nested_spans.rb) - Nested span hierarchies
475
+ - [test_subtree.rb](../bitfab-ruby-example/scripts/test_subtree.rb) - Whole-subtree capture with independent overlapping roots
476
+ - [test_subtree_comprehensive.rb](../bitfab-ruby-example/scripts/test_subtree_comprehensive.rb) - Self-checking subtree and node behavior examples, including child-Fiber context and installed application gems
477
+ - [test_subtree_replay.rb](../bitfab-ruby-example/scripts/test_subtree_replay.rb) - Live subtree record and replay
478
+ - [subtree coverage map](../bitfab-ruby-example/subtree_examples/README.md) - Every subtree spec behavior mapped to its runnable example
428
479
  - [test_wrap.rb](../bitfab-ruby-example/scripts/test_wrap.rb) - Wrapping external code
429
480
  - [test_enabled.rb](../bitfab-ruby-example/scripts/test_enabled.rb) - Using the enabled flag
430
481
  - [test_metadata.rb](../bitfab-ruby-example/scripts/test_metadata.rb) - Custom metadata
data/lib/bitfab/client.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "constants"
7
7
  require_relative "http_client"
8
8
  require_relative "replay"
9
9
  require_relative "span_context"
10
+ require_relative "subtree"
10
11
  require_relative "serialize"
11
12
  require_relative "timestamp"
12
13
  require_relative "warn_once"
@@ -51,6 +52,7 @@ module Bitfab
51
52
  # replay on this client (after any per-call mock_override). Instance
52
53
  # state, no global; clear_mock_overrides resets it.
53
54
  @mock_overrides = []
55
+ CommitRef.start_resolution
54
56
  end
55
57
 
56
58
  # The configured API key (a proc is resolved on read). Reflects what was
@@ -302,6 +304,23 @@ module Bitfab
302
304
  BitfabFunction.new(self, trace_function_key)
303
305
  end
304
306
 
307
+ # Configure an existing method only when it is discovered beneath a
308
+ # bitfab_trace root owned by this client.
309
+ def node(klass, method_name, name: nil, type: "custom", capture: true,
310
+ test_run_id: nil, mock_on_replay: false, finalize: nil)
311
+ Bitfab::Traceable.node(
312
+ klass,
313
+ method_name,
314
+ name:,
315
+ type:,
316
+ capture:,
317
+ test_run_id:,
318
+ mock_on_replay:,
319
+ finalize:,
320
+ client: self
321
+ )
322
+ end
323
+
305
324
  # Fetch one persisted span without loading the full trace.
306
325
  # Exactly one of id or name is required. Name lookups return the last
307
326
  # matching span by default; occurrence also accepts "first" or a zero-based
@@ -326,7 +345,8 @@ module Bitfab
326
345
  # Execute a block inside a span context, sending trace data on completion.
327
346
  # Called by Traceable, not intended for direct use.
328
347
  def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
329
- capture_when: "always", mock_on_replay: false)
348
+ capture_when: "always", mock_on_replay: false, independent_root: false,
349
+ explicit_span_receiver: nil, explicit_span_method_name: nil, test_run_id: nil, finalize: nil)
330
350
  # Decide at CALL time, not construction. The key may be set after the
331
351
  # client is built (env loaded later), so re-checking per call lets a
332
352
  # late-resolved key take effect.
@@ -358,13 +378,24 @@ module Bitfab
358
378
  resolved_input_source_span_id = nil
359
379
  begin
360
380
  replay_ctx = ReplayContext.current
361
- parent = SpanContext.current || ReplayContext.current_span
362
- trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
381
+ # A subtree annotation declares a trace-function root, even when it is
382
+ # invoked inside another span. Outer subtree sessions keep observing it
383
+ # through TracePoint instead of owning this root span.
384
+ ambient_parent = SpanContext.current || ReplayContext.current_span
385
+ parent = independent_root ? nil : ambient_parent
386
+ if parent && (subtree_parent_span_id = Subtree.current_parent_span_id(parent[:trace_id]))
387
+ parent = parent.merge(span_id: subtree_parent_span_id)
388
+ end
389
+ trace_id = if independent_root && ambient_parent
390
+ SecureRandom.uuid
391
+ else
392
+ parent&.dig(:trace_id) || replay_ctx&.dig(:trace_id) || SecureRandom.uuid
393
+ end
363
394
  span_id = SecureRandom.uuid
364
395
  parent_span_id = parent&.dig(:span_id)
365
396
  is_root_span = parent_span_id.nil?
366
397
  started_at = Bitfab.now_iso_timestamp
367
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
398
+ resolved_test_run_id = replay_ctx ? replay_ctx[:test_run_id] : test_run_id
368
399
  resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
369
400
  resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
370
401
 
@@ -438,18 +469,28 @@ module Bitfab
438
469
  error = nil
439
470
  span_contexts = nil
440
471
  span_prompt = nil
441
- finalized = false
472
+ completed = false
442
473
 
443
- finalize = lambda do |final_result, final_error|
474
+ complete = lambda do |final_result, final_error|
444
475
  # Never crash the host app due to span building/sending. Idempotent:
445
476
  # only the first call sends the span. Subsequent calls (e.g. from the
446
477
  # enumerator wrapper after iteration completes) are no-ops.
447
- next if finalized
478
+ next if completed
448
479
 
449
- finalized = true
480
+ completed = true
450
481
 
451
482
  begin
452
483
  ended_at = Bitfab.now_iso_timestamp
484
+ recorded_result = final_result
485
+ recorded_error = final_error
486
+ if finalize && final_error.nil?
487
+ begin
488
+ recorded_result = finalize.call(final_result)
489
+ rescue => e
490
+ recorded_result = nil
491
+ recorded_error = "finalize failed: #{e.message}"
492
+ end
493
+ end
453
494
 
454
495
  send_span(
455
496
  trace_function_key:,
@@ -463,8 +504,8 @@ module Bitfab
463
504
  prompt: span_prompt,
464
505
  args:,
465
506
  kwargs:,
466
- result: final_result,
467
- error: final_error,
507
+ result: recorded_result,
508
+ error: recorded_error,
468
509
  started_at:,
469
510
  ended_at:,
470
511
  test_run_id: resolved_test_run_id,
@@ -513,8 +554,22 @@ module Bitfab
513
554
 
514
555
  begin
515
556
  ReplayContext.with_span(trace_id:, span_id:) do
516
- SpanContext.with_span(trace_id:, span_id:) do
517
- result = yield
557
+ SpanContext.with_span(
558
+ trace_id:,
559
+ span_id:,
560
+ explicit_span_receiver:,
561
+ explicit_span_method_name: explicit_span_method_name&.to_sym
562
+ ) do
563
+ result = if explicit_span_receiver
564
+ Subtree.with_borrowed_span(
565
+ trace_id:,
566
+ span_id:,
567
+ receiver: explicit_span_receiver,
568
+ method_name: explicit_span_method_name
569
+ ) { yield }
570
+ else
571
+ yield
572
+ end
518
573
  ensure
519
574
  # Capture contexts before the span context is popped
520
575
  span_contexts = SpanContext.current&.dig(:contexts)
@@ -523,7 +578,7 @@ module Bitfab
523
578
  end
524
579
  rescue => e
525
580
  error = e.message
526
- finalize.call(result, error)
581
+ complete.call(result, error)
527
582
  raise
528
583
  end
529
584
 
@@ -541,10 +596,17 @@ module Bitfab
541
596
  # once iteration completes (or errors).
542
597
  #
543
598
  if result.is_a?(Enumerator)
544
- return wrap_enumerator(result, trace_id:, span_id:, finalize:)
599
+ return wrap_enumerator(
600
+ result,
601
+ trace_id:,
602
+ span_id:,
603
+ complete:,
604
+ explicit_span_receiver:,
605
+ explicit_span_method_name:
606
+ )
545
607
  end
546
608
 
547
- finalize.call(result, error)
609
+ complete.call(result, error)
548
610
  result
549
611
  end
550
612
 
@@ -605,8 +667,13 @@ module Bitfab
605
667
  # callbacks nest under the parent span. Yielded values are collected as the
606
668
  # span output. The span is sent exactly once: when iteration finishes,
607
669
  # raises, or the wrapper is `.close`d.
608
- def wrap_enumerator(source, trace_id:, span_id:, finalize:)
670
+ def wrap_enumerator(source, trace_id:, span_id:, complete:, explicit_span_receiver: nil,
671
+ explicit_span_method_name: nil)
609
672
  span_entry = {trace_id:, span_id:}
673
+ if explicit_span_receiver
674
+ span_entry[:explicit_span_receiver] = explicit_span_receiver
675
+ span_entry[:explicit_span_method_name] = explicit_span_method_name&.to_sym
676
+ end
610
677
  yielded = []
611
678
 
612
679
  Enumerator.new do |yielder|
@@ -618,13 +685,26 @@ module Bitfab
618
685
  SpanContext.with_fiber_bridge(trace_id:, span_id:) do
619
686
  SpanContext.stack.push(span_entry)
620
687
  begin
621
- source.each do |value|
622
- yielded << value
623
- yielder << value
688
+ iterate = lambda do
689
+ source.each do |value|
690
+ yielded << value
691
+ yielder << value
692
+ end
693
+ end
694
+ if explicit_span_receiver
695
+ Subtree.with_borrowed_span(
696
+ trace_id:,
697
+ span_id:,
698
+ receiver: explicit_span_receiver,
699
+ method_name: explicit_span_method_name,
700
+ &iterate
701
+ )
702
+ else
703
+ iterate.call
624
704
  end
625
- finalize.call(yielded, nil)
705
+ complete.call(yielded, nil)
626
706
  rescue => e
627
- finalize.call(yielded, e.message)
707
+ complete.call(yielded, e.message)
628
708
  raise
629
709
  ensure
630
710
  SpanContext.stack.pop
@@ -673,6 +753,8 @@ module Bitfab
673
753
  if trace_state&.dig(:db_snapshot_ref)
674
754
  raw_trace["db_snapshot_ref"] = trace_state[:db_snapshot_ref]
675
755
  end
756
+ commit_ref = CommitRef.current
757
+ raw_trace["commit_ref"] = commit_ref if commit_ref
676
758
  if db_snapshot_usage
677
759
  usage = {"neon_branch_id" => db_snapshot_usage[:neon_branch_id]}
678
760
  if db_snapshot_usage[:snapshot_timestamp]
@@ -1006,5 +1088,22 @@ module Bitfab
1006
1088
  client: @client
1007
1089
  )
1008
1090
  end
1091
+
1092
+ # Experimentally trace an existing method and its first-party call subtree.
1093
+ def trace(klass, method_name, name: nil, type: "custom",
1094
+ max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
1095
+ exclude: [], include_wrappers: false)
1096
+ Bitfab::Traceable.trace(
1097
+ klass, method_name,
1098
+ trace_function_key: @trace_function_key,
1099
+ name:,
1100
+ type:,
1101
+ max_depth:,
1102
+ max_spans:,
1103
+ exclude:,
1104
+ include_wrappers:,
1105
+ client: @client
1106
+ )
1107
+ end
1009
1108
  end
1010
1109
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "git_command"
5
+
6
+ module Bitfab
7
+ module CommitRef
8
+ EXPLICIT_SHA_ENV = "BITFAB_COMMIT_SHA"
9
+ DISABLE_ENV = "BITFAB_DISABLE_COMMIT_REF"
10
+ GIT_TIMEOUT_SECONDS = 2
11
+
12
+ VERCEL_PROVIDER_HOSTS = {
13
+ "github" => "github.com",
14
+ "gitlab" => "gitlab.com",
15
+ "bitbucket" => "bitbucket.org"
16
+ }.freeze
17
+
18
+ PLATFORM_ENVS = [
19
+ ["VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_REF", :vercel],
20
+ ["GITHUB_SHA", "GITHUB_REF_NAME", ["GITHUB_SERVER_URL", "GITHUB_REPOSITORY"]],
21
+ ["RAILWAY_GIT_COMMIT_SHA", "RAILWAY_GIT_BRANCH", nil],
22
+ ["RENDER_GIT_COMMIT", "RENDER_GIT_BRANCH", nil],
23
+ ["SOURCE_VERSION", nil, nil],
24
+ ["CF_PAGES_COMMIT_SHA", "CF_PAGES_BRANCH", nil],
25
+ ["CI_COMMIT_SHA", "CI_COMMIT_REF_NAME", ["CI_REPOSITORY_URL"]],
26
+ ["BUILD_SOURCEVERSION", "BUILD_SOURCEBRANCHNAME", ["BUILD_REPOSITORY_URI"]],
27
+ ["CIRCLE_SHA1", "CIRCLE_BRANCH", ["CIRCLE_REPOSITORY_URL"]]
28
+ ].freeze
29
+
30
+ REMOTE_ENVS = %w[
31
+ GITHUB_SERVER_URL GITHUB_REPOSITORY CI_REPOSITORY_URL BUILD_REPOSITORY_URI
32
+ CIRCLE_REPOSITORY_URL VERCEL_GIT_PROVIDER VERCEL_GIT_REPO_OWNER VERCEL_GIT_REPO_SLUG
33
+ ].freeze
34
+
35
+ ENV_NAMES = ([EXPLICIT_SHA_ENV, DISABLE_ENV] + PLATFORM_ENVS.flat_map { |sha, branch, _| [sha, branch].compact } + REMOTE_ENVS).freeze
36
+
37
+ @lock = Mutex.new
38
+ @resolved = false
39
+ @ref = nil
40
+ @git_thread = nil
41
+
42
+ module_function
43
+
44
+ def read(env, name)
45
+ return nil if name.nil?
46
+
47
+ value = env[name].to_s.strip
48
+ value.empty? ? nil : value
49
+ end
50
+
51
+ def remote_from_env(env, spec)
52
+ case spec
53
+ when :vercel
54
+ host = VERCEL_PROVIDER_HOSTS[read(env, "VERCEL_GIT_PROVIDER").to_s.downcase]
55
+ owner = read(env, "VERCEL_GIT_REPO_OWNER")
56
+ slug = read(env, "VERCEL_GIT_REPO_SLUG")
57
+ (host && owner && slug) ? "#{host}/#{owner}/#{slug}" : nil
58
+ when Array
59
+ parts = spec.map { |name| read(env, name) }
60
+ parts.all? ? normalize_remote(parts.join("/")) : nil
61
+ end
62
+ end
63
+
64
+ def normalize_remote(raw)
65
+ value = raw.to_s.strip
66
+ return nil if value.empty?
67
+
68
+ if value.include?("://")
69
+ parsed = begin
70
+ URI.parse(value)
71
+ rescue URI::InvalidURIError
72
+ return nil
73
+ end
74
+ host = parsed.host.to_s.downcase
75
+ path = parsed.path.to_s
76
+ else
77
+ head, separator, tail = value.partition(":")
78
+ host = separator.empty? ? "" : head.rpartition("@").last
79
+ path = separator.empty? ? value : tail
80
+ end
81
+ path = path.gsub(%r{\A/+|/+\z}, "")
82
+ path = path.delete_suffix(".git").sub(%r{/+\z}, "") if path.end_with?(".git")
83
+ return (path.empty? ? nil : path) if host.empty?
84
+
85
+ path.empty? ? host : "#{host}/#{path}"
86
+ end
87
+
88
+ def resolve_from_env(env)
89
+ explicit = read(env, EXPLICIT_SHA_ENV)
90
+ platform = PLATFORM_ENVS.find { |sha_env, _, _| read(env, sha_env) }
91
+ if platform.nil?
92
+ return nil if explicit.nil?
93
+
94
+ return {"sha" => explicit, "branch" => nil, "dirty" => nil, "remote" => nil, "root_sha" => nil}
95
+ end
96
+
97
+ sha_env, branch_env, remote_spec = platform
98
+ {
99
+ "sha" => explicit || read(env, sha_env),
100
+ "branch" => read(env, branch_env),
101
+ "dirty" => nil,
102
+ "remote" => remote_from_env(env, remote_spec),
103
+ "root_sha" => nil
104
+ }
105
+ end
106
+
107
+ def git(cwd, *args)
108
+ GitCommand.run(cwd, args, timeout: GIT_TIMEOUT_SECONDS)&.strip
109
+ end
110
+
111
+ def resolve_from_git(cwd)
112
+ sha = git(cwd, "rev-parse", "HEAD")
113
+ return nil if sha.nil? || sha.empty?
114
+
115
+ branch = git(cwd, "symbolic-ref", "--short", "-q", "HEAD")
116
+ status = git(cwd, "status", "--porcelain")
117
+ roots = git(cwd, "rev-list", "--max-parents=0", "HEAD")
118
+ {
119
+ "sha" => sha,
120
+ "branch" => (branch.nil? || branch.empty?) ? nil : branch,
121
+ "dirty" => status.nil? ? nil : !status.empty?,
122
+ "remote" => normalize_remote(git(cwd, "remote", "get-url", "origin")),
123
+ "root_sha" => (roots.nil? || roots.empty?) ? nil : roots.split.min
124
+ }
125
+ end
126
+
127
+ def start_resolution
128
+ @lock.synchronize do
129
+ return if @resolved || @git_thread
130
+
131
+ if read(ENV, DISABLE_ENV)
132
+ @resolved = true
133
+ return
134
+ end
135
+
136
+ from_env = resolve_from_env(ENV)
137
+ if from_env
138
+ @ref = from_env
139
+ @resolved = true
140
+ return
141
+ end
142
+
143
+ cwd = begin
144
+ Dir.pwd
145
+ rescue SystemCallError
146
+ @resolved = true
147
+ return
148
+ end
149
+ @git_thread = Thread.new { resolve_git_in_background(cwd) }
150
+ end
151
+ end
152
+
153
+ def resolve_git_in_background(cwd)
154
+ ref = resolve_from_git(cwd)
155
+ @lock.synchronize do
156
+ unless @resolved
157
+ @ref = ref
158
+ @resolved = true
159
+ end
160
+ end
161
+ rescue
162
+ @lock.synchronize { @resolved = true }
163
+ end
164
+
165
+ def current
166
+ start_resolution
167
+ @lock.synchronize { @ref }
168
+ end
169
+
170
+ def settle(timeout = 5)
171
+ thread = @lock.synchronize { @git_thread }
172
+ thread&.join(timeout)
173
+ nil
174
+ end
175
+
176
+ def reset!
177
+ @lock.synchronize do
178
+ @ref = nil
179
+ @resolved = false
180
+ @git_thread = nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Bitfab
6
+ module GitCommand
7
+ KILL_GRACE_SECONDS = 1
8
+
9
+ module_function
10
+
11
+ def run(cwd, args, timeout:)
12
+ Open3.popen3("git", *args, chdir: cwd, **spawn_options) do |stdin, stdout, stderr, wait_thread|
13
+ stdin.close
14
+ out = +""
15
+ reader = Thread.new { out = read_all(stdout) }
16
+ drain = Thread.new { read_all(stderr) }
17
+ finished = wait_thread.join(timeout)
18
+ stop(wait_thread) unless finished
19
+ settle(reader, stdout)
20
+ settle(drain, stderr)
21
+ (finished && wait_thread.value.success?) ? out.force_encoding("UTF-8").scrub : nil
22
+ end
23
+ rescue
24
+ nil
25
+ end
26
+
27
+ def spawn_options
28
+ Gem.win_platform? ? {} : {pgroup: true}
29
+ end
30
+
31
+ def kill_target(wait_thread)
32
+ Gem.win_platform? ? wait_thread.pid : -wait_thread.pid
33
+ end
34
+
35
+ def read_all(io)
36
+ io.read.to_s
37
+ rescue
38
+ ""
39
+ end
40
+
41
+ def settle(thread, io)
42
+ return if thread.join(KILL_GRACE_SECONDS)
43
+
44
+ io.close
45
+ thread.join
46
+ end
47
+
48
+ def stop(wait_thread)
49
+ target = kill_target(wait_thread)
50
+ if Gem.win_platform?
51
+ signal("KILL", target)
52
+ else
53
+ signal("TERM", target)
54
+ signal("KILL", target) unless wait_thread.join(KILL_GRACE_SECONDS)
55
+ end
56
+ wait_thread.join
57
+ end
58
+
59
+ def signal(name, target)
60
+ Process.kill(name, target)
61
+ rescue
62
+ nil
63
+ end
64
+ end
65
+ end
data/lib/bitfab/replay.rb CHANGED
@@ -6,6 +6,7 @@ require "open3"
6
6
  require "timeout"
7
7
 
8
8
  require_relative "constants"
9
+ require_relative "git_command"
9
10
  require_relative "mock_override"
10
11
  require_relative "serialize"
11
12
  require_relative "traceable"
@@ -560,20 +561,7 @@ module Bitfab
560
561
  end
561
562
 
562
563
  def git(cwd, args)
563
- # capture3 so git's stderr (e.g. "not a git repository") is swallowed, not
564
- # leaked to the host process's console.
565
- out, _err, status = Timeout.timeout(30) do
566
- Open3.capture3("git", *args, chdir: cwd)
567
- end
568
- return nil unless status.success?
569
-
570
- # Tag as UTF-8 and scrub invalid bytes so blob contents compare cleanly
571
- # against the UTF-8 working-file reads and never break JSON.generate later
572
- # (a non-UTF-8 file that slips past the null-byte check would otherwise
573
- # abort the whole start-replay request).
574
- out.force_encoding("UTF-8").scrub
575
- rescue
576
- nil
564
+ GitCommand.run(cwd, args, timeout: 30)
577
565
  end
578
566
 
579
567
  def cc_ref_exists?(root, ref)
@@ -133,7 +133,7 @@ module Bitfab
133
133
  end
134
134
 
135
135
  def current
136
- stack.last || fiber_bridge_stack.last
136
+ stack.last || Subtree.fiber_context&.inherited_span || fiber_bridge_stack.last
137
137
  end
138
138
 
139
139
  # Enumerator.new and enum_for run their source body in another Fiber on
@@ -149,8 +149,12 @@ module Bitfab
149
149
 
150
150
  # Execute a block with a new span pushed onto the stack.
151
151
  # The span is automatically popped when the block completes.
152
- def with_span(trace_id:, span_id:)
152
+ def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil)
153
153
  entry = {trace_id:, span_id:}
154
+ if explicit_span_receiver
155
+ entry[:explicit_span_receiver] = explicit_span_receiver
156
+ entry[:explicit_span_method_name] = explicit_span_method_name
157
+ end
154
158
  stack.push(entry)
155
159
  yield
156
160
  ensure