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 +4 -4
- data/README.md +1 -1
- data/lib/simforge/client.rb +28 -4
- data/lib/simforge/constants.rb +1 -0
- data/lib/simforge/http_client.rb +51 -0
- data/lib/simforge/replay.rb +149 -0
- data/lib/simforge/serialize.rb +34 -0
- data/lib/simforge/span_context.rb +1 -1
- data/lib/simforge/version.rb +1 -1
- data/lib/simforge.rb +1 -0
- metadata +19 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a76d9b40226f43c8a4109f0169d6d8c13858fbbe3a3c6856ff218fed8295d1a
|
|
4
|
+
data.tar.gz: 6ac64f460143f24a0e6eed048396ffd509a61106e741bdc821bc187982786ac1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d466e47c84372946639d021ebdf20ffca1e330f7625f2d2d90e004a2d1c0d4bc4622cccc3e846d1b24918c28062de32e6a60107f2a0fe67bd085649d9e190027
|
|
7
|
+
data.tar.gz: cc2d549e359de49705dc92c0c33be38167041663385d12b4a75d12db02679d3d2271d940c6c0631770cea0da9070d656ab60f49e18f90b48cbcc311e9dc9e270
|
data/README.md
CHANGED
data/lib/simforge/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/simforge/constants.rb
CHANGED
data/lib/simforge/http_client.rb
CHANGED
|
@@ -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
|
data/lib/simforge/serialize.rb
CHANGED
|
@@ -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 = :
|
|
100
|
+
STACK_KEY = :__simforge_span_stack
|
|
101
101
|
|
|
102
102
|
module_function
|
|
103
103
|
|
data/lib/simforge/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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: []
|