simforge 0.5.2 → 0.6.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: 0eaad388f5419c3933530ac93808928f219682b1e32d35fff040f691cbd4f410
4
+ data.tar.gz: a7922a5c2a3f063b5f6c13f43e5f6defed320c82d9b8d887a3cc12293ab03808
5
5
  SHA512:
6
- metadata.gz: 7057e4d20182d99a6c71b0a5fe4f52f7d661230a111967a805421f5459463371ecc8849a7bbfcb7f313e16a3bf9623b50002393eeed91ef7e311cbfe43fd6e3a
7
- data.tar.gz: a76648e123b02fc6a00124910c42624c9da7819df4b8437d8fb1b8aed64498d72871749da56e20a8ba94bc86d2ba0dd013da70ca843900138e33b65ac5e00e12
6
+ metadata.gz: 32b1df24c8a1dd4aa8acb6b5ca6828736c1a3b5586982a169865acaaa753075b4e06009f6c2d0d6843e9ec29539cba0a28c4e0df83eaaeef65f31b98e1449637
7
+ data.tar.gz: 2abbabfde142faa7c8ca0adc6e7433787f16b6b60aaafd28d26bd23c0fc890be852bdae342312540fc97e081bea05d163af66ae1b043f393c05c1f63ddd94d44
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
  )
@@ -27,25 +27,31 @@ module Simforge
27
27
 
28
28
  # Execute a block inside a span context, sending trace data on completion.
29
29
  # 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)
30
+ def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
31
31
  return yield unless @enabled
32
32
 
33
33
  parent = SpanContext.current
34
34
  trace_id = parent ? parent[:trace_id] : SecureRandom.uuid
35
35
  span_id = SecureRandom.uuid
36
36
  parent_span_id = parent&.dig(:span_id)
37
+ is_root_span = parent_span_id.nil?
37
38
  started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
38
39
 
40
+ # Register trace state for root spans
41
+ if is_root_span && !TraceState.get(trace_id)
42
+ TraceState.create(trace_id)
43
+ end
44
+
39
45
  result = nil
40
46
  error = nil
41
- runtime_metadata = nil
47
+ span_contexts = nil
42
48
 
43
49
  begin
44
50
  SpanContext.with_span(trace_id:, span_id:) do
45
51
  result = yield
46
52
  ensure
47
- # Capture runtime metadata before the span context is popped
48
- runtime_metadata = SpanContext.current&.dig(:metadata)
53
+ # Capture contexts before the span context is popped
54
+ span_contexts = SpanContext.current&.dig(:contexts)
49
55
  end
50
56
  rescue => e
51
57
  error = e.message
@@ -55,13 +61,6 @@ module Simforge
55
61
  begin
56
62
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
57
63
 
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
64
  send_span(
66
65
  trace_function_key:,
67
66
  trace_id:,
@@ -70,7 +69,7 @@ module Simforge
70
69
  span_name:,
71
70
  span_type:,
72
71
  function_name:,
73
- metadata: merged_metadata,
72
+ contexts: span_contexts,
74
73
  args:,
75
74
  kwargs:,
76
75
  result:,
@@ -78,6 +77,16 @@ module Simforge
78
77
  started_at:,
79
78
  ended_at:
80
79
  )
80
+
81
+ # For root spans, also send trace completion
82
+ if is_root_span
83
+ send_trace_completion(
84
+ trace_function_key:,
85
+ trace_id:,
86
+ started_at:,
87
+ ended_at:
88
+ )
89
+ end
81
90
  rescue Exception # rubocop:disable Lint/RescueException
82
91
  # Silently ignore — user's result/exception takes priority
83
92
  # Catches Exception (not just StandardError) to handle SystemStackError
@@ -96,8 +105,43 @@ module Simforge
96
105
  raise ArgumentError, "Invalid span type '#{type}'. Must be one of: #{SPAN_TYPES.join(", ")}"
97
106
  end
98
107
 
108
+ def send_trace_completion(trace_function_key:, trace_id:, started_at:, ended_at:)
109
+ trace_state = TraceState.get(trace_id)
110
+ trace_started_at = trace_state&.dig(:started_at) || started_at
111
+
112
+ raw_trace = {
113
+ "id" => trace_id,
114
+ "started_at" => trace_started_at,
115
+ "ended_at" => ended_at
116
+ }
117
+
118
+ if trace_state&.dig(:metadata)
119
+ raw_trace["metadata"] = trace_state[:metadata]
120
+ end
121
+ if trace_state&.dig(:contexts)
122
+ raw_trace["contexts"] = trace_state[:contexts]
123
+ end
124
+
125
+ payload = {
126
+ "type" => "sdk-function",
127
+ "source" => "ruby-sdk-function",
128
+ "traceFunctionKey" => trace_function_key,
129
+ "externalTrace" => raw_trace,
130
+ "completed" => true
131
+ }
132
+
133
+ if trace_state&.dig(:session_id)
134
+ payload["sessionId"] = trace_state[:session_id]
135
+ end
136
+
137
+ @http_client.send_external_trace(payload)
138
+
139
+ # Clean up trace state
140
+ TraceState.delete(trace_id)
141
+ end
142
+
99
143
  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:,
144
+ span_name:, span_type:, function_name:, contexts:, args:, kwargs:, result:, error:,
101
145
  started_at:, ended_at:)
102
146
  # Human-readable JSON (input/output fields)
103
147
  human_inputs = Serialize.serialize_inputs(args, kwargs)
@@ -118,7 +162,7 @@ module Simforge
118
162
  span_data["input_serialized"] = marshalled_input if marshalled_input
119
163
  span_data["output_serialized"] = marshalled_output if marshalled_output
120
164
  span_data["error"] = error if error
121
- span_data["metadata"] = metadata if metadata
165
+ span_data["contexts"] = contexts if contexts&.any?
122
166
 
123
167
  raw_span = {
124
168
  "id" => span_id,
@@ -66,6 +66,15 @@ module Simforge
66
66
  end
67
67
  end
68
68
 
69
+ # Send an external trace (fire-and-forget in background thread).
70
+ def send_external_trace(payload)
71
+ merged = payload.merge("sdkVersion" => VERSION)
72
+
73
+ Simforge._run_in_background do
74
+ request("/api/sdk/externalTraces", merged, timeout: 10)
75
+ end
76
+ end
77
+
69
78
  private
70
79
 
71
80
  def headers
@@ -1,19 +1,83 @@
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
+ end
29
+
30
+ # Handle to the current active trace, allowing trace-level context to be set.
31
+ class CurrentTrace
32
+ def initialize(trace_id)
33
+ @trace_id = trace_id
34
+ end
35
+
36
+ # Set the session ID for this trace.
37
+ # Session ID is used to group traces from the same user session.
38
+ # This is stored as a database column.
39
+ #
40
+ # @param session_id [String] the session ID to set
41
+ def set_session_id(session_id)
42
+ trace_state = get_or_create_trace_state
43
+ trace_state[:session_id] = session_id
44
+ rescue
45
+ # Silently ignore - never crash the host app
46
+ end
47
+
48
+ # Set metadata for this trace.
49
+ # Metadata is stored in the raw trace data. Subsequent calls merge with
50
+ # existing metadata, with later values taking precedence.
51
+ #
52
+ # @param metadata [Hash] key-value pairs to store as trace metadata
13
53
  def set_metadata(metadata)
14
54
  return unless metadata.is_a?(Hash)
15
55
 
16
- @context[:metadata] = (@context[:metadata] || {}).merge(metadata)
56
+ trace_state = get_or_create_trace_state
57
+ trace_state[:metadata] = (trace_state[:metadata] || {}).merge(metadata)
58
+ rescue
59
+ # Silently ignore - never crash the host app
60
+ end
61
+
62
+ # Add a context entry to this trace.
63
+ # The entire hash is pushed as a single entry in the contexts array.
64
+ # Context entries are accumulated - multiple calls add to the list.
65
+ #
66
+ # @param context [Hash] key-value pairs to add as a single context entry
67
+ def add_context(context)
68
+ return unless context.is_a?(Hash)
69
+
70
+ trace_state = get_or_create_trace_state
71
+ trace_state[:contexts] ||= []
72
+ trace_state[:contexts] << context
73
+ rescue
74
+ # Silently ignore - never crash the host app
75
+ end
76
+
77
+ private
78
+
79
+ def get_or_create_trace_state
80
+ TraceState.get(@trace_id) || TraceState.create(@trace_id)
17
81
  end
18
82
  end
19
83
 
@@ -42,4 +106,33 @@ module Simforge
42
106
  stack.pop
43
107
  end
44
108
  end
109
+
110
+ # Global storage for trace states (trace_id -> state hash)
111
+ module TraceState
112
+ @states_mutex = Mutex.new
113
+ @states = {}
114
+
115
+ module_function
116
+
117
+ def get(trace_id)
118
+ @states_mutex.synchronize { @states[trace_id] }
119
+ end
120
+
121
+ def create(trace_id)
122
+ @states_mutex.synchronize do
123
+ @states[trace_id] ||= {
124
+ trace_id:,
125
+ started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
126
+ }
127
+ end
128
+ end
129
+
130
+ def delete(trace_id)
131
+ @states_mutex.synchronize { @states.delete(trace_id) }
132
+ end
133
+
134
+ def clear_all
135
+ @states_mutex.synchronize { @states.clear }
136
+ end
137
+ end
45
138
  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.6.0"
5
5
  end
data/lib/simforge.rb CHANGED
@@ -9,6 +9,37 @@ 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
+ end
23
+
24
+ # No-op trace handle returned when outside a span context.
25
+ # All methods do nothing, preventing crashes when called outside traced code.
26
+ class NoOpCurrentTrace
27
+ def set_session_id(_session_id)
28
+ # No-op
29
+ end
30
+
31
+ def set_metadata(_metadata)
32
+ # No-op
33
+ end
34
+
35
+ def add_context(_context)
36
+ # No-op
37
+ end
38
+ end
39
+
40
+ NO_OP_SPAN = NoOpCurrentSpan.new.freeze
41
+ NO_OP_TRACE = NoOpCurrentTrace.new.freeze
42
+
12
43
  class << self
13
44
  # Configure the global Simforge client.
14
45
  #
@@ -37,12 +68,25 @@ module Simforge
37
68
  # Call this from inside a traced method to get a span handle that allows
38
69
  # setting metadata at runtime.
39
70
  #
40
- # @return [CurrentSpan, nil] the current span, or nil if outside a span context
71
+ # @return [CurrentSpan, NoOpCurrentSpan] the current span, or a no-op if outside a span context
41
72
  def current_span
42
73
  entry = SpanContext.current
43
- return nil unless entry
74
+ return NO_OP_SPAN unless entry
44
75
 
45
76
  CurrentSpan.new(entry)
46
77
  end
78
+
79
+ # Get a handle to the current active trace.
80
+ #
81
+ # Call this from inside a traced method to get a trace handle that allows
82
+ # setting trace-level context at runtime.
83
+ #
84
+ # @return [CurrentTrace, NoOpCurrentTrace] the current trace, or a no-op if outside a span context
85
+ def current_trace
86
+ entry = SpanContext.current
87
+ return NO_OP_TRACE unless entry
88
+
89
+ CurrentTrace.new(entry[:trace_id])
90
+ end
47
91
  end
48
92
  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.6.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-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake