Event System vs Monkey-Patching

Subscribe to DSPy.rb events when you need custom metrics or logs. The public event API avoids replacing logger internals or patching module methods.

Identify the Patch Boundary

Replacing logger backends reaches into internals that the public event API already exposes:

Replace a Logger Backend

class EventInterceptorBackend < Dry::Logger::Backends::Stream
  def call(entry)
    if handler = @event_handlers[entry[:event]]
      handler.call(entry)
    end
    super
  end
end

DSPy.configure do |config|
  config.logger = Dry.Logger(:dspy) do |logger|
    logger.add_backend(EventInterceptorBackend.new(stream: "log/production.log"))
  end
end

Subscribe Through the Event API

The event system exposes subscriptions without replacing logger or module internals:

Token Cost Tracking

class TokenCostTracker
  def initialize
    @costs = Hash.new(0.0)
    @subscriptions = []
    @subscriptions << DSPy.events.subscribe('llm.*') do |event_name, attributes|
      model = attributes['gen_ai.request.model']
      input_tokens = attributes['gen_ai.usage.prompt_tokens'] || 0
      output_tokens = attributes['gen_ai.usage.completion_tokens'] || 0

      cost = calculate_cost(model, input_tokens, output_tokens)
      @costs[model] += cost

      puts "#{model}: $#{cost.round(4)} (total: $#{@costs[model].round(2)})"
    end
  end

  def unsubscribe
    @subscriptions.each { |id| DSPy.events.unsubscribe(id) }
    @subscriptions.clear
  end
end

tracker = TokenCostTracker.new
# Tracks subscribed llm.* events until tracker.unsubscribe is called.

Rate Limiting

class RateLimiter
  def initialize(limit: 100)
    @requests = Hash.new(0)
    @limit = limit
    @subscriptions = []
    @subscriptions << DSPy.events.subscribe('llm.generate') do |event_name, attributes|
      model = attributes['gen_ai.request.model']
      key = "#{model}:#{Time.now.to_i / 60}"

      @requests[key] += 1
      if @requests[key] > @limit
        DSPy.event('rate_limit.exceeded', model: model, count: @requests[key])
      end
    end
  end

  def unsubscribe
    @subscriptions.each { |id| DSPy.events.unsubscribe(id) }
    @subscriptions.clear
  end
end

Audit Logging

class AuditLogger
  def initialize
    @subscriptions = []
    @subscriptions << DSPy.events.subscribe('llm.*') do |event_name, attributes|
      AuditLog.create!(
        event: event_name,
        model: attributes['gen_ai.request.model'],
        tokens: attributes['gen_ai.usage.total_tokens'],
        user_id: Current.user&.id,
        timestamp: Time.current
      )
    end
  end

  def unsubscribe
    @subscriptions.each { |id| DSPy.events.unsubscribe(id) }
    @subscriptions.clear
  end
end

Compare the Supported Boundaries

Event API properties

  1. Public entry point: DSPy.events.subscribe() is explicit and searchable
  2. Subscriber isolation: Subscribers can be tested without patching module internals
  3. Type Safe: Sorbet T::Struct validation for event structures
  4. Thread Safe: Built-in concurrency protection
  5. Error Isolated: Failing listeners don’t break others
  6. No custom backend: Subscription does not require an application-defined logger backend

Monkey-patch liabilities

  1. Hidden behavior: Prepend modules are invisible in code
  2. Testing complexity: Hard to test interceptors in isolation
  3. Fragility: Breaks when internal APIs change
  4. Performance overhead: Every call goes through override chain
  5. Debugging difficulty: Stack traces become confusing

Replace a Logger Patch

Before: replace the backend

module ContextInterceptor
  def with_span(operation:, **attributes)
    super  
  end
end
DSPy::Context.singleton_class.prepend(ContextInterceptor)

After: subscribe to the event

class MyTracker
  def initialize
    @subscriptions = []
    @subscriptions << DSPy.events.subscribe('llm.*') { |name, attrs| handle_event(attrs) }
  end

  def unsubscribe
    @subscriptions.each { |id| DSPy.events.unsubscribe(id) }
    @subscriptions.clear
  end
end

tracker = MyTracker.new

Choose an Interception Boundary

Use the event system

  • Token tracking and budget management
  • Custom analytics and reporting
  • Integration with external services
  • Performance monitoring
  • User-facing observability features

Patch only an unavailable internal hook

  • Modify an internal behavior the event API does not expose.
  • Treat the patch as version-coupled application code and test it against upgrades.

Inspect Tested Subscribers

See working implementations:

  • spec/unit/event_system_spec.rb - Thread safety tests
  • spec/unit/event_subscribers_spec.rb - Subscriber patterns
  • spec/support/event_subscriber_examples.rb - Complete implementations
  • examples/event_system_demo.rb - Live demonstration