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,537 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'sorbet-runtime'
4
- require_relative '../instrumentation/event_payloads'
5
-
6
- begin
7
- require 'opentelemetry/api'
8
- require 'opentelemetry/sdk'
9
- require 'opentelemetry/exporter/otlp'
10
- rescue LoadError
11
- # OpenTelemetry is optional - will be no-op if not available
12
- end
13
-
14
- module DSPy
15
- module Subscribers
16
- # OpenTelemetry subscriber that creates spans and metrics for DSPy operations
17
- # Provides comprehensive tracing for optimization operations and LM calls
18
- class OtelSubscriber
19
- extend T::Sig
20
-
21
- # Configuration for OpenTelemetry integration
22
- class OtelConfig
23
- extend T::Sig
24
-
25
- sig { returns(T::Boolean) }
26
- attr_accessor :enabled
27
-
28
- sig { returns(String) }
29
- attr_accessor :service_name
30
-
31
- sig { returns(String) }
32
- attr_accessor :service_version
33
-
34
- sig { returns(T.nilable(String)) }
35
- attr_accessor :endpoint
36
-
37
- sig { returns(T::Hash[String, String]) }
38
- attr_accessor :headers
39
-
40
- sig { returns(T::Boolean) }
41
- attr_accessor :trace_optimization_events
42
-
43
- sig { returns(T::Boolean) }
44
- attr_accessor :trace_lm_events
45
-
46
- sig { returns(T::Boolean) }
47
- attr_accessor :export_metrics
48
-
49
- sig { returns(Float) }
50
- attr_accessor :sample_rate
51
-
52
- sig { void }
53
- def initialize
54
- @enabled = !!(defined?(OpenTelemetry) && ENV['OTEL_EXPORTER_OTLP_ENDPOINT'])
55
- @service_name = ENV.fetch('OTEL_SERVICE_NAME', 'dspy-ruby')
56
- @service_version = begin
57
- ENV.fetch('OTEL_SERVICE_VERSION', DSPy::VERSION)
58
- rescue
59
- '1.0.0'
60
- end
61
- @endpoint = ENV['OTEL_EXPORTER_OTLP_ENDPOINT']
62
- @headers = parse_headers(ENV['OTEL_EXPORTER_OTLP_HEADERS'])
63
- @trace_optimization_events = true
64
- @trace_lm_events = true
65
- @export_metrics = true
66
- @sample_rate = ENV.fetch('OTEL_TRACE_SAMPLE_RATE', '1.0').to_f
67
- end
68
-
69
- private
70
-
71
- sig { params(headers_str: T.nilable(String)).returns(T::Hash[String, String]) }
72
- def parse_headers(headers_str)
73
- return {} unless headers_str
74
-
75
- headers_str.split(',').each_with_object({}) do |header, hash|
76
- key, value = header.split('=', 2)
77
- hash[key.strip] = value&.strip || ''
78
- end
79
- end
80
- end
81
-
82
- sig { returns(OtelConfig) }
83
- attr_reader :config
84
-
85
- sig { params(config: T.nilable(OtelConfig)).void }
86
- def initialize(config: nil)
87
- @config = config || OtelConfig.new
88
- @tracer = T.let(nil, T.nilable(T.untyped))
89
- @meter = T.let(nil, T.nilable(T.untyped))
90
- @optimization_spans = T.let({}, T::Hash[String, T.untyped])
91
- @trial_spans = T.let({}, T::Hash[String, T.untyped])
92
-
93
- setup_opentelemetry if @config.enabled
94
- setup_event_subscriptions
95
- end
96
-
97
- private
98
-
99
- sig { void }
100
- def setup_opentelemetry
101
- return unless defined?(OpenTelemetry)
102
-
103
- # Configure OpenTelemetry
104
- OpenTelemetry::SDK.configure do |c|
105
- c.service_name = @config.service_name
106
- c.service_version = @config.service_version
107
-
108
- if @config.endpoint
109
- c.add_span_processor(
110
- OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
111
- OpenTelemetry::Exporter::OTLP::Exporter.new(
112
- endpoint: @config.endpoint,
113
- headers: @config.headers
114
- )
115
- )
116
- )
117
- end
118
- end
119
-
120
- version = begin
121
- DSPy::VERSION
122
- rescue
123
- '1.0.0'
124
- end
125
-
126
- @tracer = OpenTelemetry.tracer_provider.tracer('dspy-ruby', version)
127
- @meter = OpenTelemetry.meter_provider.meter('dspy-ruby', version) if @config.export_metrics
128
- rescue => error
129
- warn "Failed to setup OpenTelemetry: #{error.message}"
130
- @config.enabled = false
131
- end
132
-
133
- sig { void }
134
- def setup_event_subscriptions
135
- return unless @config.enabled && @tracer
136
-
137
- # Subscribe to optimization events
138
- if @config.trace_optimization_events
139
- setup_optimization_subscriptions
140
- end
141
-
142
- # Subscribe to LM events
143
- if @config.trace_lm_events
144
- setup_lm_subscriptions
145
- end
146
-
147
- # Subscribe to storage and registry events
148
- setup_storage_subscriptions
149
- setup_registry_subscriptions
150
- end
151
-
152
- sig { void }
153
- def setup_optimization_subscriptions
154
- DSPy::Instrumentation.subscribe('dspy.optimization.start') do |event|
155
- handle_optimization_start(event)
156
- end
157
-
158
- DSPy::Instrumentation.subscribe('dspy.optimization.complete') do |event|
159
- handle_optimization_complete(event)
160
- end
161
-
162
- DSPy::Instrumentation.subscribe('dspy.optimization.trial_start') do |event|
163
- handle_trial_start(event)
164
- end
165
-
166
- DSPy::Instrumentation.subscribe('dspy.optimization.trial_complete') do |event|
167
- handle_trial_complete(event)
168
- end
169
-
170
- DSPy::Instrumentation.subscribe('dspy.optimization.bootstrap_start') do |event|
171
- handle_bootstrap_start(event)
172
- end
173
-
174
- DSPy::Instrumentation.subscribe('dspy.optimization.bootstrap_complete') do |event|
175
- handle_bootstrap_complete(event)
176
- end
177
-
178
- DSPy::Instrumentation.subscribe('dspy.optimization.error') do |event|
179
- handle_optimization_error(event)
180
- end
181
- end
182
-
183
- sig { void }
184
- def setup_lm_subscriptions
185
- DSPy::Instrumentation.subscribe('dspy.lm.request') do |event|
186
- handle_lm_request(event)
187
- end
188
-
189
- DSPy::Instrumentation.subscribe('dspy.predict') do |event|
190
- handle_prediction(event)
191
- end
192
-
193
- DSPy::Instrumentation.subscribe('dspy.chain_of_thought') do |event|
194
- handle_chain_of_thought(event)
195
- end
196
- end
197
-
198
- sig { void }
199
- def setup_storage_subscriptions
200
- DSPy::Instrumentation.subscribe('dspy.storage.save_start') do |event|
201
- handle_storage_operation(event, 'save')
202
- end
203
-
204
- DSPy::Instrumentation.subscribe('dspy.storage.load_start') do |event|
205
- handle_storage_operation(event, 'load')
206
- end
207
- end
208
-
209
- sig { void }
210
- def setup_registry_subscriptions
211
- DSPy::Instrumentation.subscribe('dspy.registry.register_start') do |event|
212
- handle_registry_operation(event, 'register')
213
- end
214
-
215
- DSPy::Instrumentation.subscribe('dspy.registry.deploy_start') do |event|
216
- handle_registry_operation(event, 'deploy')
217
- end
218
-
219
- DSPy::Instrumentation.subscribe('dspy.registry.rollback_start') do |event|
220
- handle_registry_operation(event, 'rollback')
221
- end
222
- end
223
-
224
- # Optimization event handlers
225
- sig { params(event: T.untyped).void }
226
- def handle_optimization_start(event)
227
- return unless @tracer
228
-
229
- payload = event.payload
230
- optimization_id = payload[:optimization_id] || SecureRandom.uuid
231
-
232
- span = @tracer.start_span(
233
- 'dspy.optimization',
234
- attributes: {
235
- 'dspy.operation' => 'optimization',
236
- 'dspy.optimization.id' => optimization_id,
237
- 'dspy.optimization.optimizer' => payload[:optimizer] || 'unknown',
238
- 'dspy.optimization.trainset_size' => payload[:trainset_size],
239
- 'dspy.optimization.valset_size' => payload[:valset_size],
240
- 'dspy.optimization.config' => payload[:config]&.to_s
241
- }
242
- )
243
-
244
- @optimization_spans[optimization_id] = span
245
-
246
- # Add metrics
247
- if @meter
248
- @meter.create_counter(
249
- 'dspy.optimization.started',
250
- description: 'Number of optimizations started'
251
- ).add(1, attributes: {
252
- 'optimizer' => payload[:optimizer] || 'unknown'
253
- })
254
- end
255
- end
256
-
257
- sig { params(event: T.untyped).void }
258
- def handle_optimization_complete(event)
259
- return unless @tracer
260
-
261
- payload = event.payload
262
- optimization_id = payload[:optimization_id]
263
- span = @optimization_spans.delete(optimization_id)
264
-
265
- return unless span
266
-
267
- span.set_attribute('dspy.optimization.status', 'success')
268
- span.set_attribute('dspy.optimization.duration_ms', payload[:duration_ms])
269
- span.set_attribute('dspy.optimization.best_score', payload[:best_score])
270
- span.set_attribute('dspy.optimization.trials_count', payload[:trials_count])
271
- span.set_attribute('dspy.optimization.final_instruction', payload[:final_instruction]&.slice(0, 500))
272
-
273
- span.finish
274
-
275
- # Record metrics
276
- if @meter && payload[:duration_ms]
277
- @meter.create_histogram(
278
- 'dspy.optimization.duration',
279
- description: 'Optimization duration in milliseconds'
280
- ).record(payload[:duration_ms], attributes: {
281
- 'optimizer' => payload[:optimizer] || 'unknown',
282
- 'status' => 'success'
283
- })
284
-
285
- if payload[:best_score]
286
- @meter.create_histogram(
287
- 'dspy.optimization.score',
288
- description: 'Best optimization score achieved'
289
- ).record(payload[:best_score], attributes: {
290
- 'optimizer' => payload[:optimizer] || 'unknown'
291
- })
292
- end
293
- end
294
- end
295
-
296
- sig { params(event: T.untyped).void }
297
- def handle_trial_start(event)
298
- return unless @tracer
299
-
300
- payload = event.payload
301
- trial_id = "#{payload[:optimization_id]}_#{payload[:trial_number]}"
302
-
303
- span = @tracer.start_span(
304
- 'dspy.optimization.trial',
305
- attributes: {
306
- 'dspy.operation' => 'optimization_trial',
307
- 'dspy.trial.id' => trial_id,
308
- 'dspy.trial.number' => payload[:trial_number],
309
- 'dspy.trial.instruction' => payload[:instruction]&.slice(0, 200),
310
- 'dspy.trial.examples_count' => payload[:examples_count]
311
- }
312
- )
313
-
314
- @trial_spans[trial_id] = span
315
- end
316
-
317
- sig { params(event: T.untyped).void }
318
- def handle_trial_complete(event)
319
- return unless @tracer
320
-
321
- payload = event.payload
322
- trial_id = "#{payload[:optimization_id]}_#{payload[:trial_number]}"
323
- span = @trial_spans.delete(trial_id)
324
-
325
- return unless span
326
-
327
- span.set_attribute('dspy.trial.status', payload[:status] || 'success')
328
- span.set_attribute('dspy.trial.duration_ms', payload[:duration_ms])
329
- span.set_attribute('dspy.trial.score', payload[:score]) if payload[:score]
330
- span.set_attribute('dspy.trial.error', payload[:error_message]) if payload[:error_message]
331
-
332
- if payload[:status] == 'error'
333
- span.record_exception(payload[:error_message] || 'Unknown error')
334
- span.status = OpenTelemetry::Trace::Status.error('Trial failed')
335
- end
336
-
337
- span.finish
338
- end
339
-
340
- sig { params(event: T.untyped).void }
341
- def handle_bootstrap_start(event)
342
- return unless @tracer
343
-
344
- payload = event.payload
345
-
346
- @tracer.in_span(
347
- 'dspy.optimization.bootstrap',
348
- attributes: {
349
- 'dspy.operation' => 'bootstrap',
350
- 'dspy.bootstrap.target_count' => payload[:target_count],
351
- 'dspy.bootstrap.trainset_size' => payload[:trainset_size]
352
- }
353
- ) do |span|
354
- # Span will be automatically finished when block exits
355
- end
356
- end
357
-
358
- sig { params(event: T.untyped).void }
359
- def handle_bootstrap_complete(event)
360
- # Bootstrap complete is handled by the span from bootstrap_start
361
- end
362
-
363
- sig { params(event: T.untyped).void }
364
- def handle_optimization_error(event)
365
- return unless @tracer
366
-
367
- payload = event.payload
368
- optimization_id = payload[:optimization_id]
369
- span = @optimization_spans.delete(optimization_id)
370
-
371
- if span
372
- span.set_attribute('dspy.optimization.status', 'error')
373
- span.set_attribute('dspy.optimization.error', payload[:error_message])
374
- span.record_exception(payload[:error_message] || 'Unknown optimization error')
375
- span.status = OpenTelemetry::Trace::Status.error('Optimization failed')
376
- span.finish
377
- end
378
-
379
- # Record error metrics
380
- if @meter
381
- @meter.create_counter(
382
- 'dspy.optimization.errors',
383
- description: 'Number of optimization errors'
384
- ).add(1, attributes: {
385
- 'optimizer' => payload[:optimizer] || 'unknown',
386
- 'error_type' => payload[:error_type] || 'unknown'
387
- })
388
- end
389
- end
390
-
391
- # LM event handlers
392
- sig { params(event: T.untyped).void }
393
- def handle_lm_request(event)
394
- return unless @tracer
395
-
396
- payload = event.payload
397
-
398
- @tracer.in_span(
399
- 'dspy.lm.request',
400
- attributes: {
401
- 'dspy.operation' => 'lm_request',
402
- 'dspy.lm.provider' => payload[:provider],
403
- 'dspy.lm.model' => payload[:gen_ai_request_model] || payload[:model],
404
- 'dspy.lm.status' => payload[:status],
405
- 'dspy.lm.duration_ms' => payload[:duration_ms],
406
- 'dspy.lm.adapter_class' => payload[:adapter_class],
407
- 'dspy.lm.input_size' => payload[:input_size]
408
- }
409
- ) do |span|
410
- if payload[:status] == 'error'
411
- span.record_exception(payload[:error_message] || 'LM request failed')
412
- span.status = OpenTelemetry::Trace::Status.error('LM request failed')
413
- end
414
-
415
- # Record metrics
416
- if @meter
417
- if payload[:duration_ms]
418
- @meter.create_histogram(
419
- 'dspy.lm.request.duration',
420
- description: 'LM request duration in milliseconds'
421
- ).record(payload[:duration_ms], attributes: {
422
- 'provider' => payload[:provider],
423
- 'model' => payload[:gen_ai_request_model] || payload[:model],
424
- 'status' => payload[:status]
425
- })
426
- end
427
-
428
- if payload[:tokens_total]
429
- @meter.create_histogram(
430
- 'dspy.lm.tokens.total',
431
- description: 'Total tokens used in LM request'
432
- ).record(payload[:tokens_total], attributes: {
433
- 'provider' => payload[:provider],
434
- 'model' => payload[:gen_ai_request_model] || payload[:model]
435
- })
436
- end
437
-
438
- if payload[:cost]
439
- @meter.create_histogram(
440
- 'dspy.lm.cost',
441
- description: 'Cost of LM request'
442
- ).record(payload[:cost], attributes: {
443
- 'provider' => payload[:provider],
444
- 'model' => payload[:gen_ai_request_model] || payload[:model]
445
- })
446
- end
447
- end
448
- end
449
- end
450
-
451
- sig { params(event: T.untyped).void }
452
- def handle_prediction(event)
453
- return unless @tracer
454
-
455
- payload = event.payload
456
-
457
- @tracer.in_span(
458
- 'dspy.predict',
459
- attributes: {
460
- 'dspy.operation' => 'predict',
461
- 'dspy.signature' => payload[:signature_class],
462
- 'dspy.predict.status' => payload[:status],
463
- 'dspy.predict.duration_ms' => payload[:duration_ms],
464
- 'dspy.predict.input_size' => payload[:input_size]
465
- }
466
- ) do |span|
467
- if payload[:status] == 'error'
468
- span.record_exception(payload[:error_message] || 'Prediction failed')
469
- span.status = OpenTelemetry::Trace::Status.error('Prediction failed')
470
- end
471
- end
472
- end
473
-
474
- sig { params(event: T.untyped).void }
475
- def handle_chain_of_thought(event)
476
- return unless @tracer
477
-
478
- payload = event.payload
479
-
480
- @tracer.in_span(
481
- 'dspy.chain_of_thought',
482
- attributes: {
483
- 'dspy.operation' => 'chain_of_thought',
484
- 'dspy.signature' => payload[:signature_class],
485
- 'dspy.cot.status' => payload[:status],
486
- 'dspy.cot.duration_ms' => payload[:duration_ms],
487
- 'dspy.cot.reasoning_steps' => payload[:reasoning_steps],
488
- 'dspy.cot.reasoning_length' => payload[:reasoning_length]
489
- }
490
- ) do |span|
491
- if payload[:status] == 'error'
492
- span.record_exception(payload[:error_message] || 'Chain of thought failed')
493
- span.status = OpenTelemetry::Trace::Status.error('Chain of thought failed')
494
- end
495
- end
496
- end
497
-
498
- # Storage event handlers
499
- sig { params(event: T.untyped, operation: String).void }
500
- def handle_storage_operation(event, operation)
501
- return unless @tracer
502
-
503
- payload = event.payload
504
-
505
- @tracer.in_span(
506
- "dspy.storage.#{operation}",
507
- attributes: {
508
- 'dspy.operation' => "storage_#{operation}",
509
- 'dspy.storage.program_id' => payload[:program_id],
510
- 'dspy.storage.size_bytes' => payload[:size_bytes]
511
- }
512
- ) do |span|
513
- # Span will auto-complete
514
- end
515
- end
516
-
517
- # Registry event handlers
518
- sig { params(event: T.untyped, operation: String).void }
519
- def handle_registry_operation(event, operation)
520
- return unless @tracer
521
-
522
- payload = event.payload
523
-
524
- @tracer.in_span(
525
- "dspy.registry.#{operation}",
526
- attributes: {
527
- 'dspy.operation' => "registry_#{operation}",
528
- 'dspy.registry.signature_name' => payload[:signature_name],
529
- 'dspy.registry.version' => payload[:version]
530
- }
531
- ) do |span|
532
- # Span will auto-complete
533
- end
534
- end
535
- end
536
- end
537
- end