dspy 0.3.1 → 0.4.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.
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ begin
6
+ require 'newrelic_rpm'
7
+ rescue LoadError
8
+ # New Relic is optional - will be no-op if not available
9
+ end
10
+
11
+ module DSPy
12
+ module Subscribers
13
+ # New Relic subscriber that creates custom metrics and traces for DSPy operations
14
+ # Provides comprehensive monitoring for optimization operations and performance tracking
15
+ class NewrelicSubscriber
16
+ extend T::Sig
17
+
18
+ # Configuration for New Relic integration
19
+ class NewrelicConfig
20
+ extend T::Sig
21
+
22
+ sig { returns(T::Boolean) }
23
+ attr_accessor :enabled
24
+
25
+ sig { returns(String) }
26
+ attr_accessor :app_name
27
+
28
+ sig { returns(T::Boolean) }
29
+ attr_accessor :trace_optimization_events
30
+
31
+ sig { returns(T::Boolean) }
32
+ attr_accessor :trace_lm_events
33
+
34
+ sig { returns(T::Boolean) }
35
+ attr_accessor :record_custom_metrics
36
+
37
+ sig { returns(T::Boolean) }
38
+ attr_accessor :record_custom_events
39
+
40
+ sig { returns(String) }
41
+ attr_accessor :metric_prefix
42
+
43
+ sig { void }
44
+ def initialize
45
+ @enabled = begin
46
+ !!(defined?(NewRelic) && NewRelic::Agent.config[:agent_enabled])
47
+ rescue
48
+ false
49
+ end
50
+ @app_name = begin
51
+ NewRelic::Agent.config[:app_name] || 'DSPy Ruby Application'
52
+ rescue
53
+ 'DSPy Ruby Application'
54
+ end
55
+ @trace_optimization_events = true
56
+ @trace_lm_events = true
57
+ @record_custom_metrics = true
58
+ @record_custom_events = true
59
+ @metric_prefix = 'Custom/DSPy'
60
+ end
61
+ end
62
+
63
+ sig { returns(NewrelicConfig) }
64
+ attr_reader :config
65
+
66
+ sig { params(config: T.nilable(NewrelicConfig)).void }
67
+ def initialize(config: nil)
68
+ @config = config || NewrelicConfig.new
69
+ @optimization_transactions = T.let({}, T::Hash[String, T.untyped])
70
+
71
+ setup_event_subscriptions if @config.enabled
72
+ end
73
+
74
+ private
75
+
76
+ sig { void }
77
+ def setup_event_subscriptions
78
+ return unless @config.enabled && defined?(NewRelic)
79
+
80
+ # Subscribe to optimization events
81
+ if @config.trace_optimization_events
82
+ setup_optimization_subscriptions
83
+ end
84
+
85
+ # Subscribe to LM events
86
+ if @config.trace_lm_events
87
+ setup_lm_subscriptions
88
+ end
89
+
90
+ # Subscribe to storage and registry events
91
+ setup_storage_subscriptions
92
+ setup_registry_subscriptions
93
+ end
94
+
95
+ sig { void }
96
+ def setup_optimization_subscriptions
97
+ DSPy::Instrumentation.subscribe('dspy.optimization.start') do |event|
98
+ handle_optimization_start(event)
99
+ end
100
+
101
+ DSPy::Instrumentation.subscribe('dspy.optimization.complete') do |event|
102
+ handle_optimization_complete(event)
103
+ end
104
+
105
+ DSPy::Instrumentation.subscribe('dspy.optimization.trial_start') do |event|
106
+ handle_trial_start(event)
107
+ end
108
+
109
+ DSPy::Instrumentation.subscribe('dspy.optimization.trial_complete') do |event|
110
+ handle_trial_complete(event)
111
+ end
112
+
113
+ DSPy::Instrumentation.subscribe('dspy.optimization.bootstrap_start') do |event|
114
+ handle_bootstrap_start(event)
115
+ end
116
+
117
+ DSPy::Instrumentation.subscribe('dspy.optimization.bootstrap_complete') do |event|
118
+ handle_bootstrap_complete(event)
119
+ end
120
+
121
+ DSPy::Instrumentation.subscribe('dspy.optimization.error') do |event|
122
+ handle_optimization_error(event)
123
+ end
124
+ end
125
+
126
+ sig { void }
127
+ def setup_lm_subscriptions
128
+ DSPy::Instrumentation.subscribe('dspy.lm.request') do |event|
129
+ handle_lm_request(event)
130
+ end
131
+
132
+ DSPy::Instrumentation.subscribe('dspy.predict') do |event|
133
+ handle_prediction(event)
134
+ end
135
+
136
+ DSPy::Instrumentation.subscribe('dspy.chain_of_thought') do |event|
137
+ handle_chain_of_thought(event)
138
+ end
139
+ end
140
+
141
+ sig { void }
142
+ def setup_storage_subscriptions
143
+ DSPy::Instrumentation.subscribe('dspy.storage.save_complete') do |event|
144
+ handle_storage_operation(event, 'save')
145
+ end
146
+
147
+ DSPy::Instrumentation.subscribe('dspy.storage.load_complete') do |event|
148
+ handle_storage_operation(event, 'load')
149
+ end
150
+ end
151
+
152
+ sig { void }
153
+ def setup_registry_subscriptions
154
+ DSPy::Instrumentation.subscribe('dspy.registry.register_complete') do |event|
155
+ handle_registry_operation(event, 'register')
156
+ end
157
+
158
+ DSPy::Instrumentation.subscribe('dspy.registry.deploy_complete') do |event|
159
+ handle_registry_operation(event, 'deploy')
160
+ end
161
+
162
+ DSPy::Instrumentation.subscribe('dspy.registry.rollback_complete') do |event|
163
+ handle_registry_operation(event, 'rollback')
164
+ end
165
+
166
+ DSPy::Instrumentation.subscribe('dspy.registry.auto_deployment') do |event|
167
+ handle_auto_deployment(event)
168
+ end
169
+
170
+ DSPy::Instrumentation.subscribe('dspy.registry.automatic_rollback') do |event|
171
+ handle_automatic_rollback(event)
172
+ end
173
+ end
174
+
175
+ # Optimization event handlers
176
+ sig { params(event: T.untyped).void }
177
+ def handle_optimization_start(event)
178
+ return unless @config.enabled && defined?(NewRelic)
179
+
180
+ payload = event.payload
181
+ optimization_id = payload[:optimization_id] || SecureRandom.uuid
182
+
183
+ # Start custom transaction for optimization
184
+ NewRelic::Agent.start_transaction(
185
+ name: 'DSPy/Optimization',
186
+ category: :task,
187
+ options: {
188
+ custom_params: {
189
+ optimization_id: optimization_id,
190
+ optimizer: payload[:optimizer] || 'unknown',
191
+ trainset_size: payload[:trainset_size],
192
+ valset_size: payload[:valset_size]
193
+ }
194
+ }
195
+ )
196
+
197
+ @optimization_transactions[optimization_id] = {
198
+ started_at: Time.now,
199
+ optimizer: payload[:optimizer] || 'unknown'
200
+ }
201
+
202
+ # Record custom event
203
+ if @config.record_custom_events
204
+ NewRelic::Agent.record_custom_event('DSPyOptimizationStart', {
205
+ optimization_id: optimization_id,
206
+ optimizer: payload[:optimizer] || 'unknown',
207
+ trainset_size: payload[:trainset_size],
208
+ valset_size: payload[:valset_size],
209
+ timestamp: Time.now.to_f
210
+ })
211
+ end
212
+ end
213
+
214
+ sig { params(event: T.untyped).void }
215
+ def handle_optimization_complete(event)
216
+ return unless @config.enabled && defined?(NewRelic)
217
+
218
+ payload = event.payload
219
+ optimization_id = payload[:optimization_id]
220
+ transaction_info = @optimization_transactions.delete(optimization_id)
221
+
222
+ if transaction_info
223
+ # Add custom attributes to the transaction
224
+ NewRelic::Agent.add_custom_attributes({
225
+ 'dspy.optimization.status' => 'success',
226
+ 'dspy.optimization.duration_ms' => payload[:duration_ms],
227
+ 'dspy.optimization.best_score' => payload[:best_score],
228
+ 'dspy.optimization.trials_count' => payload[:trials_count],
229
+ 'dspy.optimization.optimizer' => transaction_info[:optimizer]
230
+ })
231
+
232
+ # Record custom metrics
233
+ if @config.record_custom_metrics
234
+ record_optimization_metrics(payload, transaction_info[:optimizer])
235
+ end
236
+
237
+ # Record custom event
238
+ if @config.record_custom_events
239
+ NewRelic::Agent.record_custom_event('DSPyOptimizationComplete', {
240
+ optimization_id: optimization_id,
241
+ optimizer: transaction_info[:optimizer],
242
+ duration_ms: payload[:duration_ms],
243
+ best_score: payload[:best_score],
244
+ trials_count: payload[:trials_count],
245
+ status: 'success',
246
+ timestamp: Time.now.to_f
247
+ })
248
+ end
249
+ end
250
+
251
+ # End the transaction
252
+ NewRelic::Agent.end_transaction
253
+ end
254
+
255
+ sig { params(event: T.untyped).void }
256
+ def handle_trial_start(event)
257
+ return unless @config.enabled && defined?(NewRelic)
258
+
259
+ payload = event.payload
260
+
261
+ # Create a traced method for this trial
262
+ NewRelic::Agent.record_metric(
263
+ "#{@config.metric_prefix}/Trial/Started",
264
+ 1
265
+ ) if @config.record_custom_metrics
266
+ end
267
+
268
+ sig { params(event: T.untyped).void }
269
+ def handle_trial_complete(event)
270
+ return unless @config.enabled && defined?(NewRelic)
271
+
272
+ payload = event.payload
273
+ status = payload[:status] || 'success'
274
+
275
+ # Record trial metrics
276
+ if @config.record_custom_metrics
277
+ NewRelic::Agent.record_metric(
278
+ "#{@config.metric_prefix}/Trial/Completed",
279
+ 1
280
+ )
281
+
282
+ NewRelic::Agent.record_metric(
283
+ "#{@config.metric_prefix}/Trial/Duration",
284
+ payload[:duration_ms] || 0
285
+ )
286
+
287
+ if payload[:score]
288
+ NewRelic::Agent.record_metric(
289
+ "#{@config.metric_prefix}/Trial/Score",
290
+ payload[:score]
291
+ )
292
+ end
293
+
294
+ if status == 'error'
295
+ NewRelic::Agent.record_metric(
296
+ "#{@config.metric_prefix}/Trial/Errors",
297
+ 1
298
+ )
299
+ end
300
+ end
301
+
302
+ # Record custom event
303
+ if @config.record_custom_events
304
+ NewRelic::Agent.record_custom_event('DSPyTrialComplete', {
305
+ optimization_id: payload[:optimization_id],
306
+ trial_number: payload[:trial_number],
307
+ duration_ms: payload[:duration_ms],
308
+ score: payload[:score],
309
+ status: status,
310
+ instruction: payload[:instruction]&.slice(0, 100),
311
+ timestamp: Time.now.to_f
312
+ })
313
+ end
314
+ end
315
+
316
+ sig { params(event: T.untyped).void }
317
+ def handle_bootstrap_start(event)
318
+ return unless @config.enabled && defined?(NewRelic)
319
+
320
+ payload = event.payload
321
+
322
+ NewRelic::Agent.record_metric(
323
+ "#{@config.metric_prefix}/Bootstrap/Started",
324
+ 1
325
+ ) if @config.record_custom_metrics
326
+ end
327
+
328
+ sig { params(event: T.untyped).void }
329
+ def handle_bootstrap_complete(event)
330
+ return unless @config.enabled && defined?(NewRelic)
331
+
332
+ payload = event.payload
333
+
334
+ if @config.record_custom_metrics
335
+ NewRelic::Agent.record_metric(
336
+ "#{@config.metric_prefix}/Bootstrap/Completed",
337
+ 1
338
+ )
339
+
340
+ NewRelic::Agent.record_metric(
341
+ "#{@config.metric_prefix}/Bootstrap/Duration",
342
+ payload[:duration_ms] || 0
343
+ )
344
+
345
+ if payload[:examples_generated]
346
+ NewRelic::Agent.record_metric(
347
+ "#{@config.metric_prefix}/Bootstrap/ExamplesGenerated",
348
+ payload[:examples_generated]
349
+ )
350
+ end
351
+ end
352
+ end
353
+
354
+ sig { params(event: T.untyped).void }
355
+ def handle_optimization_error(event)
356
+ return unless @config.enabled && defined?(NewRelic)
357
+
358
+ payload = event.payload
359
+ optimization_id = payload[:optimization_id]
360
+ transaction_info = @optimization_transactions.delete(optimization_id)
361
+
362
+ # Record the error
363
+ error_message = payload[:error_message] || 'Unknown optimization error'
364
+ NewRelic::Agent.notice_error(
365
+ StandardError.new(error_message),
366
+ {
367
+ optimization_id: optimization_id,
368
+ optimizer: payload[:optimizer] || 'unknown',
369
+ error_type: payload[:error_type] || 'unknown'
370
+ }
371
+ )
372
+
373
+ # Record error metrics
374
+ if @config.record_custom_metrics
375
+ NewRelic::Agent.record_metric(
376
+ "#{@config.metric_prefix}/Optimization/Errors",
377
+ 1
378
+ )
379
+ end
380
+
381
+ # Record custom event
382
+ if @config.record_custom_events
383
+ NewRelic::Agent.record_custom_event('DSPyOptimizationError', {
384
+ optimization_id: optimization_id,
385
+ optimizer: payload[:optimizer] || 'unknown',
386
+ error_message: error_message,
387
+ error_type: payload[:error_type] || 'unknown',
388
+ timestamp: Time.now.to_f
389
+ })
390
+ end
391
+
392
+ # End the transaction with error status
393
+ if transaction_info
394
+ NewRelic::Agent.add_custom_attributes({
395
+ 'dspy.optimization.status' => 'error',
396
+ 'dspy.optimization.error' => error_message
397
+ })
398
+ end
399
+
400
+ NewRelic::Agent.end_transaction
401
+ end
402
+
403
+ # LM event handlers
404
+ sig { params(event: T.untyped).void }
405
+ def handle_lm_request(event)
406
+ return unless @config.enabled && defined?(NewRelic)
407
+
408
+ payload = event.payload
409
+ provider = payload[:provider] || 'unknown'
410
+ model = payload[:gen_ai_request_model] || payload[:model] || 'unknown'
411
+ status = payload[:status] || 'success'
412
+
413
+ if @config.record_custom_metrics
414
+ # Record LM request metrics
415
+ NewRelic::Agent.record_metric(
416
+ "#{@config.metric_prefix}/LM/Requests",
417
+ 1
418
+ )
419
+
420
+ if payload[:duration_ms]
421
+ NewRelic::Agent.record_metric(
422
+ "#{@config.metric_prefix}/LM/Duration",
423
+ payload[:duration_ms]
424
+ )
425
+ end
426
+
427
+ if payload[:tokens_total]
428
+ NewRelic::Agent.record_metric(
429
+ "#{@config.metric_prefix}/LM/Tokens/Total",
430
+ payload[:tokens_total]
431
+ )
432
+ end
433
+
434
+ if payload[:tokens_input]
435
+ NewRelic::Agent.record_metric(
436
+ "#{@config.metric_prefix}/LM/Tokens/Input",
437
+ payload[:tokens_input]
438
+ )
439
+ end
440
+
441
+ if payload[:tokens_output]
442
+ NewRelic::Agent.record_metric(
443
+ "#{@config.metric_prefix}/LM/Tokens/Output",
444
+ payload[:tokens_output]
445
+ )
446
+ end
447
+
448
+ if payload[:cost]
449
+ NewRelic::Agent.record_metric(
450
+ "#{@config.metric_prefix}/LM/Cost",
451
+ payload[:cost]
452
+ )
453
+ end
454
+
455
+ if status == 'error'
456
+ NewRelic::Agent.record_metric(
457
+ "#{@config.metric_prefix}/LM/Errors",
458
+ 1
459
+ )
460
+ end
461
+ end
462
+
463
+ # Record custom event
464
+ if @config.record_custom_events
465
+ NewRelic::Agent.record_custom_event('DSPyLMRequest', {
466
+ provider: provider,
467
+ model: model,
468
+ status: status,
469
+ duration_ms: payload[:duration_ms],
470
+ tokens_total: payload[:tokens_total],
471
+ tokens_input: payload[:tokens_input],
472
+ tokens_output: payload[:tokens_output],
473
+ cost: payload[:cost],
474
+ error_message: payload[:error_message],
475
+ timestamp: Time.now.to_f
476
+ })
477
+ end
478
+ end
479
+
480
+ sig { params(event: T.untyped).void }
481
+ def handle_prediction(event)
482
+ return unless @config.enabled && defined?(NewRelic)
483
+
484
+ payload = event.payload
485
+ status = payload[:status] || 'success'
486
+
487
+ if @config.record_custom_metrics
488
+ NewRelic::Agent.record_metric(
489
+ "#{@config.metric_prefix}/Predict/Requests",
490
+ 1
491
+ )
492
+
493
+ if payload[:duration_ms]
494
+ NewRelic::Agent.record_metric(
495
+ "#{@config.metric_prefix}/Predict/Duration",
496
+ payload[:duration_ms]
497
+ )
498
+ end
499
+
500
+ if status == 'error'
501
+ NewRelic::Agent.record_metric(
502
+ "#{@config.metric_prefix}/Predict/Errors",
503
+ 1
504
+ )
505
+ end
506
+ end
507
+ end
508
+
509
+ sig { params(event: T.untyped).void }
510
+ def handle_chain_of_thought(event)
511
+ return unless @config.enabled && defined?(NewRelic)
512
+
513
+ payload = event.payload
514
+ status = payload[:status] || 'success'
515
+
516
+ if @config.record_custom_metrics
517
+ NewRelic::Agent.record_metric(
518
+ "#{@config.metric_prefix}/ChainOfThought/Requests",
519
+ 1
520
+ )
521
+
522
+ if payload[:duration_ms]
523
+ NewRelic::Agent.record_metric(
524
+ "#{@config.metric_prefix}/ChainOfThought/Duration",
525
+ payload[:duration_ms]
526
+ )
527
+ end
528
+
529
+ if payload[:reasoning_steps]
530
+ NewRelic::Agent.record_metric(
531
+ "#{@config.metric_prefix}/ChainOfThought/ReasoningSteps",
532
+ payload[:reasoning_steps]
533
+ )
534
+ end
535
+
536
+ if status == 'error'
537
+ NewRelic::Agent.record_metric(
538
+ "#{@config.metric_prefix}/ChainOfThought/Errors",
539
+ 1
540
+ )
541
+ end
542
+ end
543
+ end
544
+
545
+ # Storage event handlers
546
+ sig { params(event: T.untyped, operation: String).void }
547
+ def handle_storage_operation(event, operation)
548
+ return unless @config.enabled && defined?(NewRelic)
549
+
550
+ payload = event.payload
551
+
552
+ if @config.record_custom_metrics
553
+ NewRelic::Agent.record_metric(
554
+ "#{@config.metric_prefix}/Storage/#{operation.capitalize}",
555
+ 1
556
+ )
557
+
558
+ if payload[:duration_ms]
559
+ NewRelic::Agent.record_metric(
560
+ "#{@config.metric_prefix}/Storage/Duration",
561
+ payload[:duration_ms]
562
+ )
563
+ end
564
+
565
+ if payload[:size_bytes]
566
+ NewRelic::Agent.record_metric(
567
+ "#{@config.metric_prefix}/Storage/SizeBytes",
568
+ payload[:size_bytes]
569
+ )
570
+ end
571
+ end
572
+ end
573
+
574
+ # Registry event handlers
575
+ sig { params(event: T.untyped, operation: String).void }
576
+ def handle_registry_operation(event, operation)
577
+ return unless @config.enabled && defined?(NewRelic)
578
+
579
+ payload = event.payload
580
+
581
+ if @config.record_custom_metrics
582
+ NewRelic::Agent.record_metric(
583
+ "#{@config.metric_prefix}/Registry/#{operation.capitalize}",
584
+ 1
585
+ )
586
+
587
+ if payload[:duration_ms]
588
+ NewRelic::Agent.record_metric(
589
+ "#{@config.metric_prefix}/Registry/Duration",
590
+ payload[:duration_ms]
591
+ )
592
+ end
593
+ end
594
+
595
+ # Record custom event
596
+ if @config.record_custom_events
597
+ NewRelic::Agent.record_custom_event("DSPyRegistry#{operation.capitalize}", {
598
+ signature_name: payload[:signature_name],
599
+ version: payload[:version],
600
+ performance_score: payload[:performance_score],
601
+ timestamp: Time.now.to_f
602
+ })
603
+ end
604
+ end
605
+
606
+ sig { params(event: T.untyped).void }
607
+ def handle_auto_deployment(event)
608
+ return unless @config.enabled && defined?(NewRelic)
609
+
610
+ payload = event.payload
611
+
612
+ if @config.record_custom_metrics
613
+ NewRelic::Agent.record_metric(
614
+ "#{@config.metric_prefix}/Registry/AutoDeployments",
615
+ 1
616
+ )
617
+ end
618
+
619
+ if @config.record_custom_events
620
+ NewRelic::Agent.record_custom_event('DSPyAutoDeployment', {
621
+ signature_name: payload[:signature_name],
622
+ version: payload[:version],
623
+ timestamp: Time.now.to_f
624
+ })
625
+ end
626
+ end
627
+
628
+ sig { params(event: T.untyped).void }
629
+ def handle_automatic_rollback(event)
630
+ return unless @config.enabled && defined?(NewRelic)
631
+
632
+ payload = event.payload
633
+
634
+ if @config.record_custom_metrics
635
+ NewRelic::Agent.record_metric(
636
+ "#{@config.metric_prefix}/Registry/AutoRollbacks",
637
+ 1
638
+ )
639
+ end
640
+
641
+ if @config.record_custom_events
642
+ NewRelic::Agent.record_custom_event('DSPyAutoRollback', {
643
+ signature_name: payload[:signature_name],
644
+ current_score: payload[:current_score],
645
+ previous_score: payload[:previous_score],
646
+ performance_drop: payload[:performance_drop],
647
+ timestamp: Time.now.to_f
648
+ })
649
+ end
650
+ end
651
+
652
+ # Helper methods
653
+ sig { params(payload: T.untyped, optimizer: String).void }
654
+ def record_optimization_metrics(payload, optimizer)
655
+ return unless @config.record_custom_metrics
656
+
657
+ if payload[:duration_ms]
658
+ NewRelic::Agent.record_metric(
659
+ "#{@config.metric_prefix}/Optimization/Duration",
660
+ payload[:duration_ms]
661
+ )
662
+ end
663
+
664
+ if payload[:best_score]
665
+ NewRelic::Agent.record_metric(
666
+ "#{@config.metric_prefix}/Optimization/BestScore",
667
+ payload[:best_score]
668
+ )
669
+ end
670
+
671
+ if payload[:trials_count]
672
+ NewRelic::Agent.record_metric(
673
+ "#{@config.metric_prefix}/Optimization/TrialsCount",
674
+ payload[:trials_count]
675
+ )
676
+ end
677
+
678
+ # Record optimizer-specific metrics
679
+ NewRelic::Agent.record_metric(
680
+ "#{@config.metric_prefix}/Optimization/Completed/#{optimizer}",
681
+ 1
682
+ )
683
+ end
684
+ end
685
+ end
686
+ end