dspy 0.24.2 → 0.25.1

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: a4383ac0dbb6237559bb521cf0d1355f9333f6495895742f6b2c4360d7c68392
4
- data.tar.gz: 914f295500dd86cf4d2fe611450ddf3f8f6a01b1c7834cb5b8a9057d7449c8b4
3
+ metadata.gz: 55addf122534bacff753f272a385ddb035e66322ed63ecc5bc27ce3a2bd4ea03
4
+ data.tar.gz: b73d12f9f560dcaf60fdfac9640bf6a422bb0b912912bf805dfb183058e94f81
5
5
  SHA512:
6
- metadata.gz: 3d48a54390b11eee3a38d15770abda175cd7545aa30b24d48123e2ab6657e7f3212bbc4aa3bddab5a6ec700d3137b1e7bbd8b7f4d5e78313f7d3d90b574b47a3
7
- data.tar.gz: 6189b3137e9fe1a36e6bedfc45a1a9ba10308089d497885edbfd8a20ba7879824c4e103b96eaa1efaca93e8e6c90aa4154f99ba8aa278528045d96aec7ff2024
6
+ metadata.gz: 9ffff85304ccbf2b72143e878e134637b97db118ac7b69e91597664d24e1a4fad63685219844d75c91c5e859dc7080a39052ac5baa68df520f1fb6731f35aece
7
+ data.tar.gz: f845270b9fbe9ff81fbed8519517cf174add60eb629d26702d8411077347a4dd1b9206bbf6b1fd7b4b4b7be260b35f48e2dfaf3951ae9d0e87b6d1aad7ea6b3e
data/README.md CHANGED
@@ -74,6 +74,7 @@ puts result.confidence # => 0.85
74
74
  - **Typed Examples** - Type-safe training data with automatic validation
75
75
  - **Evaluation Framework** - Advanced metrics beyond simple accuracy with error-resilient pipelines
76
76
  - **MIPROv2 Optimization** - Automatic prompt optimization with storage and persistence
77
+ - **GEPA Optimization** - Genetic-Pareto optimization for multi-objective prompt improvement
77
78
 
78
79
  **Production Features:**
79
80
  - **Reliable JSON Extraction** - Native OpenAI structured outputs, Anthropic extraction patterns, and automatic strategy selection with fallback
@@ -128,6 +129,7 @@ For LLMs and AI assistants working with DSPy.rb:
128
129
  - **[Evaluation Framework](docs/src/optimization/evaluation.md)** - Advanced metrics beyond simple accuracy
129
130
  - **[Prompt Optimization](docs/src/optimization/prompt-optimization.md)** - Manipulate prompts as objects
130
131
  - **[MIPROv2 Optimizer](docs/src/optimization/miprov2.md)** - Automatic optimization algorithms
132
+ - **[GEPA Optimizer](docs/src/optimization/gepa.md)** - Genetic-Pareto optimization for multi-objective prompt optimization
131
133
 
132
134
  ### Production Features
133
135
  - **[Storage System](docs/src/production/storage.md)** - Persistence and optimization result storage
@@ -191,7 +193,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
191
193
  - ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
192
194
 
193
195
  ### Recent Advances
194
- - ✅ **Enhanced Langfuse Integration (v0.24.1)** - Comprehensive OpenTelemetry span reporting with proper input/output, hierarchical nesting, accurate timing, and observation types
196
+ - ✅ **Enhanced Langfuse Integration (v0.25.0)** - Comprehensive OpenTelemetry span reporting with proper input/output, hierarchical nesting, accurate timing, and observation types
195
197
  - ✅ **Comprehensive Multimodal Framework** - Complete image analysis with `DSPy::Image`, type-safe bounding boxes, vision model integration
196
198
  - ✅ **Advanced Type System** - `T::Enum` integration, union types for agentic workflows, complex type coercion
197
199
  - ✅ **Production-Ready Evaluation** - Multi-factor metrics beyond accuracy, error-resilient evaluation pipelines
@@ -3,6 +3,7 @@
3
3
 
4
4
  require 'sorbet-runtime'
5
5
  require_relative 'predict'
6
+ require_relative 'utils/serialization'
6
7
  require_relative 'signature'
7
8
  require_relative 'mixins/struct_builder'
8
9
 
@@ -88,9 +89,10 @@ module DSPy
88
89
  # Wrap in chain-specific span tracking (overrides parent's span attributes)
89
90
  DSPy::Context.with_span(
90
91
  operation: "#{self.class.name}.forward",
91
- 'langfuse.observation.type' => 'chain',
92
+ 'langfuse.observation.type' => 'span', # Use 'span' for proper timing
92
93
  'langfuse.observation.input' => input_values.to_json,
93
94
  'dspy.module' => 'ChainOfThought',
95
+ 'dspy.module_type' => 'chain_of_thought', # Semantic identifier
94
96
  'dspy.signature' => @original_signature.name
95
97
  ) do |span|
96
98
  # Call parent prediction logic (which will create its own nested span)
@@ -106,7 +108,7 @@ module DSPy
106
108
  prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
107
109
  end
108
110
 
109
- span.set_attribute('langfuse.observation.output', output_with_reasoning.to_json)
111
+ span.set_attribute('langfuse.observation.output', DSPy::Utils::Serialization.to_json(output_with_reasoning))
110
112
 
111
113
  # Add reasoning metrics
112
114
  if prediction_result.respond_to?(:reasoning) && prediction_result.reasoning
data/lib/dspy/context.rb CHANGED
@@ -6,10 +6,29 @@ module DSPy
6
6
  class Context
7
7
  class << self
8
8
  def current
9
- Thread.current[:dspy_context] ||= {
10
- trace_id: SecureRandom.uuid,
11
- span_stack: []
12
- }
9
+ # Check if we're in an async context (fiber created by async gem)
10
+ if in_async_context?
11
+ # Use Fiber storage for async contexts to enable inheritance
12
+ # Inherit from Thread.current if Fiber storage is not set
13
+ Fiber[:dspy_context] ||= Thread.current[:dspy_context] || {
14
+ trace_id: SecureRandom.uuid,
15
+ span_stack: []
16
+ }
17
+
18
+ # Return Fiber storage in async contexts
19
+ Fiber[:dspy_context]
20
+ else
21
+ # Use Thread.current for regular synchronous contexts
22
+ Thread.current[:dspy_context] ||= {
23
+ trace_id: SecureRandom.uuid,
24
+ span_stack: []
25
+ }
26
+
27
+ # Also sync to Fiber storage so async contexts can inherit it
28
+ Fiber[:dspy_context] = Thread.current[:dspy_context]
29
+
30
+ Thread.current[:dspy_context]
31
+ end
13
32
  end
14
33
 
15
34
  def with_span(operation:, **attributes)
@@ -27,7 +46,7 @@ module DSPy
27
46
  }
28
47
 
29
48
  # Log span start with proper hierarchy (internal logging only)
30
- DSPy.log('span.start', **span_attributes)
49
+ DSPy.log('span.start', **span_attributes) if DSPy::Observability.enabled?
31
50
 
32
51
  # Push to stack for child spans tracking
33
52
  current[:span_stack].push(span_id)
@@ -71,17 +90,29 @@ module DSPy
71
90
  current[:span_stack].pop
72
91
 
73
92
  # Log span end with duration (internal logging only)
74
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
75
- DSPy.log('span.end',
76
- trace_id: current[:trace_id],
77
- span_id: span_id,
78
- duration_ms: duration_ms
79
- )
93
+ if DSPy::Observability.enabled?
94
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
95
+ DSPy.log('span.end',
96
+ trace_id: current[:trace_id],
97
+ span_id: span_id,
98
+ duration_ms: duration_ms
99
+ )
100
+ end
80
101
  end
81
102
  end
82
103
 
83
104
  def clear!
84
105
  Thread.current[:dspy_context] = nil
106
+ Fiber[:dspy_context] = nil
107
+ end
108
+
109
+ private
110
+
111
+ # Check if we're running in an async context
112
+ def in_async_context?
113
+ defined?(Async::Task) && Async::Task.current?
114
+ rescue
115
+ false
85
116
  end
86
117
  end
87
118
  end
@@ -38,12 +38,12 @@ module DSPy
38
38
  # Get the output JSON schema from the signature class
39
39
  output_schema = signature_class.output_json_schema
40
40
 
41
- # Build the complete schema
41
+ # Build the complete schema with OpenAI-specific modifications
42
42
  dspy_schema = {
43
43
  "$schema": "http://json-schema.org/draft-06/schema#",
44
44
  type: "object",
45
45
  properties: output_schema[:properties] || {},
46
- required: output_schema[:required] || []
46
+ required: openai_required_fields(signature_class, output_schema)
47
47
  }
48
48
 
49
49
  # Generate a schema name if not provided
@@ -52,9 +52,10 @@ module DSPy
52
52
  # Remove the $schema field as OpenAI doesn't use it
53
53
  openai_schema = dspy_schema.except(:$schema)
54
54
 
55
- # Add additionalProperties: false for strict mode
55
+ # Add additionalProperties: false for strict mode and fix nested struct schemas
56
56
  if strict
57
57
  openai_schema = add_additional_properties_recursively(openai_schema)
58
+ openai_schema = fix_nested_struct_required_fields(openai_schema)
58
59
  end
59
60
 
60
61
  # Wrap in OpenAI's required format
@@ -120,6 +121,65 @@ module DSPy
120
121
 
121
122
  private
122
123
 
124
+ # OpenAI structured outputs requires ALL properties to be in the required array
125
+ # For T.nilable fields without defaults, we warn the user and mark as required
126
+ sig { params(signature_class: T.class_of(DSPy::Signature), output_schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
127
+ def self.openai_required_fields(signature_class, output_schema)
128
+ all_properties = output_schema[:properties]&.keys || []
129
+ original_required = output_schema[:required] || []
130
+
131
+ # For OpenAI structured outputs, we need ALL properties to be required
132
+ # but warn about T.nilable fields without defaults
133
+ field_descriptors = signature_class.instance_variable_get(:@output_field_descriptors) || {}
134
+
135
+ all_properties.each do |property_name|
136
+ descriptor = field_descriptors[property_name.to_sym]
137
+
138
+ # If field is not originally required and doesn't have a default
139
+ if !original_required.include?(property_name.to_s) && descriptor && !descriptor.has_default
140
+ DSPy.logger.warn(
141
+ "OpenAI structured outputs: T.nilable field '#{property_name}' without default will be marked as required. " \
142
+ "Consider adding a default value or using a different provider for optional fields."
143
+ )
144
+ end
145
+ end
146
+
147
+ # Return all properties as required (OpenAI requirement)
148
+ all_properties.map(&:to_s)
149
+ end
150
+
151
+ # Fix nested struct schemas to include all properties in required array (OpenAI requirement)
152
+ sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
153
+ def self.fix_nested_struct_required_fields(schema)
154
+ return schema unless schema.is_a?(Hash)
155
+
156
+ result = schema.dup
157
+
158
+ # If this is an object with properties, make all properties required
159
+ if result[:type] == "object" && result[:properties].is_a?(Hash)
160
+ all_property_names = result[:properties].keys.map(&:to_s)
161
+ result[:required] = all_property_names unless result[:required] == all_property_names
162
+ end
163
+
164
+ # Process nested objects recursively
165
+ if result[:properties].is_a?(Hash)
166
+ result[:properties] = result[:properties].transform_values do |prop|
167
+ if prop.is_a?(Hash)
168
+ processed = fix_nested_struct_required_fields(prop)
169
+ # Handle arrays with object items
170
+ if processed[:type] == "array" && processed[:items].is_a?(Hash)
171
+ processed[:items] = fix_nested_struct_required_fields(processed[:items])
172
+ end
173
+ processed
174
+ else
175
+ prop
176
+ end
177
+ end
178
+ end
179
+
180
+ result
181
+ end
182
+
123
183
  sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
124
184
  def self.add_additional_properties_recursively(schema)
125
185
  return schema unless schema.is_a?(Hash)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sorbet-runtime"
4
+ require "async"
4
5
 
5
6
  module DSPy
6
7
  class LM
@@ -28,6 +29,11 @@ module DSPy
28
29
  .returns(T.type_parameter(:T))
29
30
  end
30
31
  def with_retry(initial_strategy, &block)
32
+ # Skip retries entirely if disabled
33
+ unless DSPy.config.structured_outputs.retry_enabled
34
+ return yield(initial_strategy)
35
+ end
36
+
31
37
  strategies = build_fallback_chain(initial_strategy)
32
38
  last_error = nil
33
39
 
@@ -62,7 +68,7 @@ module DSPy
62
68
  "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
63
69
  )
64
70
 
65
- sleep(backoff_time) if backoff_time > 0
71
+ Async::Task.current.sleep(backoff_time) if backoff_time > 0
66
72
  retry
67
73
  else
68
74
  DSPy.logger.info("Max retries reached for #{strategy.name}, trying next strategy")
@@ -107,8 +113,6 @@ module DSPy
107
113
  # Calculate exponential backoff with jitter
108
114
  sig { params(attempt: Integer).returns(Float) }
109
115
  def calculate_backoff(attempt)
110
- return 0.0 if DSPy.config.test_mode # No sleep in tests
111
-
112
116
  base_delay = BACKOFF_BASE * (2 ** (attempt - 1))
113
117
  jitter = rand * 0.1 * base_delay
114
118
 
data/lib/dspy/lm.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'sorbet-runtime'
4
+ require 'async'
4
5
 
5
6
  # Load adapter infrastructure
6
7
  require_relative 'lm/errors'
@@ -41,20 +42,22 @@ module DSPy
41
42
  end
42
43
 
43
44
  def chat(inference_module, input_values, &block)
44
- signature_class = inference_module.signature_class
45
-
46
- # Build messages from inference module
47
- messages = build_messages(inference_module, input_values)
48
-
49
- # Execute with instrumentation
50
- response = instrument_lm_request(messages, signature_class.name) do
51
- chat_with_strategy(messages, signature_class, &block)
45
+ Sync do
46
+ signature_class = inference_module.signature_class
47
+
48
+ # Build messages from inference module
49
+ messages = build_messages(inference_module, input_values)
50
+
51
+ # Execute with instrumentation
52
+ response = instrument_lm_request(messages, signature_class.name) do
53
+ chat_with_strategy(messages, signature_class, &block)
54
+ end
55
+
56
+ # Parse response (no longer needs separate instrumentation)
57
+ parsed_result = parse_response(response, input_values, signature_class)
58
+
59
+ parsed_result
52
60
  end
53
-
54
- # Parse response (no longer needs separate instrumentation)
55
- parsed_result = parse_response(response, input_values, signature_class)
56
-
57
- parsed_result
58
61
  end
59
62
 
60
63
  def raw_chat(messages = nil, &block)
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/queue'
5
+ require 'async/barrier'
6
+ require 'opentelemetry/sdk'
7
+ require 'opentelemetry/sdk/trace/export'
8
+
9
+ module DSPy
10
+ class Observability
11
+ # AsyncSpanProcessor provides truly non-blocking span export using Async gem.
12
+ # Spans are queued and exported using async tasks with fiber-based concurrency.
13
+ # Implements the same interface as OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor
14
+ class AsyncSpanProcessor
15
+ # Default configuration values
16
+ DEFAULT_QUEUE_SIZE = 1000
17
+ DEFAULT_EXPORT_INTERVAL = 60.0 # seconds
18
+ DEFAULT_EXPORT_BATCH_SIZE = 100
19
+ DEFAULT_SHUTDOWN_TIMEOUT = 10.0 # seconds
20
+ DEFAULT_MAX_RETRIES = 3
21
+
22
+ def initialize(
23
+ exporter,
24
+ queue_size: DEFAULT_QUEUE_SIZE,
25
+ export_interval: DEFAULT_EXPORT_INTERVAL,
26
+ export_batch_size: DEFAULT_EXPORT_BATCH_SIZE,
27
+ shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
28
+ max_retries: DEFAULT_MAX_RETRIES
29
+ )
30
+ @exporter = exporter
31
+ @queue_size = queue_size
32
+ @export_interval = export_interval
33
+ @export_batch_size = export_batch_size
34
+ @shutdown_timeout = shutdown_timeout
35
+ @max_retries = max_retries
36
+
37
+ # Use thread-safe queue for cross-fiber communication
38
+ @queue = Thread::Queue.new
39
+ @barrier = Async::Barrier.new
40
+ @shutdown_requested = false
41
+ @export_task = nil
42
+
43
+ start_export_task
44
+ end
45
+
46
+ def on_start(span, parent_context)
47
+ # Non-blocking - no operation needed on span start
48
+ end
49
+
50
+ def on_finish(span)
51
+ # Only process sampled spans to match BatchSpanProcessor behavior
52
+ return unless span.context.trace_flags.sampled?
53
+
54
+ # Non-blocking enqueue with overflow protection
55
+ # Note: on_finish is only called for already ended spans
56
+ begin
57
+ # Check queue size (non-blocking)
58
+ if @queue.size >= @queue_size
59
+ # Drop oldest span
60
+ begin
61
+ dropped_span = @queue.pop(true) # non-blocking pop
62
+ DSPy.log('observability.span_dropped',
63
+ reason: 'queue_full',
64
+ queue_size: @queue_size)
65
+ rescue ThreadError
66
+ # Queue was empty, continue
67
+ end
68
+ end
69
+
70
+ @queue.push(span)
71
+
72
+ # Log span queuing activity
73
+ DSPy.log('observability.span_queued', queue_size: @queue.size)
74
+
75
+ # Trigger immediate export if batch size reached
76
+ trigger_export_if_batch_full
77
+ rescue => e
78
+ DSPy.log('observability.enqueue_error', error: e.message)
79
+ end
80
+ end
81
+
82
+ def shutdown(timeout: nil)
83
+ timeout ||= @shutdown_timeout
84
+ @shutdown_requested = true
85
+
86
+ begin
87
+ # Export any remaining spans
88
+ export_remaining_spans
89
+
90
+ # Shutdown exporter
91
+ @exporter.shutdown(timeout: timeout)
92
+
93
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
94
+ rescue => e
95
+ DSPy.log('observability.shutdown_error', error: e.message, class: e.class.name)
96
+ OpenTelemetry::SDK::Trace::Export::FAILURE
97
+ end
98
+ end
99
+
100
+ def force_flush(timeout: nil)
101
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if @queue.empty?
102
+
103
+ export_remaining_spans
104
+ end
105
+
106
+ private
107
+
108
+ def start_export_task
109
+ return if @export_interval <= 0 # Disable timer for testing
110
+
111
+ # Start timer-based export task in background
112
+ Thread.new do
113
+ loop do
114
+ break if @shutdown_requested
115
+
116
+ sleep(@export_interval)
117
+
118
+ # Export queued spans in sync block
119
+ unless @queue.empty?
120
+ Sync do
121
+ export_queued_spans
122
+ end
123
+ end
124
+ end
125
+ rescue => e
126
+ DSPy.log('observability.export_task_error', error: e.message, class: e.class.name)
127
+ end
128
+ end
129
+
130
+ def trigger_export_if_batch_full
131
+ return if @queue.size < @export_batch_size
132
+
133
+ # Trigger immediate export in background
134
+ Thread.new do
135
+ Sync do
136
+ export_queued_spans
137
+ end
138
+ rescue => e
139
+ DSPy.log('observability.batch_export_error', error: e.message)
140
+ end
141
+ end
142
+
143
+ def export_remaining_spans
144
+ spans = []
145
+
146
+ # Drain entire queue
147
+ until @queue.empty?
148
+ begin
149
+ spans << @queue.pop(true) # non-blocking pop
150
+ rescue ThreadError
151
+ break
152
+ end
153
+ end
154
+
155
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if spans.empty?
156
+
157
+ export_spans_with_retry(spans)
158
+ end
159
+
160
+ def export_queued_spans
161
+ spans = []
162
+
163
+ # Collect up to batch size
164
+ @export_batch_size.times do
165
+ begin
166
+ spans << @queue.pop(true) # non-blocking pop
167
+ rescue ThreadError
168
+ break
169
+ end
170
+ end
171
+
172
+ return if spans.empty?
173
+
174
+ # Export using async I/O
175
+ Sync do
176
+ export_spans_with_retry_async(spans)
177
+ end
178
+ end
179
+
180
+ def export_spans_with_retry(spans)
181
+ retries = 0
182
+
183
+ # Convert spans to SpanData objects (required by OTLP exporter)
184
+ span_data_batch = spans.map(&:to_span_data)
185
+
186
+ # Log export attempt
187
+ DSPy.log('observability.export_attempt',
188
+ spans_count: span_data_batch.size,
189
+ batch_size: span_data_batch.size)
190
+
191
+ loop do
192
+ result = @exporter.export(span_data_batch, timeout: @shutdown_timeout)
193
+
194
+ case result
195
+ when OpenTelemetry::SDK::Trace::Export::SUCCESS
196
+ DSPy.log('observability.export_success',
197
+ spans_count: span_data_batch.size,
198
+ export_result: 'SUCCESS')
199
+ return result
200
+ when OpenTelemetry::SDK::Trace::Export::FAILURE
201
+ retries += 1
202
+ if retries <= @max_retries
203
+ backoff_seconds = 0.1 * (2 ** retries)
204
+ DSPy.log('observability.export_retry',
205
+ attempt: retries,
206
+ spans_count: span_data_batch.size,
207
+ backoff_seconds: backoff_seconds)
208
+ # Exponential backoff
209
+ sleep(backoff_seconds)
210
+ next
211
+ else
212
+ DSPy.log('observability.export_failed',
213
+ spans_count: span_data_batch.size,
214
+ retries: retries)
215
+ return result
216
+ end
217
+ else
218
+ return result
219
+ end
220
+ end
221
+ rescue => e
222
+ DSPy.log('observability.export_error', error: e.message, class: e.class.name)
223
+ OpenTelemetry::SDK::Trace::Export::FAILURE
224
+ end
225
+
226
+ def export_spans_with_retry_async(spans)
227
+ retries = 0
228
+
229
+ # Convert spans to SpanData objects (required by OTLP exporter)
230
+ span_data_batch = spans.map(&:to_span_data)
231
+
232
+ # Log export attempt
233
+ DSPy.log('observability.export_attempt',
234
+ spans_count: span_data_batch.size,
235
+ batch_size: span_data_batch.size)
236
+
237
+ loop do
238
+ # Use current async task for potentially non-blocking export
239
+ result = @exporter.export(span_data_batch, timeout: @shutdown_timeout)
240
+
241
+ case result
242
+ when OpenTelemetry::SDK::Trace::Export::SUCCESS
243
+ DSPy.log('observability.export_success',
244
+ spans_count: span_data_batch.size,
245
+ export_result: 'SUCCESS')
246
+ return result
247
+ when OpenTelemetry::SDK::Trace::Export::FAILURE
248
+ retries += 1
249
+ if retries <= @max_retries
250
+ backoff_seconds = 0.1 * (2 ** retries)
251
+ DSPy.log('observability.export_retry',
252
+ attempt: retries,
253
+ spans_count: span_data_batch.size,
254
+ backoff_seconds: backoff_seconds)
255
+ # Async sleep for exponential backoff
256
+ Async::Task.current.sleep(backoff_seconds)
257
+ next
258
+ else
259
+ DSPy.log('observability.export_failed',
260
+ spans_count: span_data_batch.size,
261
+ retries: retries)
262
+ return result
263
+ end
264
+ else
265
+ return result
266
+ end
267
+ end
268
+ rescue => e
269
+ DSPy.log('observability.export_error', error: e.message, class: e.class.name)
270
+ OpenTelemetry::SDK::Trace::Export::FAILURE
271
+ end
272
+ end
273
+ end
274
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'base64'
4
+ require_relative 'observability/async_span_processor'
4
5
 
5
6
  module DSPy
6
7
  class Observability
@@ -35,18 +36,26 @@ module DSPy
35
36
  config.service_name = 'dspy-ruby'
36
37
  config.service_version = DSPy::VERSION
37
38
 
38
- # Add OTLP exporter for Langfuse
39
+ # Add OTLP exporter for Langfuse using AsyncSpanProcessor
40
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
41
+ endpoint: @endpoint,
42
+ headers: {
43
+ 'Authorization' => "Basic #{auth_string}",
44
+ 'Content-Type' => 'application/x-protobuf'
45
+ },
46
+ compression: 'gzip'
47
+ )
48
+
49
+ # Configure AsyncSpanProcessor with environment variables
50
+ async_config = {
51
+ queue_size: (ENV['DSPY_TELEMETRY_QUEUE_SIZE'] || AsyncSpanProcessor::DEFAULT_QUEUE_SIZE).to_i,
52
+ export_interval: (ENV['DSPY_TELEMETRY_EXPORT_INTERVAL'] || AsyncSpanProcessor::DEFAULT_EXPORT_INTERVAL).to_f,
53
+ export_batch_size: (ENV['DSPY_TELEMETRY_BATCH_SIZE'] || AsyncSpanProcessor::DEFAULT_EXPORT_BATCH_SIZE).to_i,
54
+ shutdown_timeout: (ENV['DSPY_TELEMETRY_SHUTDOWN_TIMEOUT'] || AsyncSpanProcessor::DEFAULT_SHUTDOWN_TIMEOUT).to_f
55
+ }
56
+
39
57
  config.add_span_processor(
40
- OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
41
- OpenTelemetry::Exporter::OTLP::Exporter.new(
42
- endpoint: @endpoint,
43
- headers: {
44
- 'Authorization' => "Basic #{auth_string}",
45
- 'Content-Type' => 'application/x-protobuf'
46
- },
47
- compression: 'gzip'
48
- )
49
- )
58
+ AsyncSpanProcessor.new(exporter, **async_config)
50
59
  )
51
60
 
52
61
  # Add resource attributes
data/lib/dspy/predict.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'sorbet-runtime'
4
4
  require_relative 'module'
5
5
  require_relative 'prompt'
6
+ require_relative 'utils/serialization'
6
7
  require_relative 'mixins/struct_builder'
7
8
  require_relative 'mixins/type_coercion'
8
9
  require_relative 'error_formatter'
@@ -165,7 +166,7 @@ module DSPy
165
166
  # Add output to span
166
167
  if span && prediction_result
167
168
  output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
168
- span.set_attribute('langfuse.observation.output', output_hash.to_json)
169
+ span.set_attribute('langfuse.observation.output', DSPy::Utils::Serialization.to_json(output_hash))
169
170
  end
170
171
 
171
172
  prediction_result