dspy 0.24.1 → 0.25.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 +3 -1
- data/lib/dspy/chain_of_thought.rb +5 -3
- data/lib/dspy/context.rb +56 -12
- data/lib/dspy/lm/adapters/openai/schema_converter.rb +63 -3
- data/lib/dspy/lm/retry_handler.rb +7 -3
- data/lib/dspy/lm.rb +16 -13
- data/lib/dspy/observability/async_span_processor.rb +274 -0
- data/lib/dspy/observability.rb +29 -11
- data/lib/dspy/predict.rb +2 -1
- data/lib/dspy/teleprompt/gepa.rb +329 -772
- data/lib/dspy/utils/serialization.rb +35 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +30 -25
- metadata +6 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d45d1ee46193e0bad5339bb7a05732797fa4c2dcc7fafcbaef0bee1aa7ecd591
|
4
|
+
data.tar.gz: 5ca7952a3119f1177ff034c55819e266a443d6fa48c975af15195f937560c10e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0e29265f2bf13028265591281536baa229cab475dabaf0318045d40d4568eb4329b23b07c6f8b70c83588fb53e5059cb854ab8e68b5716e78f5368adad7a02bb
|
7
|
+
data.tar.gz: 714a2864f33cf2f22e27e9962368c277f8f684b22e751fb25fc03c14db3e42c06e95ec035fe99c7fba5295f98440895831ab3db6c45fd8859a450f2a4fbe6b7d
|
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.
|
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
|
|
@@ -87,10 +88,11 @@ module DSPy
|
|
87
88
|
def forward_untyped(**input_values)
|
88
89
|
# Wrap in chain-specific span tracking (overrides parent's span attributes)
|
89
90
|
DSPy::Context.with_span(
|
90
|
-
operation: "
|
91
|
-
'langfuse.observation.type' => '
|
91
|
+
operation: "#{self.class.name}.forward",
|
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',
|
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
|
-
|
10
|
-
|
11
|
-
|
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)
|
@@ -43,12 +62,25 @@ module DSPy
|
|
43
62
|
span_attributes['langfuse.trace.name'] = operation
|
44
63
|
end
|
45
64
|
|
65
|
+
# Record start time for explicit duration tracking
|
66
|
+
otel_start_time = Time.now
|
67
|
+
|
46
68
|
DSPy::Observability.tracer.in_span(
|
47
69
|
operation,
|
48
70
|
attributes: span_attributes,
|
49
71
|
kind: :internal
|
50
72
|
) do |span|
|
51
|
-
yield(span)
|
73
|
+
result = yield(span)
|
74
|
+
|
75
|
+
# Add explicit timing information to help Langfuse
|
76
|
+
if span
|
77
|
+
duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
|
78
|
+
span.set_attribute('duration.ms', duration_ms)
|
79
|
+
span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
|
80
|
+
span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
|
81
|
+
end
|
82
|
+
|
83
|
+
result
|
52
84
|
end
|
53
85
|
else
|
54
86
|
yield(nil)
|
@@ -58,17 +90,29 @@ module DSPy
|
|
58
90
|
current[:span_stack].pop
|
59
91
|
|
60
92
|
# Log span end with duration (internal logging only)
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
67
101
|
end
|
68
102
|
end
|
69
103
|
|
70
104
|
def clear!
|
71
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
|
72
116
|
end
|
73
117
|
end
|
74
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
|
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
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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 = 1.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
|
data/lib/dspy/observability.rb
CHANGED
@@ -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
|
-
|
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
|
@@ -103,6 +112,15 @@ module DSPy
|
|
103
112
|
DSPy.log('observability.span_finish_error', error: e.message)
|
104
113
|
end
|
105
114
|
|
115
|
+
def flush!
|
116
|
+
return unless enabled?
|
117
|
+
|
118
|
+
# Force flush any pending spans
|
119
|
+
OpenTelemetry.tracer_provider.force_flush
|
120
|
+
rescue StandardError => e
|
121
|
+
DSPy.log('observability.flush_error', error: e.message)
|
122
|
+
end
|
123
|
+
|
106
124
|
def reset!
|
107
125
|
@enabled = false
|
108
126
|
@tracer = nil
|