simforge 0.5.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 662f1ce8942083e856907a38568a40b252c4d6360f043fc25da58900a46e36e7
4
+ data.tar.gz: 9d74cf518171472d22b49c6b8fb3544e258607e5284acebe0f6b46e71213e0cd
5
+ SHA512:
6
+ metadata.gz: 556e17aa0f010ecb394f7a5937c3c02a43ef0d76bffe00082899074a19f26c555bec5c4d47dfe1afa98c34f7093beae5dd4b4a4d2d9d00a68d0155aadd979aea
7
+ data.tar.gz: 14cd3fbc8c4ea4096036ce312e6eac67ab7b57a634c10faba0962fa3dd10368928ebc3c25c1236b5ff6019a8db36094b0a4e4a8eeb737da2c76fc42e5525cd62
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ require_relative "constants"
7
+ require_relative "http_client"
8
+ require_relative "span_context"
9
+ require_relative "serialize"
10
+
11
+ module Simforge
12
+ class Client
13
+ SPAN_TYPES = %w[llm agent function guardrail handoff custom].freeze
14
+
15
+ attr_reader :api_key, :service_url
16
+
17
+ def initialize(api_key:, service_url: nil)
18
+ @api_key = api_key
19
+ @service_url = service_url || DEFAULT_SERVICE_URL
20
+ @http_client = HttpClient.new(api_key:, service_url: @service_url)
21
+ end
22
+
23
+ # Execute a block inside a span context, sending trace data on completion.
24
+ # Called by Traceable — not intended for direct use.
25
+ def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
26
+ validate_span_type!(span_type)
27
+
28
+ parent = SpanContext.current
29
+ trace_id = parent ? parent[:trace_id] : SecureRandom.uuid
30
+ span_id = SecureRandom.uuid
31
+ parent_span_id = parent&.dig(:span_id)
32
+ started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
33
+
34
+ result = nil
35
+ error = nil
36
+
37
+ begin
38
+ result = SpanContext.with_span(trace_id:, span_id:) { yield }
39
+ rescue => e
40
+ error = e.message
41
+ raise
42
+ ensure
43
+ ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
44
+
45
+ send_span(
46
+ trace_function_key:,
47
+ trace_id:,
48
+ span_id:,
49
+ parent_span_id:,
50
+ span_name:,
51
+ span_type:,
52
+ function_name:,
53
+ args:,
54
+ kwargs:,
55
+ result:,
56
+ error:,
57
+ started_at:,
58
+ ended_at:
59
+ )
60
+ end
61
+
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ def validate_span_type!(type)
68
+ return if SPAN_TYPES.include?(type.to_s)
69
+
70
+ raise ArgumentError, "Invalid span type '#{type}'. Must be one of: #{SPAN_TYPES.join(", ")}"
71
+ end
72
+
73
+ def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
74
+ span_name:, span_type:, function_name:, args:, kwargs:, result:, error:,
75
+ started_at:, ended_at:)
76
+ # Human-readable JSON (input/output fields)
77
+ human_inputs = Serialize.serialize_inputs(args, kwargs)
78
+ human_output = Serialize.serialize_value(result)
79
+
80
+ # Marshal + Base64 for full Ruby-to-Ruby object reconstruction
81
+ raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
82
+ marshalled_input = Serialize.marshal_value(raw_input)
83
+ marshalled_output = Serialize.marshal_value(result)
84
+
85
+ span_data = {
86
+ "name" => span_name,
87
+ "type" => span_type,
88
+ "input" => human_inputs,
89
+ "output" => human_output,
90
+ "function_name" => function_name
91
+ }
92
+ span_data["input_serialized"] = marshalled_input if marshalled_input
93
+ span_data["output_serialized"] = marshalled_output if marshalled_output
94
+ span_data["error"] = error if error
95
+
96
+ raw_span = {
97
+ "id" => span_id,
98
+ "trace_id" => trace_id,
99
+ "started_at" => started_at,
100
+ "ended_at" => ended_at,
101
+ "span_data" => span_data
102
+ }
103
+ raw_span["parent_id"] = parent_span_id if parent_span_id
104
+
105
+ @http_client.send_external_span(
106
+ "type" => "sdk-function",
107
+ "source" => "ruby-sdk-function",
108
+ "sourceTraceId" => trace_id,
109
+ "traceFunctionKey" => trace_function_key,
110
+ "rawSpan" => raw_span
111
+ )
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Simforge
4
+ DEFAULT_SERVICE_URL = "https://simforge.goharvest.ai"
5
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ require_relative "constants"
8
+ require_relative "version"
9
+
10
+ module Simforge
11
+ class HttpClient
12
+ attr_reader :service_url
13
+
14
+ def initialize(api_key:, service_url: nil, timeout: 120)
15
+ @api_key = api_key
16
+ @service_url = (service_url || DEFAULT_SERVICE_URL).chomp("/")
17
+ @timeout = timeout
18
+ end
19
+
20
+ # Make a POST request to the Simforge API.
21
+ # Returns parsed JSON response hash.
22
+ def request(endpoint, payload, timeout: nil, max_retries: 1, retry_delay: 0.1)
23
+ uri = URI("#{@service_url}#{endpoint}")
24
+ request_timeout = timeout || @timeout
25
+
26
+ last_error = nil
27
+
28
+ max_retries.times do |attempt|
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = request_timeout
32
+ http.read_timeout = request_timeout
33
+
34
+ req = Net::HTTP::Post.new(uri.path, headers)
35
+ req.body = JSON.generate(payload)
36
+
37
+ response = http.request(req)
38
+
39
+ unless response.is_a?(Net::HTTPSuccess)
40
+ raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
41
+ end
42
+
43
+ result = JSON.parse(response.body)
44
+
45
+ if result["error"]
46
+ msg = result["error"]
47
+ msg = "#{msg} Configure it at: #{@service_url}#{result["url"]}" if result["url"]
48
+ raise StandardError, msg
49
+ end
50
+
51
+ return result
52
+ rescue => e
53
+ last_error = e
54
+ sleep(retry_delay) if attempt < max_retries - 1
55
+ end
56
+
57
+ raise last_error
58
+ end
59
+
60
+ # Send an external span (fire-and-forget in background thread).
61
+ def send_external_span(payload)
62
+ merged = payload.merge("sdkVersion" => VERSION)
63
+
64
+ Simforge._run_in_background do
65
+ request("/api/sdk/externalSpans", merged, timeout: 30)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def headers
72
+ {
73
+ "Content-Type" => "application/json",
74
+ "Authorization" => "Bearer #{@api_key}"
75
+ }
76
+ end
77
+ end
78
+
79
+ # --- Background thread management ---
80
+
81
+ @pending_threads_mutex = Mutex.new
82
+ @pending_threads = []
83
+
84
+ class << self
85
+ # Run a block in a background thread with tracking.
86
+ def _run_in_background(&block)
87
+ thread = Thread.new do
88
+ block.call
89
+ rescue
90
+ # Silently ignore failures in background spans
91
+ ensure
92
+ @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
93
+ end
94
+
95
+ @pending_threads_mutex.synchronize { @pending_threads << thread }
96
+ end
97
+
98
+ # Wait for all pending background threads to complete.
99
+ def flush_traces(timeout: 30)
100
+ threads = @pending_threads_mutex.synchronize { @pending_threads.dup }
101
+ threads.each { |t| t.join(timeout) }
102
+ end
103
+ end
104
+
105
+ at_exit do
106
+ Simforge.flush_traces(timeout: 2)
107
+ end
108
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Simforge
8
+ module Serialize
9
+ module_function
10
+
11
+ # Serialize a value for JSON storage (human-readable).
12
+ # Handles primitives, hashes, arrays, and objects with common conversion methods.
13
+ # Note: We intentionally avoid as_json here because it requires ActiveSupport,
14
+ # and we want to keep the SDK dependency-free (stdlib only).
15
+ def serialize_value(value)
16
+ case value
17
+ when nil, true, false, Integer, Float, String
18
+ value
19
+ when Hash
20
+ value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
21
+ when Array
22
+ value.map { |v| serialize_value(v) }
23
+ when Set
24
+ value.map { |v| serialize_value(v) }
25
+ when Time, DateTime
26
+ value.iso8601(3)
27
+ when Date
28
+ value.to_s
29
+ when Symbol
30
+ value.to_s
31
+ else
32
+ if value.respond_to?(:to_h)
33
+ serialize_value(value.to_h)
34
+ elsif value.respond_to?(:to_a)
35
+ serialize_value(value.to_a)
36
+ else
37
+ value.to_s
38
+ end
39
+ end
40
+ end
41
+
42
+ # Serialize function inputs (args + kwargs) for span data (human-readable).
43
+ def serialize_inputs(args, kwargs = {})
44
+ serialized = args.map { |arg| serialize_value(arg) }
45
+ serialized << kwargs.transform_keys(&:to_s).transform_values { |v| serialize_value(v) } unless kwargs.empty?
46
+ serialized
47
+ end
48
+
49
+ # Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction.
50
+ # Handles arbitrary Ruby objects including custom classes.
51
+ #
52
+ # @param value [Object] any Ruby value
53
+ # @return [String, nil] Base64-encoded Marshal dump, or nil if marshalling fails
54
+ def marshal_value(value)
55
+ Base64.strict_encode64(Marshal.dump(value))
56
+ rescue TypeError, ArgumentError
57
+ # Some objects (Proc, IO, etc.) can't be marshalled
58
+ nil
59
+ end
60
+
61
+ # Unmarshal a Base64-encoded string back into a Ruby object.
62
+ #
63
+ # @param encoded [String] Base64-encoded Marshal dump
64
+ # @return [Object] the reconstructed Ruby object
65
+ def unmarshal_value(encoded)
66
+ Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Simforge
4
+ # Thread-local span stack for tracking nested spans.
5
+ # Each entry is a Hash with :trace_id and :span_id keys.
6
+ module SpanContext
7
+ STACK_KEY = :simforge_span_stack
8
+
9
+ module_function
10
+
11
+ def stack
12
+ Thread.current[STACK_KEY] ||= []
13
+ end
14
+
15
+ def current
16
+ stack.last
17
+ end
18
+
19
+ # Execute a block with a new span pushed onto the stack.
20
+ # The span is automatically popped when the block completes.
21
+ def with_span(trace_id:, span_id:)
22
+ entry = {trace_id:, span_id:}
23
+ stack.push(entry)
24
+ yield
25
+ ensure
26
+ stack.pop
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Simforge
4
+ # Mixin for declarative span tracing on instance methods.
5
+ #
6
+ # @example
7
+ # Simforge.configure(api_key: "...")
8
+ #
9
+ # class OrderService
10
+ # include Simforge::Traceable
11
+ # simforge_function "order-processing"
12
+ #
13
+ # simforge_span :process_order, type: "function"
14
+ # def process_order(order_id)
15
+ # { total: 100 }
16
+ # end
17
+ #
18
+ # simforge_span :validate_order, name: "Validate", type: "guardrail"
19
+ # def validate_order(order_id)
20
+ # { valid: true }
21
+ # end
22
+ # end
23
+ #
24
+ module Traceable
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ end
28
+
29
+ module ClassMethods
30
+ # Set the trace function key for this class.
31
+ # All spans declared in this class will be grouped under this key.
32
+ #
33
+ # @param key [String] the trace function key
34
+ def simforge_function(key)
35
+ @simforge_function_key = key
36
+ end
37
+
38
+ # Declare that a method should be wrapped with span tracing.
39
+ #
40
+ # Supports three styles:
41
+ # simforge_span :foo, type: "function" # before def foo (uses method_added hook)
42
+ # def foo; end
43
+ #
44
+ # simforge_span def foo # inline (def returns :foo, method already exists)
45
+ # ...
46
+ # end, type: "function"
47
+ #
48
+ # def foo; end # after def foo (method already exists)
49
+ # simforge_span :foo, type: "function"
50
+ #
51
+ # @param method_name [Symbol] the method to wrap
52
+ # @param trace_function_key [String, nil] trace function key (overrides class-level simforge_function)
53
+ # @param name [String, nil] explicit span name (defaults to method name)
54
+ # @param type [String] span type: llm, agent, function, guardrail, handoff, custom
55
+ def simforge_span(method_name, trace_function_key: nil, name: nil, type: "custom")
56
+ trace_function_key ||= @simforge_function_key
57
+ unless trace_function_key
58
+ raise "No trace function key provided. Pass `trace_function_key:` to `simforge_span` " \
59
+ "or call `simforge_function 'my-key'` before using `simforge_span`."
60
+ end
61
+
62
+ # If the method already exists (inline or after-method style), wrap it immediately
63
+ if method_defined?(method_name) || private_method_defined?(method_name)
64
+ _simforge_wrap_method(method_name, trace_function_key:, name:, type:)
65
+ else
66
+ # Method doesn't exist yet (before-method style) — register for method_added hook
67
+ @_simforge_pending_spans ||= {}
68
+ @_simforge_pending_spans[method_name] = {
69
+ trace_function_key:,
70
+ name:,
71
+ type:
72
+ }
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def method_added(method_name)
79
+ super
80
+ return unless defined?(@_simforge_pending_spans) && @_simforge_pending_spans&.key?(method_name)
81
+
82
+ config = @_simforge_pending_spans.delete(method_name)
83
+ _simforge_wrap_method(method_name, **config)
84
+ end
85
+
86
+ def _simforge_wrap_method(method_name, trace_function_key:, name: nil, type: "custom")
87
+ span_name = name || method_name.to_s
88
+ method_name_str = method_name.to_s
89
+
90
+ wrapper = Module.new do
91
+ define_method(method_name) do |*args, **kwargs, &block|
92
+ Simforge.client.send(:execute_span,
93
+ trace_function_key:,
94
+ span_name:,
95
+ span_type: type,
96
+ function_name: method_name_str,
97
+ args:,
98
+ kwargs:) do
99
+ super(*args, **kwargs, &block)
100
+ end
101
+ end
102
+ end
103
+
104
+ prepend(wrapper)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Simforge
4
+ VERSION = "0.5.0"
5
+ end
data/lib/simforge.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "simforge/version"
4
+ require_relative "simforge/constants"
5
+ require_relative "simforge/serialize"
6
+ require_relative "simforge/span_context"
7
+ require_relative "simforge/http_client"
8
+ require_relative "simforge/client"
9
+ require_relative "simforge/traceable"
10
+
11
+ module Simforge
12
+ class << self
13
+ # Configure the global Simforge client.
14
+ #
15
+ # @param api_key [String] API key for authentication
16
+ # @param service_url [String, nil] base URL (default: https://simforge.goharvest.ai)
17
+ #
18
+ # @example
19
+ # Simforge.configure(api_key: ENV["SIMFORGE_API_KEY"])
20
+ #
21
+ def configure(api_key:, service_url: nil)
22
+ @client = Client.new(api_key:, service_url:)
23
+ end
24
+
25
+ # Returns the global client, raising if not configured.
26
+ def client
27
+ @client or raise "Simforge not configured. Call Simforge.configure(api_key: '...') first."
28
+ end
29
+
30
+ # Reset the global client (primarily for testing).
31
+ def reset!
32
+ @client = nil
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: simforge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Harvest Team
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-01-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: standard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webmock
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: Client library for sending function execution spans to the Simforge API.
69
+ Provides automatic tracing with nested span support.
70
+ email:
71
+ - team@goharvest.ai
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/simforge.rb
77
+ - lib/simforge/client.rb
78
+ - lib/simforge/constants.rb
79
+ - lib/simforge/http_client.rb
80
+ - lib/simforge/serialize.rb
81
+ - lib/simforge/span_context.rb
82
+ - lib/simforge/traceable.rb
83
+ - lib/simforge/version.rb
84
+ homepage: https://simforge.goharvest.ai
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://simforge.goharvest.ai
89
+ source_code_uri: https://simforge.goharvest.ai
90
+ rubygems_mfa_required: 'true'
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.1'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.6.6
106
+ specification_version: 4
107
+ summary: Simforge Ruby SDK for function tracing and span management
108
+ test_files: []