simforge 0.8.0 → 0.9.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: 73a5e6deba272d1145d6403c0101d8f32a694f236ac4a976d02eb11b79b17c25
4
- data.tar.gz: 89481b2c5f3e7a00946aba1e438c21df60b9c8138a04720b1a22701a35095c73
3
+ metadata.gz: 2a76d9b40226f43c8a4109f0169d6d8c13858fbbe3a3c6856ff218fed8295d1a
4
+ data.tar.gz: 6ac64f460143f24a0e6eed048396ffd509a61106e741bdc821bc187982786ac1
5
5
  SHA512:
6
- metadata.gz: 2626a260759ff51764a1b6497b29ac1493acbc86ea9a0e347a39bd5b02987c5bc4012d7b5e7839887f634da72643370c75b6835a92da59c6b7768ae525fae6e2
7
- data.tar.gz: 45b37048fc7d7338135f5b2d9cd8d6a4a111034587d055aeb9b58e9cd44ac562a1e00e19b9833293f68afc81ff5b47e184e31e410e5c7a795739ef2bdea17e5f
6
+ metadata.gz: d466e47c84372946639d021ebdf20ffca1e330f7625f2d2d90e004a2d1c0d4bc4622cccc3e846d1b24918c28062de32e6a60107f2a0fe67bd085649d9e190027
7
+ data.tar.gz: cc2d549e359de49705dc92c0c33be38167041663385d12b4a75d12db02679d3d2271d940c6c0631770cea0da9070d656ab60f49e18f90b48cbcc311e9dc9e270
data/README.md CHANGED
@@ -18,7 +18,7 @@ gem install simforge
18
18
 
19
19
  ## Requirements
20
20
 
21
- - Ruby >= 3.1
21
+ - Ruby >= 3.4
22
22
  - No external runtime dependencies (uses stdlib only)
23
23
 
24
24
  ## Quick Start
@@ -5,6 +5,7 @@ require "time"
5
5
 
6
6
  require_relative "constants"
7
7
  require_relative "http_client"
8
+ require_relative "replay"
8
9
  require_relative "span_context"
9
10
  require_relative "serialize"
10
11
 
@@ -27,6 +28,19 @@ module Simforge
27
28
  @pending_span_mutex = Mutex.new
28
29
  end
29
30
 
31
+ # Replay historical traces through a method and create a test run.
32
+ #
33
+ # @param receiver [Object, Class] an instance for instance methods, or a Class for class methods
34
+ # @param method_name [Symbol] the method to replay
35
+ # @param trace_function_key [String] the trace function key for this method
36
+ # @param limit [Integer] maximum number of traces to replay (default: 5)
37
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
38
+ # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
39
+ # @return [Hash] with :items, :test_run_id, :test_run_url
40
+ def replay(receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10)
41
+ Replay.run(self, receiver, method_name, trace_function_key:, limit:, trace_ids:, max_concurrency:)
42
+ end
43
+
30
44
  # Execute a block inside a span context, sending trace data on completion.
31
45
  # Called by Traceable — not intended for direct use.
32
46
  def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
@@ -69,6 +83,10 @@ module Simforge
69
83
  begin
70
84
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
71
85
 
86
+ replay_ctx = ReplayContext.current
87
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
88
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
89
+
72
90
  span_thread = send_span(
73
91
  trace_function_key:,
74
92
  trace_id:,
@@ -84,7 +102,9 @@ module Simforge
84
102
  result:,
85
103
  error:,
86
104
  started_at:,
87
- ended_at:
105
+ ended_at:,
106
+ test_run_id: resolved_test_run_id,
107
+ input_source_span_id: resolved_input_source_span_id
88
108
  )
89
109
 
90
110
  if is_root_span
@@ -158,7 +178,7 @@ module Simforge
158
178
 
159
179
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
160
180
  span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
161
- started_at:, ended_at:)
181
+ started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil)
162
182
  # Human-readable JSON (input/output fields)
163
183
  human_inputs = Serialize.serialize_inputs(args, kwargs)
164
184
  human_output = Serialize.serialize_value(result)
@@ -189,14 +209,18 @@ module Simforge
189
209
  "span_data" => span_data
190
210
  }
191
211
  raw_span["parent_id"] = parent_span_id if parent_span_id
212
+ raw_span["input_source_span_id"] = input_source_span_id if input_source_span_id
192
213
 
193
- @http_client.send_external_span(
214
+ payload = {
194
215
  "type" => "sdk-function",
195
216
  "source" => "ruby-sdk-function",
196
217
  "sourceTraceId" => trace_id,
197
218
  "traceFunctionKey" => trace_function_key,
198
219
  "rawSpan" => raw_span
199
- ) # Returns the background thread
220
+ }
221
+ payload["testRunId"] = test_run_id if test_run_id
222
+
223
+ @http_client.send_external_span(payload) # Returns the background thread
200
224
  end
201
225
  end
202
226
  end
@@ -2,4 +2,5 @@
2
2
 
3
3
  module Simforge
4
4
  DEFAULT_SERVICE_URL = "https://simforge.goharvest.ai"
5
+ REPLAY_CONTEXT_KEY = :__simforge_replay_context
5
6
  end
@@ -67,6 +67,57 @@ module Simforge
67
67
  end
68
68
  end
69
69
 
70
+ # Make a GET request to the Simforge API.
71
+ # Returns parsed JSON response hash.
72
+ def get(endpoint, timeout: nil)
73
+ uri = URI("#{@service_url}#{endpoint}")
74
+ request_timeout = timeout || @timeout
75
+
76
+ http = Net::HTTP.new(uri.host, uri.port)
77
+ http.use_ssl = uri.scheme == "https"
78
+ http.open_timeout = request_timeout
79
+ http.read_timeout = request_timeout
80
+
81
+ req = Net::HTTP::Get.new(uri.path, headers)
82
+ response = http.request(req)
83
+
84
+ unless response.is_a?(Net::HTTPSuccess)
85
+ raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
86
+ end
87
+
88
+ result = JSON.parse(response.body)
89
+
90
+ if result["error"]
91
+ msg = result["error"]
92
+ msg = "#{msg} Configure it at: #{@service_url}#{result["url"]}" if result["url"]
93
+ raise StandardError, msg
94
+ end
95
+
96
+ result
97
+ end
98
+
99
+ # Start a replay session by fetching historical traces.
100
+ # Blocking call. Returns hash with testRunId, testRunUrl, and items array.
101
+ def start_replay(trace_function_key, limit, trace_ids: nil)
102
+ payload = {
103
+ "traceFunctionKey" => trace_function_key,
104
+ "limit" => limit
105
+ }
106
+ payload["traceIds"] = trace_ids if trace_ids
107
+
108
+ request("/api/sdk/replay/start", payload, timeout: 30)
109
+ end
110
+
111
+ # Fetch an external span by ID. Blocking GET request.
112
+ def get_external_span(span_id)
113
+ get("/api/sdk/externalSpans/#{span_id}", timeout: 30)
114
+ end
115
+
116
+ # Mark a replay test run as completed. Blocking call.
117
+ def complete_replay(test_run_id)
118
+ request("/api/sdk/replay/complete", {"testRunId" => test_run_id}, timeout: 30)
119
+ end
120
+
70
121
  # Send an external trace (fire-and-forget in background thread).
71
122
  def send_external_trace(payload)
72
123
  merged = payload.merge("sdkVersion" => VERSION)
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "serialize"
5
+
6
+ module Simforge
7
+ # Thread-local replay context management.
8
+ module ReplayContext
9
+ module_function
10
+
11
+ def current
12
+ Thread.current[REPLAY_CONTEXT_KEY]
13
+ end
14
+
15
+ # Execute a block with replay context set on the current thread.
16
+ # The context is automatically cleared when the block completes.
17
+ def with_context(test_run_id:, input_source_span_id: nil)
18
+ previous = Thread.current[REPLAY_CONTEXT_KEY]
19
+ Thread.current[REPLAY_CONTEXT_KEY] = {
20
+ test_run_id:,
21
+ input_source_span_id:
22
+ }
23
+ yield
24
+ ensure
25
+ Thread.current[REPLAY_CONTEXT_KEY] = previous
26
+ end
27
+ end
28
+
29
+ # Replay historical traces through a traced method and create a test run.
30
+ module Replay
31
+ module_function
32
+
33
+ # Replay historical traces through a method and create a test run.
34
+ #
35
+ # Fetches the last N traces for the given trace function key, re-runs each
36
+ # through the provided receiver and method, and returns comparison data.
37
+ #
38
+ # @param client [Simforge::Client] the Simforge client instance
39
+ # @param receiver [Object, Class] an instance for instance methods, or a Class for class methods
40
+ # @param method_name [Symbol] the method to replay
41
+ # @param trace_function_key [String] the trace function key for this method
42
+ # @param limit [Integer] maximum number of traces to replay (default: 5)
43
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
44
+ # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
45
+ # @return [Hash] with :items, :test_run_id, :test_run_url
46
+ def run(client, receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10)
47
+ http_client = client.instance_variable_get(:@http_client)
48
+
49
+ replay_data = http_client.start_replay(trace_function_key, limit, trace_ids:)
50
+ test_run_id = replay_data["testRunId"]
51
+ test_run_url = replay_data["testRunUrl"]
52
+ server_items = replay_data["items"] || []
53
+
54
+ result_items = if server_items.any?
55
+ process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
56
+ else
57
+ []
58
+ end
59
+
60
+ Simforge.flush_traces
61
+
62
+ begin
63
+ http_client.complete_replay(test_run_id)
64
+ rescue => e
65
+ warn "Simforge: Failed to complete replay: #{e.message}"
66
+ end
67
+
68
+ {
69
+ items: result_items,
70
+ test_run_id:,
71
+ test_run_url: "#{client.service_url}#{test_run_url}"
72
+ }
73
+ end
74
+
75
+ # Process all replay items, optionally in parallel using threads.
76
+ def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
77
+ concurrency = max_concurrency || server_items.length
78
+
79
+ if concurrency <= 1
80
+ server_items.map { |item| process_single_item(http_client, item, receiver, method_name, test_run_id) }
81
+ else
82
+ results_mutex = Mutex.new
83
+ results = []
84
+ work_queue = server_items.each_with_index.to_a
85
+ work_mutex = Mutex.new
86
+
87
+ workers = [concurrency, server_items.length].min.times.map do
88
+ Thread.new do
89
+ loop do
90
+ item, idx = work_mutex.synchronize { work_queue.shift }
91
+ break unless item
92
+
93
+ result = process_single_item(http_client, item, receiver, method_name, test_run_id)
94
+ results_mutex.synchronize { results[idx] = result }
95
+ end
96
+ end
97
+ end
98
+
99
+ workers.each(&:join)
100
+ results.compact
101
+ end
102
+ end
103
+
104
+ # Fetch span data and execute a single replay item.
105
+ def process_single_item(http_client, server_item, receiver, method_name, test_run_id)
106
+ span = http_client.get_external_span(server_item["externalSpanId"])
107
+ item_data = extract_span_data(span)
108
+ execute_item(item_data, receiver, method_name, test_run_id, span["id"])
109
+ end
110
+
111
+ # Extract input/output data from an external span's rawData.
112
+ def extract_span_data(span)
113
+ raw_data = span["rawData"] || {}
114
+ span_data = raw_data["span_data"] || {}
115
+
116
+ {
117
+ "input" => span_data["input"],
118
+ "output" => span_data["output"],
119
+ "inputSerialized" => span_data["input_serialized"],
120
+ "outputSerialized" => span_data["output_serialized"]
121
+ }
122
+ end
123
+
124
+ # Execute a single replay item: deserialize inputs, call method with replay context.
125
+ def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil)
126
+ args, kwargs = Serialize.deserialize_inputs(item)
127
+
128
+ fn_result = nil
129
+ fn_error = nil
130
+
131
+ ReplayContext.with_context(test_run_id:, input_source_span_id:) do
132
+ fn_result = if kwargs.empty?
133
+ receiver.send(method_name, *args)
134
+ else
135
+ receiver.send(method_name, *args, **kwargs)
136
+ end
137
+ rescue => e
138
+ fn_error = e.message
139
+ end
140
+
141
+ {
142
+ input: args,
143
+ result: fn_result,
144
+ original_output: item["output"],
145
+ error: fn_error
146
+ }
147
+ end
148
+ end
149
+ end
@@ -65,5 +65,39 @@ module Simforge
65
65
  def unmarshal_value(encoded)
66
66
  Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
67
67
  end
68
+
69
+ # Deserialize replay inputs from a span's data into [args, kwargs].
70
+ #
71
+ # Prefers Marshal-serialized `inputSerialized` for type preservation,
72
+ # falls back to the raw `input` field.
73
+ #
74
+ # @param item [Hash] with "inputSerialized" and/or "input" keys
75
+ # @return [Array(Array, Hash)] positional args and keyword args
76
+ def deserialize_inputs(item)
77
+ input_serialized = item["inputSerialized"]
78
+ raw_input = item["input"]
79
+
80
+ if input_serialized.is_a?(String) && !input_serialized.empty?
81
+ begin
82
+ deserialized = unmarshal_value(input_serialized)
83
+ if deserialized.is_a?(Hash) && (deserialized.key?(:args) || deserialized.key?(:kwargs))
84
+ return [deserialized[:args] || [], deserialized[:kwargs] || {}]
85
+ end
86
+ return deserialized.nil? ? [[], {}] : [[deserialized], {}]
87
+ rescue
88
+ # Fall through to raw_input
89
+ end
90
+ end
91
+
92
+ if raw_input.is_a?(Array)
93
+ [raw_input, {}]
94
+ elsif raw_input.is_a?(Hash)
95
+ [[], raw_input.transform_keys(&:to_sym)]
96
+ elsif raw_input.nil?
97
+ [[], {}]
98
+ else
99
+ [[raw_input], {}]
100
+ end
101
+ end
68
102
  end
69
103
  end
@@ -97,7 +97,7 @@ module Simforge
97
97
  # Thread-local span stack for tracking nested spans.
98
98
  # Each entry is a Hash with :trace_id and :span_id keys.
99
99
  module SpanContext
100
- STACK_KEY = :simforge_span_stack
100
+ STACK_KEY = :__simforge_span_stack
101
101
 
102
102
  module_function
103
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Simforge
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/simforge.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "simforge/constants"
5
5
  require_relative "simforge/serialize"
6
6
  require_relative "simforge/span_context"
7
7
  require_relative "simforge/http_client"
8
+ require_relative "simforge/replay"
8
9
  require_relative "simforge/client"
9
10
  require_relative "simforge/traceable"
10
11
 
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simforge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-02-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  - !ruby/object:Gem::Dependency
14
27
  name: rake
15
28
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +120,7 @@ files:
107
120
  - lib/simforge/client.rb
108
121
  - lib/simforge/constants.rb
109
122
  - lib/simforge/http_client.rb
123
+ - lib/simforge/replay.rb
110
124
  - lib/simforge/serialize.rb
111
125
  - lib/simforge/span_context.rb
112
126
  - lib/simforge/traceable.rb
@@ -118,7 +132,6 @@ metadata:
118
132
  homepage_uri: https://simforge.goharvest.ai
119
133
  source_code_uri: https://simforge.goharvest.ai
120
134
  rubygems_mfa_required: 'true'
121
- post_install_message:
122
135
  rdoc_options: []
123
136
  require_paths:
124
137
  - lib
@@ -126,15 +139,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
126
139
  requirements:
127
140
  - - ">="
128
141
  - !ruby/object:Gem::Version
129
- version: '3.1'
142
+ version: '3.4'
130
143
  required_rubygems_version: !ruby/object:Gem::Requirement
131
144
  requirements:
132
145
  - - ">="
133
146
  - !ruby/object:Gem::Version
134
147
  version: '0'
135
148
  requirements: []
136
- rubygems_version: 3.3.26
137
- signing_key:
149
+ rubygems_version: 3.6.9
138
150
  specification_version: 4
139
151
  summary: Simforge Ruby SDK for function tracing and span management
140
152
  test_files: []