simforge 0.5.2 → 0.8.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: b9f2c1b27222c1a19c1ee819c021349f8f7cd479432e38a8b8ed5d218b13b026
4
- data.tar.gz: '059c030ed76519b30a5fd187392af8af0d29ba6a0704132359792ef4381fed9c'
3
+ metadata.gz: 73a5e6deba272d1145d6403c0101d8f32a694f236ac4a976d02eb11b79b17c25
4
+ data.tar.gz: 89481b2c5f3e7a00946aba1e438c21df60b9c8138a04720b1a22701a35095c73
5
5
  SHA512:
6
- metadata.gz: 7057e4d20182d99a6c71b0a5fe4f52f7d661230a111967a805421f5459463371ecc8849a7bbfcb7f313e16a3bf9623b50002393eeed91ef7e311cbfe43fd6e3a
7
- data.tar.gz: a76648e123b02fc6a00124910c42624c9da7819df4b8437d8fb1b8aed64498d72871749da56e20a8ba94bc86d2ba0dd013da70ca843900138e33b65ac5e00e12
6
+ metadata.gz: 2626a260759ff51764a1b6497b29ac1493acbc86ea9a0e347a39bd5b02987c5bc4012d7b5e7839887f634da72643370c75b6835a92da59c6b7768ae525fae6e2
7
+ data.tar.gz: 45b37048fc7d7338135f5b2d9cd8d6a4a111034587d055aeb9b58e9cd44ac562a1e00e19b9833293f68afc81ff5b47e184e31e410e5c7a795739ef2bdea17e5f
data/README.md CHANGED
@@ -244,7 +244,7 @@ end
244
244
  # Runtime metadata (inside a span)
245
245
  simforge_span :process_order, type: "function"
246
246
  def process_order(order_id)
247
- Simforge.current_span.set_metadata(
247
+ Simforge.current_span.add_metadata(
248
248
  "user_id" => current_user.id,
249
249
  "request_id" => request.id
250
250
  )
@@ -23,29 +23,43 @@ module Simforge
23
23
  @enabled = false
24
24
  end
25
25
  @http_client = HttpClient.new(api_key:, service_url: @service_url)
26
+ @pending_span_threads = {}
27
+ @pending_span_mutex = Mutex.new
26
28
  end
27
29
 
28
30
  # Execute a block inside a span context, sending trace data on completion.
29
31
  # Called by Traceable — not intended for direct use.
30
- def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:, metadata: nil)
32
+ def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
31
33
  return yield unless @enabled
32
34
 
33
35
  parent = SpanContext.current
34
36
  trace_id = parent ? parent[:trace_id] : SecureRandom.uuid
35
37
  span_id = SecureRandom.uuid
36
38
  parent_span_id = parent&.dig(:span_id)
39
+ is_root_span = parent_span_id.nil?
37
40
  started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
38
41
 
42
+ # Register trace state for root spans
43
+ if is_root_span && !TraceState.get(trace_id)
44
+ TraceState.create(trace_id)
45
+ end
46
+
47
+ if is_root_span
48
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
49
+ end
50
+
39
51
  result = nil
40
52
  error = nil
41
- runtime_metadata = nil
53
+ span_contexts = nil
54
+ span_prompt = nil
42
55
 
43
56
  begin
44
57
  SpanContext.with_span(trace_id:, span_id:) do
45
58
  result = yield
46
59
  ensure
47
- # Capture runtime metadata before the span context is popped
48
- runtime_metadata = SpanContext.current&.dig(:metadata)
60
+ # Capture contexts before the span context is popped
61
+ span_contexts = SpanContext.current&.dig(:contexts)
62
+ span_prompt = SpanContext.current&.dig(:prompt)
49
63
  end
50
64
  rescue => e
51
65
  error = e.message
@@ -55,14 +69,7 @@ module Simforge
55
69
  begin
56
70
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
57
71
 
58
- # Merge runtime metadata with definition-time metadata (runtime wins)
59
- merged_metadata = if runtime_metadata
60
- (metadata || {}).merge(runtime_metadata)
61
- else
62
- metadata
63
- end
64
-
65
- send_span(
72
+ span_thread = send_span(
66
73
  trace_function_key:,
67
74
  trace_id:,
68
75
  span_id:,
@@ -70,7 +77,8 @@ module Simforge
70
77
  span_name:,
71
78
  span_type:,
72
79
  function_name:,
73
- metadata: merged_metadata,
80
+ contexts: span_contexts,
81
+ prompt: span_prompt,
74
82
  args:,
75
83
  kwargs:,
76
84
  result:,
@@ -78,6 +86,23 @@ module Simforge
78
86
  started_at:,
79
87
  ended_at:
80
88
  )
89
+
90
+ if is_root_span
91
+ pending = @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) || [] }
92
+ pending << span_thread if span_thread
93
+ pending.each { |t| t.join(5) }
94
+
95
+ send_trace_completion(
96
+ trace_function_key:,
97
+ trace_id:,
98
+ started_at:,
99
+ ended_at:
100
+ )
101
+ else
102
+ @pending_span_mutex.synchronize do
103
+ @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
104
+ end
105
+ end
81
106
  rescue Exception # rubocop:disable Lint/RescueException
82
107
  # Silently ignore — user's result/exception takes priority
83
108
  # Catches Exception (not just StandardError) to handle SystemStackError
@@ -96,8 +121,43 @@ module Simforge
96
121
  raise ArgumentError, "Invalid span type '#{type}'. Must be one of: #{SPAN_TYPES.join(", ")}"
97
122
  end
98
123
 
124
+ def send_trace_completion(trace_function_key:, trace_id:, started_at:, ended_at:)
125
+ trace_state = TraceState.get(trace_id)
126
+ trace_started_at = trace_state&.dig(:started_at) || started_at
127
+
128
+ raw_trace = {
129
+ "id" => trace_id,
130
+ "started_at" => trace_started_at,
131
+ "ended_at" => ended_at
132
+ }
133
+
134
+ if trace_state&.dig(:metadata)
135
+ raw_trace["metadata"] = trace_state[:metadata]
136
+ end
137
+ if trace_state&.dig(:contexts)
138
+ raw_trace["contexts"] = trace_state[:contexts]
139
+ end
140
+
141
+ payload = {
142
+ "type" => "sdk-function",
143
+ "source" => "ruby-sdk-function",
144
+ "traceFunctionKey" => trace_function_key,
145
+ "externalTrace" => raw_trace,
146
+ "completed" => true
147
+ }
148
+
149
+ if trace_state&.dig(:session_id)
150
+ payload["sessionId"] = trace_state[:session_id]
151
+ end
152
+
153
+ @http_client.send_external_trace(payload)
154
+
155
+ # Clean up trace state
156
+ TraceState.delete(trace_id)
157
+ end
158
+
99
159
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
100
- span_name:, span_type:, function_name:, metadata:, args:, kwargs:, result:, error:,
160
+ span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
101
161
  started_at:, ended_at:)
102
162
  # Human-readable JSON (input/output fields)
103
163
  human_inputs = Serialize.serialize_inputs(args, kwargs)
@@ -118,7 +178,8 @@ module Simforge
118
178
  span_data["input_serialized"] = marshalled_input if marshalled_input
119
179
  span_data["output_serialized"] = marshalled_output if marshalled_output
120
180
  span_data["error"] = error if error
121
- span_data["metadata"] = metadata if metadata
181
+ span_data["contexts"] = contexts if contexts&.any?
182
+ span_data["prompt"] = prompt if prompt
122
183
 
123
184
  raw_span = {
124
185
  "id" => span_id,
@@ -135,7 +196,7 @@ module Simforge
135
196
  "sourceTraceId" => trace_id,
136
197
  "traceFunctionKey" => trace_function_key,
137
198
  "rawSpan" => raw_span
138
- )
199
+ ) # Returns the background thread
139
200
  end
140
201
  end
141
202
  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,15 @@ module Simforge
66
67
  end
67
68
  end
68
69
 
70
+ # Send an external trace (fire-and-forget in background thread).
71
+ def send_external_trace(payload)
72
+ merged = payload.merge("sdkVersion" => VERSION)
73
+
74
+ Simforge._run_in_background do
75
+ request("/api/sdk/externalTraces", merged, timeout: 10)
76
+ end
77
+ end
78
+
69
79
  private
70
80
 
71
81
  def headers
@@ -83,16 +93,22 @@ module Simforge
83
93
 
84
94
  class << self
85
95
  # Run a block in a background thread with tracking.
96
+ # Returns the thread for callers that need to join on it.
86
97
  def _run_in_background(&block)
87
98
  thread = Thread.new do
88
99
  block.call
89
- rescue
90
- # Silently ignore failures in background spans
100
+ rescue => e
101
+ begin
102
+ warn "Simforge: Failed to send request: #{e.message}"
103
+ rescue
104
+ # Never crash the host app
105
+ end
91
106
  ensure
92
107
  @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
93
108
  end
94
109
 
95
110
  @pending_threads_mutex.synchronize { @pending_threads << thread }
111
+ thread
96
112
  end
97
113
 
98
114
  # Wait for all pending background threads to complete.
@@ -1,19 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Simforge
4
- # Handle to the current active span, allowing runtime metadata to be set.
4
+ # Handle to the current active span, allowing context to be added.
5
5
  class CurrentSpan
6
- def initialize(context)
7
- @context = context
6
+ def initialize(span_state)
7
+ @span_state = span_state
8
8
  end
9
9
 
10
- # Set custom metadata on this span. Merges with any existing metadata
11
- # (both definition-time and previous runtime calls), with later values
12
- # taking precedence on conflict.
10
+ # The trace ID for the current span.
11
+ def trace_id
12
+ @span_state[:trace_id]
13
+ end
14
+
15
+ # Add a context entry to this span.
16
+ # The entire hash is pushed as a single entry in the contexts array.
17
+ # Context entries are accumulated - multiple calls add to the list.
18
+ #
19
+ # @param context [Hash] key-value pairs to add as a single context entry
20
+ def add_context(context)
21
+ return unless context.is_a?(Hash)
22
+
23
+ @span_state[:contexts] ||= []
24
+ @span_state[:contexts] << context
25
+ rescue
26
+ # Silently ignore - never crash the host app
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
41
+ end
42
+
43
+ # Handle to the current active trace, allowing trace-level context to be set.
44
+ class CurrentTrace
45
+ def initialize(trace_id)
46
+ @trace_id = trace_id
47
+ end
48
+
49
+ # Set the session ID for this trace.
50
+ # Session ID is used to group traces from the same user session.
51
+ # This is stored as a database column.
52
+ #
53
+ # @param session_id [String] the session ID to set
54
+ def set_session_id(session_id)
55
+ trace_state = get_or_create_trace_state
56
+ trace_state[:session_id] = session_id
57
+ rescue
58
+ # Silently ignore - never crash the host app
59
+ end
60
+
61
+ # Set metadata for this trace.
62
+ # Metadata is stored in the raw trace data. Subsequent calls merge with
63
+ # existing metadata, with later values taking precedence.
64
+ #
65
+ # @param metadata [Hash] key-value pairs to store as trace metadata
13
66
  def set_metadata(metadata)
14
67
  return unless metadata.is_a?(Hash)
15
68
 
16
- @context[:metadata] = (@context[:metadata] || {}).merge(metadata)
69
+ trace_state = get_or_create_trace_state
70
+ trace_state[:metadata] = (trace_state[:metadata] || {}).merge(metadata)
71
+ rescue
72
+ # Silently ignore - never crash the host app
73
+ end
74
+
75
+ # Add a context entry to this trace.
76
+ # The entire hash is pushed as a single entry in the contexts array.
77
+ # Context entries are accumulated - multiple calls add to the list.
78
+ #
79
+ # @param context [Hash] key-value pairs to add as a single context entry
80
+ def add_context(context)
81
+ return unless context.is_a?(Hash)
82
+
83
+ trace_state = get_or_create_trace_state
84
+ trace_state[:contexts] ||= []
85
+ trace_state[:contexts] << context
86
+ rescue
87
+ # Silently ignore - never crash the host app
88
+ end
89
+
90
+ private
91
+
92
+ def get_or_create_trace_state
93
+ TraceState.get(@trace_id) || TraceState.create(@trace_id)
17
94
  end
18
95
  end
19
96
 
@@ -42,4 +119,33 @@ module Simforge
42
119
  stack.pop
43
120
  end
44
121
  end
122
+
123
+ # Global storage for trace states (trace_id -> state hash)
124
+ module TraceState
125
+ @states_mutex = Mutex.new
126
+ @states = {}
127
+
128
+ module_function
129
+
130
+ def get(trace_id)
131
+ @states_mutex.synchronize { @states[trace_id] }
132
+ end
133
+
134
+ def create(trace_id)
135
+ @states_mutex.synchronize do
136
+ @states[trace_id] ||= {
137
+ trace_id:,
138
+ started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
139
+ }
140
+ end
141
+ end
142
+
143
+ def delete(trace_id)
144
+ @states_mutex.synchronize { @states.delete(trace_id) }
145
+ end
146
+
147
+ def clear_all
148
+ @states_mutex.synchronize { @states.clear }
149
+ end
150
+ end
45
151
  end
@@ -38,10 +38,9 @@ module Simforge
38
38
  # @param trace_function_key [String] the trace function key
39
39
  # @param name [String, nil] explicit span name (defaults to method name)
40
40
  # @param type [String] span type: llm, agent, function, guardrail, handoff, custom
41
- def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom", metadata: nil)
41
+ def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom")
42
42
  span_name = name || method_name.to_s
43
43
  method_name_str = method_name.to_s
44
- span_metadata = metadata
45
44
 
46
45
  wrapper = Module.new do
47
46
  define_method(method_name) do |*args, **kwargs, &block|
@@ -50,7 +49,6 @@ module Simforge
50
49
  span_name:,
51
50
  span_type: type,
52
51
  function_name: method_name_str,
53
- metadata: span_metadata,
54
52
  args:,
55
53
  kwargs:) do
56
54
  super(*args, **kwargs, &block)
@@ -87,7 +85,7 @@ module Simforge
87
85
  # @param trace_function_key [String, nil] trace function key (overrides class-level simforge_function)
88
86
  # @param name [String, nil] explicit span name (defaults to method name)
89
87
  # @param type [String] span type: llm, agent, function, guardrail, handoff, custom
90
- def simforge_span(method_name, trace_function_key: nil, name: nil, type: "custom", metadata: nil)
88
+ def simforge_span(method_name, trace_function_key: nil, name: nil, type: "custom")
91
89
  trace_function_key ||= @simforge_function_key
92
90
  unless trace_function_key
93
91
  raise "No trace function key provided. Pass `trace_function_key:` to `simforge_span` " \
@@ -96,15 +94,14 @@ module Simforge
96
94
 
97
95
  # If the method already exists (inline or after-method style), wrap it immediately
98
96
  if method_defined?(method_name) || private_method_defined?(method_name)
99
- _simforge_wrap_method(method_name, trace_function_key:, name:, type:, metadata:)
97
+ _simforge_wrap_method(method_name, trace_function_key:, name:, type:)
100
98
  else
101
99
  # Method doesn't exist yet (before-method style) — register for method_added hook
102
100
  @_simforge_pending_spans ||= {}
103
101
  @_simforge_pending_spans[method_name] = {
104
102
  trace_function_key:,
105
103
  name:,
106
- type:,
107
- metadata:
104
+ type:
108
105
  }
109
106
  end
110
107
  end
@@ -119,10 +116,9 @@ module Simforge
119
116
  _simforge_wrap_method(method_name, **config)
120
117
  end
121
118
 
122
- def _simforge_wrap_method(method_name, trace_function_key:, name: nil, type: "custom", metadata: nil)
119
+ def _simforge_wrap_method(method_name, trace_function_key:, name: nil, type: "custom")
123
120
  span_name = name || method_name.to_s
124
121
  method_name_str = method_name.to_s
125
- span_metadata = metadata
126
122
 
127
123
  wrapper = Module.new do
128
124
  define_method(method_name) do |*args, **kwargs, &block|
@@ -131,7 +127,6 @@ module Simforge
131
127
  span_name:,
132
128
  span_type: type,
133
129
  function_name: method_name_str,
134
- metadata: span_metadata,
135
130
  args:,
136
131
  kwargs:) do
137
132
  super(*args, **kwargs, &block)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Simforge
4
- VERSION = "0.5.2"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/simforge.rb CHANGED
@@ -9,6 +9,41 @@ require_relative "simforge/client"
9
9
  require_relative "simforge/traceable"
10
10
 
11
11
  module Simforge
12
+ # No-op span handle returned when outside a span context.
13
+ # All methods do nothing, preventing crashes when called outside traced code.
14
+ class NoOpCurrentSpan
15
+ def trace_id
16
+ ""
17
+ end
18
+
19
+ def add_context(_context)
20
+ # No-op
21
+ end
22
+
23
+ def set_prompt(_prompt)
24
+ # No-op
25
+ end
26
+ end
27
+
28
+ # No-op trace handle returned when outside a span context.
29
+ # All methods do nothing, preventing crashes when called outside traced code.
30
+ class NoOpCurrentTrace
31
+ def set_session_id(_session_id)
32
+ # No-op
33
+ end
34
+
35
+ def set_metadata(_metadata)
36
+ # No-op
37
+ end
38
+
39
+ def add_context(_context)
40
+ # No-op
41
+ end
42
+ end
43
+
44
+ NO_OP_SPAN = NoOpCurrentSpan.new.freeze
45
+ NO_OP_TRACE = NoOpCurrentTrace.new.freeze
46
+
12
47
  class << self
13
48
  # Configure the global Simforge client.
14
49
  #
@@ -37,12 +72,25 @@ module Simforge
37
72
  # Call this from inside a traced method to get a span handle that allows
38
73
  # setting metadata at runtime.
39
74
  #
40
- # @return [CurrentSpan, nil] the current span, or nil if outside a span context
75
+ # @return [CurrentSpan, NoOpCurrentSpan] the current span, or a no-op if outside a span context
41
76
  def current_span
42
77
  entry = SpanContext.current
43
- return nil unless entry
78
+ return NO_OP_SPAN unless entry
44
79
 
45
80
  CurrentSpan.new(entry)
46
81
  end
82
+
83
+ # Get a handle to the current active trace.
84
+ #
85
+ # Call this from inside a traced method to get a trace handle that allows
86
+ # setting trace-level context at runtime.
87
+ #
88
+ # @return [CurrentTrace, NoOpCurrentTrace] the current trace, or a no-op if outside a span context
89
+ def current_trace
90
+ entry = SpanContext.current
91
+ return NO_OP_TRACE unless entry
92
+
93
+ CurrentTrace.new(entry[:trace_id])
94
+ end
47
95
  end
48
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simforge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-05 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake