dspy 0.17.0 → 0.18.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.
@@ -1,341 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dry-monitor'
4
- require 'dry-configurable'
5
- require 'time'
6
- require_relative 'instrumentation/event_payload_factory'
7
-
8
- module DSPy
9
- # Core instrumentation module using dry-monitor for event emission
10
- # Provides extension points for logging, OpenTelemetry, New Relic, Langfuse, and custom monitoring
11
- module Instrumentation
12
- # Get a logger subscriber instance (creates new instance each time)
13
- def self.logger_subscriber(**options)
14
- require_relative 'subscribers/logger_subscriber'
15
- DSPy::Subscribers::LoggerSubscriber.new(**options)
16
- end
17
-
18
- # Get an OpenTelemetry subscriber instance (creates new instance each time)
19
- def self.otel_subscriber(**options)
20
- require_relative 'subscribers/otel_subscriber'
21
- DSPy::Subscribers::OtelSubscriber.new(**options)
22
- end
23
-
24
- # Get a New Relic subscriber instance (creates new instance each time)
25
- def self.newrelic_subscriber(**options)
26
- require_relative 'subscribers/newrelic_subscriber'
27
- DSPy::Subscribers::NewrelicSubscriber.new(**options)
28
- end
29
-
30
- # Get a Langfuse subscriber instance (creates new instance each time)
31
- def self.langfuse_subscriber(**options)
32
- require_relative 'subscribers/langfuse_subscriber'
33
- DSPy::Subscribers::LangfuseSubscriber.new(**options)
34
- end
35
-
36
- def self.notifications
37
- @notifications ||= Dry::Monitor::Notifications.new(:dspy).tap do |n|
38
- # Register all DSPy events
39
- n.register_event('dspy.lm.request')
40
- n.register_event('dspy.lm.tokens')
41
- n.register_event('dspy.lm.response.parsed')
42
- n.register_event('dspy.predict')
43
- n.register_event('dspy.predict.validation_error')
44
- n.register_event('dspy.chain_of_thought')
45
- n.register_event('dspy.chain_of_thought.reasoning_step')
46
- n.register_event('dspy.react')
47
- n.register_event('dspy.react.tool_call')
48
- n.register_event('dspy.react.iteration_complete')
49
- n.register_event('dspy.react.max_iterations')
50
-
51
- # CodeAct events
52
- n.register_event('dspy.codeact')
53
- n.register_event('dspy.codeact.iteration')
54
- n.register_event('dspy.codeact.code_execution')
55
- n.register_event('dspy.codeact.iteration_complete')
56
- n.register_event('dspy.codeact.max_iterations')
57
-
58
- # Evaluation events
59
- n.register_event('dspy.evaluation.start')
60
- n.register_event('dspy.evaluation.example')
61
- n.register_event('dspy.evaluation.batch')
62
- n.register_event('dspy.evaluation.batch_complete')
63
-
64
- # Optimization events
65
- n.register_event('dspy.optimization.start')
66
- n.register_event('dspy.optimization.complete')
67
- n.register_event('dspy.optimization.trial_start')
68
- n.register_event('dspy.optimization.trial_complete')
69
- n.register_event('dspy.optimization.bootstrap_start')
70
- n.register_event('dspy.optimization.bootstrap_complete')
71
- n.register_event('dspy.optimization.bootstrap_example')
72
- n.register_event('dspy.optimization.minibatch_evaluation')
73
- n.register_event('dspy.optimization.instruction_proposal_start')
74
- n.register_event('dspy.optimization.instruction_proposal_complete')
75
- n.register_event('dspy.optimization.error')
76
- n.register_event('dspy.optimization.save')
77
- n.register_event('dspy.optimization.load')
78
-
79
- # Storage events
80
- n.register_event('dspy.storage.save_start')
81
- n.register_event('dspy.storage.save_complete')
82
- n.register_event('dspy.storage.save_error')
83
- n.register_event('dspy.storage.load_start')
84
- n.register_event('dspy.storage.load_complete')
85
- n.register_event('dspy.storage.load_error')
86
- n.register_event('dspy.storage.delete')
87
- n.register_event('dspy.storage.export')
88
- n.register_event('dspy.storage.import')
89
- n.register_event('dspy.storage.cleanup')
90
-
91
- # Memory compaction events
92
- n.register_event('dspy.memory.compaction_check')
93
- n.register_event('dspy.memory.size_compaction')
94
- n.register_event('dspy.memory.age_compaction')
95
- n.register_event('dspy.memory.deduplication')
96
- n.register_event('dspy.memory.relevance_pruning')
97
- n.register_event('dspy.memory.compaction_complete')
98
-
99
- # Registry events
100
- n.register_event('dspy.registry.register_start')
101
- n.register_event('dspy.registry.register_complete')
102
- n.register_event('dspy.registry.register_error')
103
- n.register_event('dspy.registry.deploy_start')
104
- n.register_event('dspy.registry.deploy_complete')
105
- n.register_event('dspy.registry.deploy_error')
106
- n.register_event('dspy.registry.rollback_start')
107
- n.register_event('dspy.registry.rollback_complete')
108
- n.register_event('dspy.registry.rollback_error')
109
- n.register_event('dspy.registry.performance_update')
110
- n.register_event('dspy.registry.export')
111
- n.register_event('dspy.registry.import')
112
- n.register_event('dspy.registry.auto_deployment')
113
- n.register_event('dspy.registry.automatic_rollback')
114
- end
115
- end
116
-
117
- # High-precision timing for performance tracking
118
- def self.instrument(event_name, payload = {}, &block)
119
- # If no block is given, return early
120
- return unless block_given?
121
-
122
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
- start_cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
124
-
125
- begin
126
- result = yield
127
-
128
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
- end_cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
130
-
131
- enhanced_payload = payload.merge(
132
- duration_ms: ((end_time - start_time) * 1000).round(2),
133
- cpu_time_ms: ((end_cpu - start_cpu) * 1000).round(2),
134
- status: 'success'
135
- ).merge(generate_timestamp)
136
-
137
- # Create typed event struct
138
- event_struct = EventPayloadFactory.create_event(event_name, enhanced_payload)
139
- self.emit_event(event_name, event_struct)
140
- result
141
- rescue => error
142
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
- end_cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
144
-
145
- error_payload = payload.merge(
146
- duration_ms: ((end_time - start_time) * 1000).round(2),
147
- cpu_time_ms: ((end_cpu - start_cpu) * 1000).round(2),
148
- status: 'error',
149
- error_type: error.class.name,
150
- error_message: error.message
151
- ).merge(generate_timestamp)
152
-
153
- # Create typed event struct
154
- event_struct = EventPayloadFactory.create_event(event_name, error_payload)
155
- self.emit_event(event_name, event_struct)
156
- raise
157
- end
158
- end
159
-
160
- # Emit event without timing (for discrete events)
161
- def self.emit(event_name, payload = {})
162
- # Handle nil payload
163
- payload ||= {}
164
-
165
- enhanced_payload = payload.merge(
166
- status: payload[:status] || 'success'
167
- ).merge(generate_timestamp)
168
-
169
- # Create typed event struct
170
- event_struct = EventPayloadFactory.create_event(event_name, enhanced_payload)
171
- self.emit_event(event_name, event_struct)
172
- end
173
-
174
- # Register additional events dynamically (useful for testing)
175
- def self.register_event(event_name)
176
- notifications.register_event(event_name)
177
- end
178
-
179
- # Subscribe to DSPy instrumentation events
180
- def self.subscribe(event_pattern = nil, &block)
181
- if event_pattern
182
- notifications.subscribe(event_pattern, &block)
183
- else
184
- # Subscribe to all DSPy events
185
- %w[dspy.lm.request dspy.lm.tokens dspy.lm.response.parsed dspy.predict dspy.predict.validation_error dspy.chain_of_thought dspy.chain_of_thought.reasoning_step dspy.react dspy.react.tool_call dspy.react.iteration_complete dspy.react.max_iterations].each do |event_name|
186
- notifications.subscribe(event_name, &block)
187
- end
188
- end
189
- end
190
-
191
- def self.emit_event(event_name, payload)
192
- # Only emit events - subscribers self-register when explicitly created
193
- # Convert struct to hash if needed (dry-monitor expects hash)
194
- if payload.respond_to?(:to_h)
195
- payload_hash = payload.to_h
196
- # Restore original timestamp format if needed
197
- restore_timestamp_format(payload_hash)
198
- else
199
- payload_hash = payload
200
- end
201
- notifications.instrument(event_name, payload_hash)
202
- end
203
-
204
- # Restore timestamp to original format based on configuration
205
- def self.restore_timestamp_format(payload_hash)
206
- return unless payload_hash[:timestamp]
207
-
208
- case DSPy.config.instrumentation.timestamp_format
209
- when DSPy::TimestampFormat::UNIX_NANO
210
- # Convert ISO8601 back to nanoseconds
211
- timestamp = Time.parse(payload_hash[:timestamp])
212
- payload_hash.delete(:timestamp)
213
- payload_hash[:timestamp_ns] = (timestamp.to_f * 1_000_000_000).to_i
214
- when DSPy::TimestampFormat::RFC3339_NANO
215
- # Convert to RFC3339 with nanoseconds
216
- timestamp = Time.parse(payload_hash[:timestamp])
217
- payload_hash[:timestamp] = timestamp.strftime('%Y-%m-%dT%H:%M:%S.%9N%z')
218
- end
219
- end
220
-
221
- def self.setup_subscribers
222
- config = DSPy.config.instrumentation
223
-
224
- # Return early if instrumentation is disabled
225
- return unless config.enabled
226
-
227
- # Validate configuration first
228
- DSPy.validate_instrumentation!
229
-
230
- # Setup each configured subscriber
231
- config.subscribers.each do |subscriber_type|
232
- setup_subscriber(subscriber_type)
233
- end
234
- end
235
-
236
- def self.setup_subscriber(subscriber_type)
237
- case subscriber_type
238
- when :logger
239
- setup_logger_subscriber
240
- when :otel
241
- setup_otel_subscriber if otel_available?
242
- when :newrelic
243
- setup_newrelic_subscriber if newrelic_available?
244
- when :langfuse
245
- setup_langfuse_subscriber if langfuse_available?
246
- else
247
- raise ArgumentError, "Unknown subscriber type: #{subscriber_type}"
248
- end
249
- rescue LoadError => e
250
- DSPy.logger.warn "Failed to setup #{subscriber_type} subscriber: #{e.message}"
251
- end
252
-
253
- def self.setup_logger_subscriber
254
- # Create subscriber - it will read configuration when handling events
255
- logger_subscriber
256
- end
257
-
258
- def self.setup_otel_subscriber
259
- # Create subscriber - it will read configuration when handling events
260
- otel_subscriber
261
- end
262
-
263
- def self.setup_newrelic_subscriber
264
- # Create subscriber - it will read configuration when handling events
265
- newrelic_subscriber
266
- end
267
-
268
- def self.setup_langfuse_subscriber
269
- # Create subscriber - it will read configuration when handling events
270
- langfuse_subscriber
271
- end
272
-
273
- # Dependency checking methods
274
- def self.otel_available?
275
- begin
276
- require 'opentelemetry/sdk'
277
- true
278
- rescue LoadError
279
- false
280
- end
281
- end
282
-
283
- def self.newrelic_available?
284
- begin
285
- require 'newrelic_rpm'
286
- true
287
- rescue LoadError
288
- false
289
- end
290
- end
291
-
292
- def self.langfuse_available?
293
- begin
294
- require 'langfuse'
295
- true
296
- rescue LoadError
297
- false
298
- end
299
- end
300
-
301
- # Generate timestamp in the configured format
302
- def self.generate_timestamp
303
- case DSPy.config.instrumentation.timestamp_format
304
- when DSPy::TimestampFormat::ISO8601
305
- { timestamp: Time.now.iso8601 }
306
- when DSPy::TimestampFormat::RFC3339_NANO
307
- { timestamp: Time.now.strftime('%Y-%m-%dT%H:%M:%S.%9N%z') }
308
- when DSPy::TimestampFormat::UNIX_NANO
309
- { timestamp_ns: (Time.now.to_f * 1_000_000_000).to_i }
310
- else
311
- { timestamp: Time.now.iso8601 } # Fallback to iso8601
312
- end
313
- end
314
-
315
- # Legacy setup method for backward compatibility
316
- def self.setup_subscribers_legacy
317
- # Legacy initialization - will be created when first accessed
318
- # Force initialization of enabled subscribers
319
- logger_subscriber
320
-
321
- # Only initialize if dependencies are available
322
- begin
323
- otel_subscriber if ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] || defined?(OpenTelemetry)
324
- rescue LoadError
325
- # OpenTelemetry not available, skip
326
- end
327
-
328
- begin
329
- newrelic_subscriber if defined?(NewRelic)
330
- rescue LoadError
331
- # New Relic not available, skip
332
- end
333
-
334
- begin
335
- langfuse_subscriber if ENV['LANGFUSE_SECRET_KEY'] || defined?(Langfuse)
336
- rescue LoadError
337
- # Langfuse not available, skip
338
- end
339
- end
340
- end
341
- end
@@ -1,120 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require 'sorbet-runtime'
5
- require_relative '../instrumentation'
6
-
7
- module DSPy
8
- module Mixins
9
- # Shared instrumentation helper methods for DSPy modules
10
- module InstrumentationHelpers
11
- extend T::Sig
12
-
13
- private
14
-
15
- # Prepares base instrumentation payload for prediction-based modules
16
- sig { params(signature_class: T.class_of(DSPy::Signature), input_values: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
17
- def prepare_base_instrumentation_payload(signature_class, input_values)
18
- # Validate LM is configured before accessing its properties
19
- raise DSPy::ConfigurationError.missing_lm(self.class.name) if lm.nil?
20
-
21
- {
22
- signature_class: signature_class.name,
23
- model: lm.model,
24
- provider: lm.provider,
25
- input_fields: input_values.keys.map(&:to_s)
26
- }
27
- end
28
-
29
- # Instruments a prediction operation with base payload
30
- sig { params(event_name: String, signature_class: T.class_of(DSPy::Signature), input_values: T::Hash[Symbol, T.untyped], additional_payload: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
31
- def instrument_prediction(event_name, signature_class, input_values, additional_payload = {})
32
- base_payload = prepare_base_instrumentation_payload(signature_class, input_values)
33
- full_payload = base_payload.merge(additional_payload)
34
-
35
- # Use smart consolidation: skip nested events when higher-level events are being emitted
36
- if should_emit_event?(event_name)
37
- Instrumentation.instrument(event_name, full_payload) do
38
- yield
39
- end
40
- else
41
- # Skip instrumentation, just execute the block
42
- yield
43
- end
44
- end
45
-
46
- # Emits a validation error event
47
- sig { params(signature_class: T.class_of(DSPy::Signature), validation_type: String, error_message: String).void }
48
- def emit_validation_error(signature_class, validation_type, error_message)
49
- Instrumentation.emit('dspy.prediction.validation_error', {
50
- signature_class: signature_class.name,
51
- validation_type: validation_type,
52
- validation_errors: { validation_type.to_sym => error_message }
53
- })
54
- end
55
-
56
- # Emits a prediction completion event
57
- sig { params(signature_class: T.class_of(DSPy::Signature), success: T::Boolean, additional_data: T::Hash[Symbol, T.untyped]).void }
58
- def emit_prediction_complete(signature_class, success, additional_data = {})
59
- Instrumentation.emit('dspy.prediction.complete', {
60
- signature_class: signature_class.name,
61
- success: success
62
- }.merge(additional_data))
63
- end
64
-
65
- # Determines if an event should be emitted using smart consolidation
66
- sig { params(event_name: String).returns(T::Boolean) }
67
- def should_emit_event?(event_name)
68
- # Smart consolidation: skip nested events when higher-level events are being emitted
69
- if is_nested_context?
70
- # If we're in a nested context, only emit higher-level events
71
- event_name.match?(/^dspy\.(chain_of_thought|react|codeact)$/)
72
- else
73
- # If we're not in a nested context, emit all events normally
74
- true
75
- end
76
- end
77
-
78
- # Determines if this is a top-level event (not nested)
79
- sig { params(event_name: String).returns(T::Boolean) }
80
- def is_top_level_event?(event_name)
81
- # Check if we're in a nested call by looking at the call stack
82
- caller_locations = caller_locations(1, 20)
83
- return false if caller_locations.nil?
84
-
85
- # Look for other instrumentation calls in the stack
86
- instrumentation_calls = caller_locations.select do |loc|
87
- loc.label.include?('instrument_prediction') ||
88
- loc.label.include?('instrument') ||
89
- loc.path.include?('instrumentation')
90
- end
91
-
92
- # If we have more than one instrumentation call, this is nested
93
- instrumentation_calls.size <= 1
94
- end
95
-
96
- # Determines if we're in a nested call context
97
- sig { returns(T::Boolean) }
98
- def is_nested_call?
99
- !is_top_level_event?('')
100
- end
101
-
102
- # Determines if we're in a nested context where higher-level events are being emitted
103
- sig { returns(T::Boolean) }
104
- def is_nested_context?
105
- caller_locations = caller_locations(1, 30)
106
- return false if caller_locations.nil?
107
-
108
- # Look for higher-level DSPy modules in the call stack
109
- # We consider ChainOfThought, ReAct, and CodeAct as higher-level modules
110
- higher_level_modules = caller_locations.select do |loc|
111
- loc.path.match?(/(?:chain_of_thought|re_act|react|code_act)/)
112
- end
113
-
114
- # If we have higher-level modules in the call stack, we're in a nested context
115
- higher_level_modules.any?
116
- end
117
-
118
- end
119
- end
120
- end