bitfab 0.67.3 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d1b6f82e669d8a99caa8d5b2e94a9057456b4246d739cb80bade802d9bc4214
4
- data.tar.gz: c429087a28e28243c339127692797f069dbec86a7c39c61547ee5510612710a3
3
+ metadata.gz: c05530ec639d246b474f368fae48df4ed2230e4275272f2d903c9ef81bc96b6b
4
+ data.tar.gz: 1019089f79f07cafb3b0e8be5604717ed32a1a894f28a7ae6b26d71cf05c1889
5
5
  SHA512:
6
- metadata.gz: da254947e50206457558b7d897e47fa3fe9c53ce2a700299cec3eec4cd0e2546b5d363a33fc3fa12cd9a1c27cddf53dccf48b08281c81437156f86e7c543741e
7
- data.tar.gz: 9d34b5617f75d6d5bcfbae28ced5eda7df437d4854e442d1f661a2053989ac5189cb98feeb0fc99224e0dcf70cc5b17348e892b6718bf486c27c804f14c3ca09
6
+ metadata.gz: 551641d9eb87d4c98d16d17cf5f6dcfe3f795f84a8fe1c155ba14d5db41bb5e53b04229ce0424ecac59430f854e79790c1460fb9fbc7ba10a2e0cb2bf739acd4
7
+ data.tar.gz: 28c329c02e65d57a75af5ae12895be94bc525dfa6ef01e2203a7485e3998a5d6c0e04209e2e78694f6085777bdad2009dba36c6d8ee4b7e75bb95bf8d98e6f0f
data/lib/bitfab/client.rb CHANGED
@@ -1039,6 +1039,7 @@ module Bitfab
1039
1039
  content_off = !declared_node && (extra_span_data["content_off_by_simulation_plan"] == true ||
1040
1040
  content_off_by_simulation_plan?(trace_id:, trace_function_key:, span_name:, instrumentation:) ||
1041
1041
  (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation:)))
1042
+ snapshot = nil
1042
1043
  if content_off
1043
1044
  dropped = []
1044
1045
  span_data = {"name" => span_name, "type" => span_type, "function_name" => function_name}
@@ -1049,10 +1050,6 @@ module Bitfab
1049
1050
  human_output, output_dropped = Serialize.serialize_value_with_report(result)
1050
1051
  dropped = input_dropped + output_dropped
1051
1052
 
1052
- raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
1053
- marshalled_input = Serialize.marshal_value(raw_input)
1054
- marshalled_output = Serialize.marshal_value(result)
1055
-
1056
1053
  span_data = {
1057
1054
  "name" => span_name,
1058
1055
  "type" => span_type,
@@ -1061,8 +1058,13 @@ module Bitfab
1061
1058
  "function_name" => function_name
1062
1059
  }
1063
1060
  span_data.merge!(extra_span_data)
1064
- span_data["input_serialized"] = marshalled_input if marshalled_input
1065
- span_data["output_serialized"] = marshalled_output if marshalled_output
1061
+ snapshot = SpanMeta::Snapshot.capture(args, kwargs, result)
1062
+ unless snapshot
1063
+ input_meta = SpanMeta.build_input_meta(args, kwargs, human_inputs)
1064
+ output_meta = SpanMeta.build_output_meta(result, human_output)
1065
+ span_data["input_meta"] = input_meta if input_meta
1066
+ span_data["output_meta"] = output_meta if output_meta
1067
+ end
1066
1068
  end
1067
1069
  if error
1068
1070
  span_data["error"] = error
@@ -1120,7 +1122,7 @@ module Bitfab
1120
1122
  }]
1121
1123
  end
1122
1124
 
1123
- @http_client.send_external_span(payload, declared_node)
1125
+ @http_client.send_external_span(payload, declared_node, snapshot)
1124
1126
  end
1125
1127
 
1126
1128
  # Advance the per-(key, name) call counter when this invocation is a
@@ -45,6 +45,15 @@ module Bitfab
45
45
  @http_client.get("#{PATH}/rollups?#{query}")["rollups"]
46
46
  end
47
47
 
48
+ # Reads one run's written summary. "status" is "pending" while the run has not finished or its verdicts are
49
+ # still arriving; the summary is written once every verdict is in. "unavailable" means the run ended failed or
50
+ # interrupted and no summary will be written. "ready" carries the summary, compared against the first finished
51
+ # run in the same experiment group for the same traced function, or against the run's original traces when
52
+ # there is none.
53
+ def get_summary(id)
54
+ @http_client.get("#{PATH}/#{encode(id)}/summary")
55
+ end
56
+
48
57
  private
49
58
 
50
59
  def iso8601(value)
@@ -193,7 +193,7 @@ module Bitfab
193
193
  end
194
194
 
195
195
  # Queue an external span on this client's trace transport (fire-and-forget).
196
- def send_external_span(payload, declared_node = false)
196
+ def send_external_span(payload, declared_node = false, snapshot = nil)
197
197
  if (data = payload.dig("rawSpan", "span_data")).is_a?(Hash) && !data.key?("runtime")
198
198
  payload = payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => data.merge("runtime" => ThreadPropagation.runtime)))
199
199
  end
@@ -203,11 +203,16 @@ module Bitfab
203
203
  submit = ->(record) {
204
204
  ref = carrier_ref(record)
205
205
  @trace_completion.record(ref.trace_id, ref.span_id) if ref&.span_id
206
- trace_transport&.submit(
207
- "external_span",
208
- record.merge("sdkVersion" => VERSION),
209
- recorded_meta("external_span", record, ref)
210
- )
206
+ transport = trace_transport
207
+ carrier = record.merge("sdkVersion" => VERSION)
208
+ meta = recorded_meta("external_span", record, ref)
209
+ if snapshot.nil? || !snapshot.content?(carrier)
210
+ transport&.submit("external_span", carrier, meta)
211
+ elsif transport.respond_to?(:submit_with_snapshot)
212
+ transport.submit_with_snapshot("external_span", carrier, meta, snapshot)
213
+ else
214
+ transport&.submit("external_span", snapshot.apply(carrier), meta)
215
+ end
211
216
  }
212
217
  discard = ->(record) {
213
218
  ref = carrier_ref(record)
data/lib/bitfab/otel.rb CHANGED
@@ -492,6 +492,94 @@ module Bitfab
492
492
  # Owns one private TracerProvider + BatchSpanProcessor per client. The
493
493
  # provider is never installed globally, so the SDK cannot disturb an
494
494
  # application's own OpenTelemetry setup.
495
+ class SpanWorker
496
+ MAX_PENDING = 256
497
+ MAX_PENDING_BYTES = 64 * 1024 * 1024
498
+
499
+ def initialize
500
+ reset
501
+ end
502
+
503
+ def enqueue(bytes = 0, &job)
504
+ reset_after_fork
505
+ queued = @mutex.synchronize do
506
+ next false if @queue.closed? || @pending >= MAX_PENDING
507
+ next false if @pending.positive? && @pending_bytes + bytes > MAX_PENDING_BYTES
508
+
509
+ @pending += 1
510
+ @pending_bytes += bytes
511
+ @queue << [job, bytes]
512
+ @thread = Thread.new { work } unless @thread&.alive?
513
+ true
514
+ end
515
+ run(job) unless queued
516
+ end
517
+
518
+ def after_pending(&job)
519
+ reset_after_fork
520
+ return run(job) if @mutex.synchronize { @pending.zero? }
521
+
522
+ enqueue(&job)
523
+ end
524
+
525
+ def drain(timeout)
526
+ reset_after_fork
527
+ deadline = Otel.monotonic_now + [timeout, 0].max
528
+ @mutex.synchronize do
529
+ while @pending.positive?
530
+ remaining = deadline - Otel.monotonic_now
531
+ return false unless remaining.positive?
532
+
533
+ @idle.wait(@mutex, remaining)
534
+ end
535
+ true
536
+ end
537
+ end
538
+
539
+ def stop
540
+ @queue.close
541
+ end
542
+
543
+ private
544
+
545
+ def reset
546
+ @owner_pid = Process.pid
547
+ @mutex = Mutex.new
548
+ @idle = ConditionVariable.new
549
+ @queue = Thread::Queue.new
550
+ @pending = 0
551
+ @pending_bytes = 0
552
+ @thread = nil
553
+ end
554
+
555
+ def reset_after_fork
556
+ reset unless @owner_pid == Process.pid
557
+ end
558
+
559
+ def work
560
+ while (entry = @queue.pop)
561
+ job, bytes = entry
562
+ run(job)
563
+ @mutex.synchronize do
564
+ @pending -= 1
565
+ @pending_bytes -= bytes
566
+ @idle.broadcast if @pending.zero?
567
+ end
568
+ end
569
+ end
570
+
571
+ def run(job)
572
+ job.call
573
+ rescue => e
574
+ Bitfab.warn_once(
575
+ "span-worker-failed",
576
+ "failed to finish a span in the background (further occurrences " \
577
+ "suppressed): #{e.message}"
578
+ )
579
+ nil
580
+ end
581
+ end
582
+
495
583
  class BatchTransport
496
584
  attr_reader :owner_pid
497
585
 
@@ -510,57 +598,40 @@ module Bitfab
510
598
  @owner_pid = Process.pid
511
599
  @state_mutex = Mutex.new
512
600
  @flush_mutex = Mutex.new
601
+ @span_worker = SpanWorker.new
513
602
  @closed = false
514
603
  create_pipeline
515
604
  Otel.register_transport(self)
516
605
  end
517
606
 
518
607
  def submit(operation, payload, meta = nil)
519
- meta ||= CarrierMeta.new
608
+ meta = stamped(meta)
520
609
  # Encoding is the expensive part of a submit and needs no mutual
521
610
  # exclusion, so it stays outside the lock.
522
- name = meta.name || "bitfab.#{operation}"
523
- encoded_payload = Serialize.safe_generate(
524
- payload,
525
- max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
526
- )
527
- encoded_payload.instance_variable_set(CARRIER_REF_IVAR, meta.ref) if meta.ref
528
- started_at = meta.started_at || Time.now
529
- ended_at = meta.ended_at || Time.now
530
- errored = meta.errored
531
-
532
- # Pipeline construction and the closed check are serialized: concurrent
533
- # first submits in a forked child would otherwise each build a pipeline,
534
- # orphaning one batch worker along with whatever it had queued.
535
- @state_mutex.synchronize do
536
- return warn_closed if @closed
537
-
538
- ensure_process
539
- tracer = @tracer
540
- return warn_closed if tracer.nil?
611
+ encoded_payload = encode_payload(payload, meta)
612
+ @span_worker.after_pending { emit(operation, encoded_payload, meta) }
613
+ nil
614
+ rescue => e
615
+ warn_submit_failed(e)
616
+ end
541
617
 
542
- span = tracer.start_root_span(
543
- name,
544
- attributes: {
545
- OPERATION_ATTRIBUTE => operation,
546
- PAYLOAD_ATTRIBUTE => encoded_payload
547
- },
548
- start_timestamp: started_at
549
- )
550
- span.status = OpenTelemetry::Trace::Status.error if errored
551
- span.finish(end_timestamp: ended_at)
618
+ def submit_with_snapshot(operation, payload, meta, snapshot)
619
+ meta = stamped(meta)
620
+ encoded_payload = encode_payload(payload, meta)
621
+ @span_worker.enqueue(encoded_payload.bytesize + snapshot.bytesize) do
622
+ completed = complete_with_snapshot(encoded_payload, snapshot)
623
+ emit(operation, completed ? encode_payload(completed, meta) : encoded_payload, meta)
552
624
  end
553
625
  nil
554
626
  rescue => e
555
- Bitfab.warn_once(
556
- "otel-submit-failed",
557
- "failed to queue an OpenTelemetry span (further occurrences " \
558
- "suppressed): #{e.message}"
559
- )
560
- nil
627
+ warn_submit_failed(e)
561
628
  end
562
629
 
563
630
  def flush(timeout = 30.0)
631
+ deadline = Otel.monotonic_now + [timeout, 0].max
632
+ return false unless @span_worker.drain(timeout)
633
+
634
+ timeout = [deadline - Otel.monotonic_now, 0].max
564
635
  processor, delivery_tracker = @state_mutex.synchronize do
565
636
  ensure_process
566
637
  [@processor, @delivery_tracker]
@@ -591,8 +662,10 @@ module Bitfab
591
662
 
592
663
  def shutdown(timeout = 30.0)
593
664
  deadline = Otel.monotonic_now + [timeout, 0].max
665
+ drained = @span_worker.drain(timeout)
594
666
  @state_mutex.synchronize { @closed = true }
595
- flushed = flush([deadline - Otel.monotonic_now, 0].max)
667
+ @span_worker.stop
668
+ flushed = flush([deadline - Otel.monotonic_now, 0].max) && drained
596
669
 
597
670
  processor = @state_mutex.synchronize do
598
671
  current = @processor
@@ -611,6 +684,68 @@ module Bitfab
611
684
 
612
685
  private
613
686
 
687
+ def stamped(meta)
688
+ meta = (meta || CarrierMeta.new).dup
689
+ now = Time.now
690
+ meta.started_at ||= now
691
+ meta.ended_at ||= now
692
+ meta
693
+ end
694
+
695
+ def encode_payload(payload, meta)
696
+ encoded = Serialize.safe_generate(
697
+ payload,
698
+ max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
699
+ )
700
+ encoded.instance_variable_set(CARRIER_REF_IVAR, meta.ref) if meta.ref
701
+ encoded
702
+ end
703
+
704
+ def complete_with_snapshot(encoded_payload, snapshot)
705
+ snapshot.apply(JSON.parse(encoded_payload))
706
+ rescue StandardError, SystemStackError => e
707
+ Bitfab.warn_once(
708
+ "span-meta-snapshot-failed",
709
+ "could not record replay type details for a span; it will replay " \
710
+ "from its readable value (further occurrences suppressed): #{e.message}"
711
+ )
712
+ nil
713
+ end
714
+
715
+ # Pipeline construction and the closed check are serialized: concurrent
716
+ # first submits in a forked child would otherwise each build a pipeline,
717
+ # orphaning one batch worker along with whatever it had queued.
718
+ def emit(operation, encoded_payload, meta)
719
+ @state_mutex.synchronize do
720
+ return warn_closed if @closed
721
+
722
+ ensure_process
723
+ tracer = @tracer
724
+ return warn_closed if tracer.nil?
725
+
726
+ span = tracer.start_root_span(
727
+ meta.name || "bitfab.#{operation}",
728
+ attributes: {
729
+ OPERATION_ATTRIBUTE => operation,
730
+ PAYLOAD_ATTRIBUTE => encoded_payload
731
+ },
732
+ start_timestamp: meta.started_at
733
+ )
734
+ span.status = OpenTelemetry::Trace::Status.error if meta.errored
735
+ span.finish(end_timestamp: meta.ended_at)
736
+ end
737
+ nil
738
+ end
739
+
740
+ def warn_submit_failed(error)
741
+ Bitfab.warn_once(
742
+ "otel-submit-failed",
743
+ "failed to queue an OpenTelemetry span (further occurrences " \
744
+ "suppressed): #{error.message}"
745
+ )
746
+ nil
747
+ end
748
+
614
749
  def finish_shutdown(succeeded)
615
750
  Otel.unregister_transport(self)
616
751
  succeeded
data/lib/bitfab/replay.rb CHANGED
@@ -1278,8 +1278,6 @@ module Bitfab
1278
1278
 
1279
1279
  span = http_client.get_external_span(external_span_id, replay_view: true)
1280
1280
  span_data = (span["rawData"] || {})["span_data"] || {}
1281
- # Prefer the Ruby Marshal payload (output_serialized) written by this
1282
- # SDK; fall back to another SDK's output_meta, then the raw JSON output.
1283
1281
  meta = span_data["output_serialized"]
1284
1282
  meta = span_data["output_meta"] if meta.nil? || meta == ""
1285
1283
  cache[external_span_id] = Serialize.deserialize_output(span_data["output"], meta)
@@ -1294,6 +1292,7 @@ module Bitfab
1294
1292
  {
1295
1293
  "input" => span_data["input"],
1296
1294
  "output" => span_data["output"],
1295
+ "inputMeta" => span_data["input_meta"],
1297
1296
  "inputSerialized" => span_data["input_serialized"],
1298
1297
  "outputSerialized" => span_data["output_serialized"]
1299
1298
  }
data/lib/bitfab/seed.rb CHANGED
@@ -99,7 +99,7 @@ module Bitfab
99
99
  unless source.fetch("traceFunctionKey") == trace_function_key
100
100
  raise ArgumentError, "Trace #{trace_id} belongs to '#{source["traceFunctionKey"]}', not '#{trace_function_key}'."
101
101
  end
102
- args, kwargs = Serialize.deserialize_inputs({"input" => source["input"], "input_serialized" => source["inputSerialized"]})
102
+ args, kwargs = Serialize.deserialize_inputs({"input" => source["input"], "inputSerialized" => source["inputSerialized"], "inputMeta" => source["inputMeta"]})
103
103
  run_trace_id = seed_trace(trace_function_key, callable, args:, kwargs:,
104
104
  metadata: source["metadata"], session_id: source["sessionId"], name: source["name"])
105
105
  adopted = @http_client.request(endpoint, {runTraceId: run_trace_id})
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
 
7
7
  require_relative "payload_budget"
8
+ require_relative "span_meta"
8
9
 
9
10
  module Bitfab
10
11
  module Serialize
@@ -196,35 +197,19 @@ module Bitfab
196
197
  Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
197
198
  end
198
199
 
199
- # Deserialize a recorded span output. Prefers the Ruby-side Marshal+Base64
200
- # `meta` when present (type-preserving); falls back to the raw JSON output
201
- # when meta is absent or a shape Ruby cannot reconstruct (e.g. the
202
- # superjson/jsonpickle meta another SDK's span may carry). Shared by the
203
- # inline mock-tree path and the lazy per-span fetch so both deserialize
204
- # identically.
205
- #
206
- # @param raw_output [Object] the human-readable JSON output
207
- # @param meta [String, nil] the Marshal+Base64 metadata, if any
208
- # @return [Object] the reconstructed output, or raw_output on fallback
209
200
  def deserialize_output(raw_output, meta)
210
- if meta.is_a?(String) && !meta.empty?
211
- begin
212
- return unmarshal_value(meta)
213
- rescue
214
- # Fall through to the raw JSON output
215
- end
216
- end
201
+ return SpanMeta.restore_output(raw_output, meta) if meta.is_a?(Hash)
202
+ return raw_output unless meta.is_a?(String) && !meta.empty?
203
+
204
+ unmarshal_value(meta)
205
+ rescue
217
206
  raw_output
218
207
  end
219
208
 
220
- # Deserialize replay inputs from a span's data into [args, kwargs].
221
- #
222
- # Prefers Marshal-serialized `inputSerialized` for type preservation,
223
- # falls back to the raw `input` field.
224
- #
225
- # @param item [Hash] with "inputSerialized" and/or "input" keys
226
- # @return [Array(Array, Hash)] positional args and keyword args
227
209
  def deserialize_inputs(item)
210
+ restored = SpanMeta.restore_inputs(item["input"], item["inputMeta"])
211
+ return restored if restored
212
+
228
213
  input_serialized = item["inputSerialized"]
229
214
  raw_input = item["input"]
230
215
 
@@ -236,7 +221,7 @@ module Bitfab
236
221
  end
237
222
  return deserialized.nil? ? [[], {}] : [[deserialized], {}]
238
223
  rescue
239
- # Fall through to raw_input
224
+ nil
240
225
  end
241
226
  end
242
227
 
@@ -0,0 +1,635 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Bitfab
8
+ module SpanMeta
9
+ MAX_NODES = 1_000_000
10
+ PLAIN_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
11
+
12
+ class NeedsCopy < StandardError; end
13
+
14
+ class GiveUp < StandardError; end
15
+
16
+ class Cycle < StandardError
17
+ attr_reader :target
18
+
19
+ def initialize(target)
20
+ @target = target
21
+ super("value refers back to #{target.inspect}")
22
+ end
23
+ end
24
+
25
+ SpanType = Struct.new(:name, :matches, :describe, :rebuild)
26
+
27
+ PathTrie = Struct.new(:children, :annotation, :copy, :same, :store, :symbol)
28
+
29
+ extend self
30
+
31
+ def build_input_meta(args, kwargs, readable_input)
32
+ meta = {}
33
+ meta["kwarg_names"] = kwargs.keys.map { |name| Serialize.safe_to_s(name) } unless kwargs.empty?
34
+ root = kwargs.empty? ? args.dup : args + [kwargs]
35
+ symbol_paths = kwargs.empty? ? [] : [[args.length.to_s]]
36
+ meta.merge!(value_meta(root, readable_input, symbol_paths))
37
+ meta.empty? ? nil : meta
38
+ end
39
+
40
+ def build_output_meta(value, readable_output)
41
+ meta = value_meta(value, readable_output, [])
42
+ meta.empty? ? nil : meta
43
+ end
44
+
45
+ def restore_inputs(raw_input, input_meta)
46
+ return nil unless input_meta.is_a?(Hash) && raw_input.is_a?(Array)
47
+
48
+ kwargs_path = input_meta["kwarg_names"]&.any? ? [(raw_input.length - 1).to_s] : nil
49
+ root = restore(raw_input, input_meta, kwargs_path ? [kwargs_path] : [])
50
+ return nil unless root.is_a?(Array)
51
+
52
+ args = root.dup
53
+ return [args, {}] unless kwargs_path
54
+
55
+ kwargs = args.pop
56
+ kwargs.is_a?(Hash) ? [args, kwargs] : nil
57
+ rescue
58
+ nil
59
+ end
60
+
61
+ def restore_output(raw_output, output_meta)
62
+ return raw_output unless output_meta.is_a?(Hash)
63
+
64
+ restore(raw_output, output_meta, [])
65
+ rescue
66
+ raw_output
67
+ end
68
+
69
+ def value_meta(value, readable, symbol_paths)
70
+ recorder = Recorder.new(symbol_paths)
71
+ recorder.walk(value, readable, [])
72
+ meta = recorder.meta
73
+ raise GiveUp if meta.key?("v") && !restores_exactly?(value, readable, meta, recorder.lossy, symbol_paths)
74
+
75
+ meta
76
+ rescue StandardError, SystemStackError
77
+ copy = Serialize.marshal_value(value)
78
+ copy ? {"copy" => copy} : {}
79
+ end
80
+
81
+ def restores_exactly?(value, readable, meta, lossy, symbol_paths)
82
+ restored = restore(JSON.parse(JSON.generate(readable)), JSON.parse(JSON.generate(meta)), symbol_paths)
83
+ Exact.new(lossy).same?(value, restored, [])
84
+ rescue StandardError, SystemStackError
85
+ false
86
+ end
87
+
88
+ def tracks_identity?(value)
89
+ !(value.nil? || true.equal?(value) || false.equal?(value) || value.is_a?(Numeric) ||
90
+ value.is_a?(Symbol) || value.is_a?(Module) || value.frozen? || value.is_a?(String) || value.is_a?(Time) || value.is_a?(Date))
91
+ end
92
+
93
+ def require!(condition)
94
+ raise NeedsCopy unless condition
95
+ end
96
+
97
+ def class_name_for(klass)
98
+ name = klass.name
99
+ raise NeedsCopy if name.nil? || name.empty?
100
+
101
+ name
102
+ end
103
+
104
+ def symbol_keys_note(hash, symbol_count, symbol_default)
105
+ kind = if symbol_count.zero?
106
+ "string"
107
+ elsif symbol_count == hash.size
108
+ "symbol"
109
+ else
110
+ hash.keys.select { |key| key.is_a?(Symbol) }.map(&:name)
111
+ end
112
+ default = symbol_default ? "symbol" : "string"
113
+ (kind == default) ? nil : kind
114
+ end
115
+
116
+ def plain_string?(value, readable)
117
+ return false unless readable.is_a?(String) && readable == value && value.instance_variables.empty?
118
+
119
+ case value.encoding
120
+ when Encoding::UTF_8 then value.valid_encoding?
121
+ when Encoding::US_ASCII then value.ascii_only?
122
+ else false
123
+ end
124
+ end
125
+
126
+ class Snapshot
127
+ def self.capture(args, kwargs, result)
128
+ new(Marshal.dump([args, kwargs, result]))
129
+ rescue StandardError, SystemStackError
130
+ nil
131
+ end
132
+
133
+ def initialize(bytes)
134
+ @bytes = bytes
135
+ end
136
+
137
+ def bytesize
138
+ @bytes.bytesize
139
+ end
140
+
141
+ def content?(payload)
142
+ data = payload.dig("rawSpan", "span_data")
143
+ data.is_a?(Hash) && !data["content_off_by_simulation_plan"] && (data.key?("input") || data.key?("output"))
144
+ end
145
+
146
+ def apply(payload)
147
+ return payload unless content?(payload)
148
+
149
+ data = payload["rawSpan"]["span_data"]
150
+ args, kwargs, result = Marshal.load(@bytes) # rubocop:disable Security/MarshalLoad
151
+ input_meta = SpanMeta.build_input_meta(args, kwargs, data["input"]) if data.key?("input")
152
+ output_meta = SpanMeta.build_output_meta(result, data["output"]) if data.key?("output")
153
+ data["input_meta"] = input_meta if input_meta
154
+ data["output_meta"] = output_meta if output_meta
155
+ payload
156
+ end
157
+ end
158
+
159
+ class Node
160
+ def initialize(recorder, path)
161
+ @recorder = recorder
162
+ @path = path
163
+ end
164
+
165
+ def walk(child, readable, key)
166
+ @recorder.walk(child, readable, @path + [key.to_s])
167
+ end
168
+
169
+ def walk_items(items, readable)
170
+ SpanMeta.require!(readable.is_a?(Array) && readable.length == items.length)
171
+ items.each_with_index { |item, index| walk(item, readable[index], index) }
172
+ end
173
+
174
+ def symbol_keys_default?
175
+ @recorder.symbol_path?(@path)
176
+ end
177
+ end
178
+
179
+ class Recorder
180
+ attr_reader :lossy
181
+
182
+ def initialize(symbol_paths)
183
+ @symbol_paths = symbol_paths
184
+ @values = {}
185
+ @same = []
186
+ @copies = {}
187
+ @seen = {}.compare_by_identity
188
+ @dropped = []
189
+ @lossy = []
190
+ @nodes = 0
191
+ end
192
+
193
+ def symbol_path?(path)
194
+ @symbol_paths.include?(path)
195
+ end
196
+
197
+ def meta
198
+ meta = {}
199
+ meta["values"] = SpanMeta.values_tree(@values) unless @values.empty?
200
+ meta["referentialEqualities"] = SpanMeta.equalities_record(@same) unless @same.empty?
201
+ meta["v"] = 1 unless meta.empty?
202
+ if @copies.values.sum(&:bytesize) > Serialize::MAX_SERIALIZED_BYTES
203
+ @lossy.concat(@copies.keys)
204
+ elsif @copies.key?([])
205
+ meta["copy"] = @copies[[]]
206
+ elsif !@copies.empty?
207
+ meta["copies"] = @copies.transform_keys { |path| SpanMeta.stringify_path(path) }
208
+ end
209
+ meta
210
+ end
211
+
212
+ def walk(value, readable, path)
213
+ @nodes += 1
214
+ raise GiveUp if @nodes > MAX_NODES
215
+
216
+ values_mark = @values.size
217
+ same_mark = @same.size
218
+ copies_mark = @copies.size
219
+ lossy_mark = @lossy.size
220
+ if SpanMeta.tracks_identity?(value)
221
+ first = @seen[value]
222
+ if first && !dropped?(first)
223
+ raise Cycle.new(first) if path.first(first.length) == first
224
+
225
+ @same << [path, first]
226
+ return
227
+ end
228
+ @seen[value] = path
229
+ end
230
+ annotate(value, readable, path)
231
+ rescue GiveUp
232
+ raise
233
+ rescue Cycle => cycle
234
+ raise unless cycle.target == path
235
+
236
+ copy_here(value, path, values_mark, same_mark, copies_mark, lossy_mark)
237
+ rescue StandardError, SystemStackError
238
+ copy_here(value, path, values_mark, same_mark, copies_mark, lossy_mark)
239
+ end
240
+
241
+ private
242
+
243
+ def copy_here(value, path, values_mark, same_mark, copies_mark, lossy_mark)
244
+ drop_after(values_mark, same_mark, copies_mark, lossy_mark)
245
+ @dropped << path
246
+ copy = Serialize.marshal_value(value)
247
+ copy ? @copies[path] = copy : @lossy << path
248
+ end
249
+
250
+ def drop_after(values_size, same_size, copies_size, lossy_size)
251
+ @values.keys.drop(values_size).each { |key| @values.delete(key) } if @values.size > values_size
252
+ @same.slice!(same_size..) if @same.size > same_size
253
+ @copies.keys.drop(copies_size).each { |key| @copies.delete(key) } if @copies.size > copies_size
254
+ @lossy.slice!(lossy_size..) if @lossy.size > lossy_size
255
+ end
256
+
257
+ def dropped?(path)
258
+ @dropped.any? { |prefix| path.first(prefix.length) == prefix }
259
+ end
260
+
261
+ def annotate(value, readable, path)
262
+ case value
263
+ when nil, true, false
264
+ SpanMeta.require!(readable.equal?(value))
265
+ when Integer
266
+ SpanMeta.require!(readable.is_a?(Integer) && readable == value)
267
+ when Float
268
+ SpanMeta.require!(value.finite? && readable.is_a?(Float) && readable == value)
269
+ else
270
+ return if value.instance_of?(String) && SpanMeta.plain_string?(value, readable)
271
+ return Node.new(self, path).walk_items(value, readable) if value.instance_of?(Array)
272
+
273
+ span_type = value.instance_of?(Hash) ? SPAN_TYPES_BY_NAME["rb:hash"] : SPAN_TYPES.find { |entry| entry.matches.call(value) }
274
+ raise NeedsCopy unless span_type
275
+
276
+ params = span_type.describe.call(value, readable, Node.new(self, path))
277
+ @values[path] = ["custom", span_type.name] + (params.empty? ? [] : [params]) if params
278
+ end
279
+ end
280
+ end
281
+
282
+ def describe_symbol(value, readable, _node)
283
+ require!(readable == value.to_s)
284
+ {}
285
+ end
286
+
287
+ def describe_string(value, readable, _node)
288
+ require!(readable.is_a?(String) && readable == value && value.dup.force_encoding(Encoding::UTF_8).valid_encoding?)
289
+ require!(value.instance_variables.empty?)
290
+ params = {}
291
+ params["class"] = class_name_for(value.class) unless value.instance_of?(String)
292
+ params["encoding"] = value.encoding.name unless PLAIN_ENCODINGS.include?(value.encoding)
293
+ params.empty? ? nil : params
294
+ end
295
+
296
+ def describe_array(value, readable, node)
297
+ node.walk_items(value, readable)
298
+ value.instance_of?(Array) ? nil : {"class" => class_name_for(value.class)}
299
+ end
300
+
301
+ def describe_hash(value, readable, node)
302
+ require!(value.default.nil? && value.default_proc.nil? && !value.compare_by_identity?)
303
+ require!(readable.is_a?(Hash) && readable.size == value.size)
304
+ readable_keys = readable.keys
305
+ symbol_count = 0
306
+ value.each_with_index do |(key, item), index|
307
+ name = case key
308
+ when String then key
309
+ when Symbol
310
+ symbol_count += 1
311
+ key.name
312
+ else raise NeedsCopy
313
+ end
314
+ require!(readable_keys[index] == name)
315
+ node.walk(item, readable[name], name)
316
+ end
317
+ params = {}
318
+ params["class"] = class_name_for(value.class) unless value.instance_of?(Hash)
319
+ keys = symbol_keys_note(value, symbol_count, node.symbol_keys_default?)
320
+ params["keys"] = keys if keys
321
+ params.empty? ? nil : params
322
+ end
323
+
324
+ def describe_set(value, readable, node)
325
+ require!(value.instance_of?(Set))
326
+ node.walk_items(value.to_a, readable)
327
+ {}
328
+ end
329
+
330
+ def describe_time(value, readable, _node)
331
+ require!(value.instance_of?(Time) && readable == value.iso8601(3))
332
+ {"nsec" => value.nsec}
333
+ end
334
+
335
+ def describe_date(value, readable, _node)
336
+ require!(value.instance_of?(Date) && readable == value.to_s)
337
+ {}
338
+ end
339
+
340
+ def describe_bigdecimal(value, readable, _node)
341
+ require!(readable == value.to_s)
342
+ {}
343
+ end
344
+
345
+ def describe_struct(value, readable, node)
346
+ members = value.members
347
+ require!(readable.is_a?(Hash) && readable.keys == members.map(&:to_s))
348
+ members.each { |member| node.walk(value[member], readable[member.to_s], member) }
349
+ {"class" => class_name_for(value.class)}
350
+ end
351
+
352
+ def rebuild_array(built, params)
353
+ Array.instance_method(:replace).bind_call(resolve(params["class"]).allocate, built)
354
+ end
355
+
356
+ def rebuild_string(built, params)
357
+ text = params["class"] ? String.instance_method(:replace).bind_call(resolve(params["class"]).allocate, built) : built.dup
358
+ params["encoding"] ? text.force_encoding(params["encoding"]) : text
359
+ end
360
+
361
+ def rebuild_hash(built, params)
362
+ keys = params["keys"]
363
+ hash = case keys
364
+ when "symbol" then built.transform_keys(&:to_sym)
365
+ when Array then built.transform_keys { |key| keys.include?(key) ? key.to_sym : key }
366
+ else built
367
+ end
368
+ return hash unless params["class"]
369
+
370
+ Hash.instance_method(:replace).bind_call(resolve(params["class"]).allocate, hash)
371
+ end
372
+
373
+ def rebuild_time(built, params)
374
+ base = begin
375
+ Time.new(built)
376
+ rescue ArgumentError
377
+ Time.iso8601(built)
378
+ end
379
+ zone = built.end_with?("Z") ? "UTC" : base.strftime("%:z")
380
+ Time.at(base.to_i, params["nsec"], :nsec, in: zone)
381
+ end
382
+
383
+ def rebuild_struct(built, params)
384
+ klass = resolve(params["class"])
385
+ struct = klass.allocate
386
+ klass.members.each { |member| struct[member] = built.fetch(member.to_s) }
387
+ struct
388
+ end
389
+
390
+ def resolve(name)
391
+ Object.const_get(name)
392
+ end
393
+
394
+ def stringify_path(path)
395
+ path.map { |segment| escape_segment(segment) }.join(".")
396
+ end
397
+
398
+ def escape_segment(segment)
399
+ return segment unless segment.include?(".") || segment.include?("\\")
400
+
401
+ segment.gsub("\\") { "\\\\" }.gsub(".") { "\\." }
402
+ end
403
+
404
+ def parse_path(text)
405
+ return (text.empty? ? [""] : text.split(".", -1)) unless text.include?("\\")
406
+
407
+ segments = []
408
+ segment = +""
409
+ index = 0
410
+ while index < text.length
411
+ char = text[index]
412
+ if char == "\\"
413
+ escaped = text[index + 1]
414
+ raise ArgumentError, "invalid path #{text.inspect}" unless escaped == "\\" || escaped == "."
415
+
416
+ segment << escaped
417
+ index += 2
418
+ next
419
+ end
420
+ if char == "."
421
+ segments << segment
422
+ segment = +""
423
+ else
424
+ segment << char
425
+ end
426
+ index += 1
427
+ end
428
+ segments << segment
429
+ end
430
+
431
+ def values_tree(annotations)
432
+ root = {}
433
+ annotations.each_key do |path|
434
+ node = path.reduce(root) { |parent, segment| parent[segment] ||= {} }
435
+ node[:path] = path
436
+ end
437
+ tree_for = nil
438
+ collect = lambda do |node, prefix, out|
439
+ node.each do |segment, child|
440
+ next unless segment.is_a?(String)
441
+
442
+ relative = prefix + [segment]
443
+ if child.key?(:path)
444
+ out[stringify_path(relative)] = tree_for.call(child)
445
+ else
446
+ collect.call(child, relative, out)
447
+ end
448
+ end
449
+ out
450
+ end
451
+ tree_for = lambda do |node|
452
+ kids = collect.call(node, [], {})
453
+ kids.empty? ? [annotations[node[:path]]] : [annotations[node[:path]], kids]
454
+ end
455
+ root.key?(:path) ? tree_for.call(root) : collect.call(root, [], {})
456
+ end
457
+
458
+ def flatten_values(tree, origin = [], out = {})
459
+ case tree
460
+ when Hash
461
+ tree.each { |key, child| flatten_values(child, origin + parse_path(key), out) }
462
+ when Array
463
+ unless tree.empty?
464
+ flatten_values(tree[1], origin, out) if tree[1].is_a?(Hash)
465
+ out[origin] = tree[0]
466
+ end
467
+ end
468
+ out
469
+ end
470
+
471
+ def equalities_record(same)
472
+ same.each_with_object({}) do |(path, first), record|
473
+ (record[stringify_path(first)] ||= []) << stringify_path(path)
474
+ end
475
+ end
476
+
477
+ def parse_equalities(annotations)
478
+ annotations = annotations[1] if annotations.is_a?(Array)
479
+ return {} unless annotations.is_a?(Hash)
480
+
481
+ annotations.each_with_object({}) do |(first, paths), out|
482
+ next unless paths.is_a?(Array)
483
+
484
+ paths.each { |path| out[parse_path(path)] = parse_path(first) if path.is_a?(String) }
485
+ end
486
+ end
487
+
488
+ def restore_trie(meta, symbol_paths)
489
+ root = PathTrie.new({})
490
+ insert = lambda do |path|
491
+ path.reduce(root) { |node, segment| node.children[segment] ||= PathTrie.new({}) }
492
+ end
493
+ flatten_values(meta["values"]).each { |path, annotation| insert.call(path).annotation = annotation }
494
+ parse_equalities(meta["referentialEqualities"]).each do |path, target|
495
+ insert.call(path).same = target
496
+ insert.call(target).store = target
497
+ end
498
+ meta["copies"].each { |path, copy| insert.call(parse_path(path)).copy = [copy] } if meta["copies"].is_a?(Hash)
499
+ symbol_paths.each { |path| insert.call(path).symbol = true }
500
+ root
501
+ end
502
+
503
+ def restore(readable, meta, symbol_paths)
504
+ return unmarshal_or_keep(meta["copy"], readable) if meta.key?("copy")
505
+
506
+ trie = restore_trie(meta, symbol_paths)
507
+ return readable if trie.children.empty? && trie.annotation.nil? && !trie.symbol
508
+
509
+ built = {}
510
+ build = lambda do |node, trie_node|
511
+ return node if trie_node.nil?
512
+
513
+ result = if trie_node.copy
514
+ unmarshal_or_keep(trie_node.copy.first, node)
515
+ elsif trie_node.same
516
+ built.fetch(trie_node.same, node)
517
+ else
518
+ children = if trie_node.children.empty?
519
+ node
520
+ else
521
+ case node
522
+ when Array then node.each_with_index.map { |item, index| build.call(item, trie_node.children[index.to_s]) }
523
+ when Hash then node.to_h { |key, item| [key, build.call(item, trie_node.children[key])] }
524
+ else node
525
+ end
526
+ end
527
+ apply_or_keep(annotation_for(trie_node.annotation, children, trie_node.symbol), children)
528
+ end
529
+ built[trie_node.store] = result if trie_node.store
530
+ result
531
+ end
532
+ build.call(readable, trie)
533
+ end
534
+
535
+ def annotation_for(annotation, children, symbol_default)
536
+ return annotation unless children.is_a?(Hash)
537
+ return annotation if annotation.nil? && !symbol_default
538
+
539
+ annotation ||= ["custom", "rb:hash"]
540
+ return annotation unless annotation[1] == "rb:hash"
541
+
542
+ params = annotation[2].is_a?(Hash) ? annotation[2] : {}
543
+ return annotation if params.key?("keys")
544
+
545
+ ["custom", "rb:hash", params.merge("keys" => symbol_default ? "symbol" : "string")]
546
+ end
547
+
548
+ def apply_or_keep(annotation, built)
549
+ return built unless annotation.is_a?(Array) && annotation[0] == "custom"
550
+
551
+ span_type = SPAN_TYPES_BY_NAME[annotation[1]]
552
+ return built unless span_type
553
+
554
+ span_type.rebuild.call(built, annotation[2].is_a?(Hash) ? annotation[2] : {})
555
+ rescue
556
+ built
557
+ end
558
+
559
+ def unmarshal_or_keep(encoded, readable)
560
+ return readable unless encoded.is_a?(String) && !encoded.empty?
561
+
562
+ Serialize.unmarshal_value(encoded)
563
+ rescue
564
+ readable
565
+ end
566
+
567
+ SPAN_TYPES = [
568
+ SpanType.new("rb:symbol", ->(value) { value.is_a?(Symbol) }, method(:describe_symbol), ->(built, _) { built.to_sym }),
569
+ SpanType.new("rb:string", ->(value) { value.is_a?(String) }, method(:describe_string), method(:rebuild_string)),
570
+ SpanType.new("rb:array", ->(value) { value.is_a?(Array) }, method(:describe_array), method(:rebuild_array)),
571
+ SpanType.new("rb:hash", ->(value) { value.is_a?(Hash) }, method(:describe_hash), method(:rebuild_hash)),
572
+ SpanType.new("rb:set", ->(value) { value.is_a?(Set) }, method(:describe_set), ->(built, _) { Set.new(built) }),
573
+ SpanType.new("rb:time", ->(value) { value.is_a?(Time) }, method(:describe_time), method(:rebuild_time)),
574
+ SpanType.new("rb:date", ->(value) { value.is_a?(Date) }, method(:describe_date), ->(built, _) { Date.iso8601(built) }),
575
+ SpanType.new("rb:bigdecimal", ->(value) { defined?(::BigDecimal) && value.is_a?(::BigDecimal) }, method(:describe_bigdecimal), ->(built, _) { BigDecimal(built) }),
576
+ SpanType.new("rb:struct", ->(value) { value.is_a?(Struct) }, method(:describe_struct), method(:rebuild_struct))
577
+ ].freeze
578
+
579
+ SPAN_TYPES_BY_NAME = SPAN_TYPES.to_h { |span_type| [span_type.name, span_type] }.freeze
580
+
581
+ class Exact
582
+ def initialize(lossy)
583
+ @lossy = lossy
584
+ @tracks_paths = !lossy.empty?
585
+ @forward = {}.compare_by_identity
586
+ @backward = {}.compare_by_identity
587
+ end
588
+
589
+ def same?(a, b, path)
590
+ return true if @tracks_paths && @lossy.include?(path)
591
+ return false unless a.instance_of?(b.class)
592
+
593
+ if SpanMeta.tracks_identity?(a)
594
+ return false unless @forward.fetch(a, b).equal?(b) && @backward.fetch(b, a).equal?(a)
595
+ return true if @forward.key?(a)
596
+
597
+ @forward[a] = b
598
+ @backward[b] = a
599
+ end
600
+ same_value?(a, b, path) && same_ivars?(a, b, path)
601
+ end
602
+
603
+ private
604
+
605
+ def same_value?(a, b, path)
606
+ case a
607
+ when Float then a == b || (a.nan? && b.nan?)
608
+ when String then a == b && (a.encoding == b.encoding || (a.ascii_only? && PLAIN_ENCODINGS.include?(a.encoding) && PLAIN_ENCODINGS.include?(b.encoding)))
609
+ when Array then a.length == b.length && a.each_with_index.all? { |item, index| same?(item, b[index], child(path, index)) }
610
+ when Hash then same_hash?(a, b, path)
611
+ when Time then a == b && a.utc_offset == b.utc_offset && a.utc? == b.utc? && a.zone == b.zone
612
+ when Struct then a.members.all? { |member| same?(a[member], b[member], child(path, member)) }
613
+ else
614
+ a.method(:==).owner == BasicObject || a == b
615
+ end
616
+ end
617
+
618
+ def same_hash?(a, b, path)
619
+ a.keys == b.keys && a.default == b.default && a.default_proc == b.default_proc &&
620
+ a.compare_by_identity? == b.compare_by_identity? &&
621
+ a.all? { |key, item| same?(item, b[key], child(path, key)) }
622
+ end
623
+
624
+ def same_ivars?(a, b, path)
625
+ names = a.instance_variables
626
+ names == b.instance_variables &&
627
+ names.all? { |name| same?(a.instance_variable_get(name), b.instance_variable_get(name), child(path, name)) }
628
+ end
629
+
630
+ def child(path, segment)
631
+ @tracks_paths ? path + [segment.to_s] : path
632
+ end
633
+ end
634
+ end
635
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.67.3"
4
+ VERSION = "0.68.0"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "bitfab/warn_once"
8
8
  require_relative "bitfab/experiment_id"
9
9
  require_relative "bitfab/payload_budget"
10
10
  require_relative "bitfab/serialize"
11
+ require_relative "bitfab/span_meta"
11
12
  require_relative "bitfab/db_snapshot"
12
13
  require_relative "bitfab/git_command"
13
14
  require_relative "bitfab/commit_ref"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.67.3
4
+ version: 0.68.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -211,6 +211,7 @@ files:
211
211
  - lib/bitfab/serialize.rb
212
212
  - lib/bitfab/simulation_plan.rb
213
213
  - lib/bitfab/span_context.rb
214
+ - lib/bitfab/span_meta.rb
214
215
  - lib/bitfab/span_origin.rb
215
216
  - lib/bitfab/spans.rb
216
217
  - lib/bitfab/subtree.rb