simforge 0.6.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: 0eaad388f5419c3933530ac93808928f219682b1e32d35fff040f691cbd4f410
4
- data.tar.gz: a7922a5c2a3f063b5f6c13f43e5f6defed320c82d9b8d887a3cc12293ab03808
3
+ metadata.gz: 2a76d9b40226f43c8a4109f0169d6d8c13858fbbe3a3c6856ff218fed8295d1a
4
+ data.tar.gz: 6ac64f460143f24a0e6eed048396ffd509a61106e741bdc821bc187982786ac1
5
5
  SHA512:
6
- metadata.gz: 32b1df24c8a1dd4aa8acb6b5ca6828736c1a3b5586982a169865acaaa753075b4e06009f6c2d0d6843e9ec29539cba0a28c4e0df83eaaeef65f31b98e1449637
7
- data.tar.gz: 2abbabfde142faa7c8ca0adc6e7433787f16b6b60aaafd28d26bd23c0fc890be852bdae342312540fc97e081bea05d163af66ae1b043f393c05c1f63ddd94d44
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
 
@@ -23,6 +24,21 @@ module Simforge
23
24
  @enabled = false
24
25
  end
25
26
  @http_client = HttpClient.new(api_key:, service_url: @service_url)
27
+ @pending_span_threads = {}
28
+ @pending_span_mutex = Mutex.new
29
+ end
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:)
26
42
  end
27
43
 
28
44
  # Execute a block inside a span context, sending trace data on completion.
@@ -42,9 +58,14 @@ module Simforge
42
58
  TraceState.create(trace_id)
43
59
  end
44
60
 
61
+ if is_root_span
62
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
63
+ end
64
+
45
65
  result = nil
46
66
  error = nil
47
67
  span_contexts = nil
68
+ span_prompt = nil
48
69
 
49
70
  begin
50
71
  SpanContext.with_span(trace_id:, span_id:) do
@@ -52,6 +73,7 @@ module Simforge
52
73
  ensure
53
74
  # Capture contexts before the span context is popped
54
75
  span_contexts = SpanContext.current&.dig(:contexts)
76
+ span_prompt = SpanContext.current&.dig(:prompt)
55
77
  end
56
78
  rescue => e
57
79
  error = e.message
@@ -61,7 +83,11 @@ module Simforge
61
83
  begin
62
84
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
63
85
 
64
- send_span(
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
+
90
+ span_thread = send_span(
65
91
  trace_function_key:,
66
92
  trace_id:,
67
93
  span_id:,
@@ -70,22 +96,32 @@ module Simforge
70
96
  span_type:,
71
97
  function_name:,
72
98
  contexts: span_contexts,
99
+ prompt: span_prompt,
73
100
  args:,
74
101
  kwargs:,
75
102
  result:,
76
103
  error:,
77
104
  started_at:,
78
- ended_at:
105
+ ended_at:,
106
+ test_run_id: resolved_test_run_id,
107
+ input_source_span_id: resolved_input_source_span_id
79
108
  )
80
109
 
81
- # For root spans, also send trace completion
82
110
  if is_root_span
111
+ pending = @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) || [] }
112
+ pending << span_thread if span_thread
113
+ pending.each { |t| t.join(5) }
114
+
83
115
  send_trace_completion(
84
116
  trace_function_key:,
85
117
  trace_id:,
86
118
  started_at:,
87
119
  ended_at:
88
120
  )
121
+ else
122
+ @pending_span_mutex.synchronize do
123
+ @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
124
+ end
89
125
  end
90
126
  rescue Exception # rubocop:disable Lint/RescueException
91
127
  # Silently ignore — user's result/exception takes priority
@@ -141,8 +177,8 @@ module Simforge
141
177
  end
142
178
 
143
179
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
144
- span_name:, span_type:, function_name:, contexts:, args:, kwargs:, result:, error:,
145
- started_at:, ended_at:)
180
+ span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
181
+ started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil)
146
182
  # Human-readable JSON (input/output fields)
147
183
  human_inputs = Serialize.serialize_inputs(args, kwargs)
148
184
  human_output = Serialize.serialize_value(result)
@@ -163,6 +199,7 @@ module Simforge
163
199
  span_data["output_serialized"] = marshalled_output if marshalled_output
164
200
  span_data["error"] = error if error
165
201
  span_data["contexts"] = contexts if contexts&.any?
202
+ span_data["prompt"] = prompt if prompt
166
203
 
167
204
  raw_span = {
168
205
  "id" => span_id,
@@ -172,14 +209,18 @@ module Simforge
172
209
  "span_data" => span_data
173
210
  }
174
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
175
213
 
176
- @http_client.send_external_span(
214
+ payload = {
177
215
  "type" => "sdk-function",
178
216
  "source" => "ruby-sdk-function",
179
217
  "sourceTraceId" => trace_id,
180
218
  "traceFunctionKey" => trace_function_key,
181
219
  "rawSpan" => raw_span
182
- )
220
+ }
221
+ payload["testRunId"] = test_run_id if test_run_id
222
+
223
+ @http_client.send_external_span(payload) # Returns the background thread
183
224
  end
184
225
  end
185
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
@@ -57,7 +57,8 @@ module Simforge
57
57
  raise last_error
58
58
  end
59
59
 
60
- # Send an external span (fire-and-forget in background thread).
60
+ # Send an external span in a background thread.
61
+ # Returns the thread for callers that need to await completion.
61
62
  def send_external_span(payload)
62
63
  merged = payload.merge("sdkVersion" => VERSION)
63
64
 
@@ -66,6 +67,57 @@ module Simforge
66
67
  end
67
68
  end
68
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
+
69
121
  # Send an external trace (fire-and-forget in background thread).
70
122
  def send_external_trace(payload)
71
123
  merged = payload.merge("sdkVersion" => VERSION)
@@ -92,16 +144,22 @@ module Simforge
92
144
 
93
145
  class << self
94
146
  # Run a block in a background thread with tracking.
147
+ # Returns the thread for callers that need to join on it.
95
148
  def _run_in_background(&block)
96
149
  thread = Thread.new do
97
150
  block.call
98
- rescue
99
- # Silently ignore failures in background spans
151
+ rescue => e
152
+ begin
153
+ warn "Simforge: Failed to send request: #{e.message}"
154
+ rescue
155
+ # Never crash the host app
156
+ end
100
157
  ensure
101
158
  @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
102
159
  end
103
160
 
104
161
  @pending_threads_mutex.synchronize { @pending_threads << thread }
162
+ thread
105
163
  end
106
164
 
107
165
  # Wait for all pending background threads to complete.
@@ -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
@@ -25,6 +25,19 @@ module Simforge
25
25
  rescue
26
26
  # Silently ignore - never crash the host app
27
27
  end
28
+
29
+ # Set the prompt for this span.
30
+ # The prompt is stored in span_data.prompt. Calling multiple times
31
+ # overwrites the previous value.
32
+ #
33
+ # @param prompt [String] the prompt string to store
34
+ def set_prompt(prompt)
35
+ return unless prompt.is_a?(String)
36
+
37
+ @span_state[:prompt] = prompt
38
+ rescue
39
+ # Silently ignore - never crash the host app
40
+ end
28
41
  end
29
42
 
30
43
  # Handle to the current active trace, allowing trace-level context to be set.
@@ -84,7 +97,7 @@ module Simforge
84
97
  # Thread-local span stack for tracking nested spans.
85
98
  # Each entry is a Hash with :trace_id and :span_id keys.
86
99
  module SpanContext
87
- STACK_KEY = :simforge_span_stack
100
+ STACK_KEY = :__simforge_span_stack
88
101
 
89
102
  module_function
90
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Simforge
4
- VERSION = "0.6.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
 
@@ -19,6 +20,10 @@ module Simforge
19
20
  def add_context(_context)
20
21
  # No-op
21
22
  end
23
+
24
+ def set_prompt(_prompt)
25
+ # No-op
26
+ end
22
27
  end
23
28
 
24
29
  # No-op trace handle returned when outside a span context.
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.6.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-13 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: []