Event System
DSPy.rb ships with a structured event bus so agents, tooling, and monitoring stacks can react to everything that happens at runtime. This page explains how to emit events, listen globally, scope listeners to specific modules, and cleanly tear everything down.
Emitting Events
Use DSPy.event whenever something noteworthy happens:
DSPy.event('chain_of_thought.reasoning_complete', {
question: question,
reasoning_steps: reasoning.count("\n") + 1
})
Key points:
- Event names are strings with dot-separated namespaces (
llm.generate,react.iteration_complete, etc.). - Attributes must be JSON-serializable. DSPy automatically merges context (trace ID, module stack) and emits OpenTelemetry spans unless the event is marked internal.
- Typed events (
DSPy::Events::LLMEvent,DSPy::Events::OptimizationEvent, etc.) can be passed instead of raw strings; the event bus extracts their attributes and spans for you.
Global Listeners
To react to every event (the question everyone asks), subscribe directly to the registry:
DSPy.events.subscribe('*') do |event_name, attrs|
puts "[#{event_name}] tokens=#{attrs[:total_tokens]}"
end
Notes:
- Wildcards (
llm.*) are supported. An exact string listens to one event. DSPy.events.unsubscribe(id)removes a listener by subscription ID.DSPy.events.clear_listenersis handy in tests to avoid cross-contamination.
For richer lifecycle management inherit from DSPy::Events::BaseSubscriber:
class TokenBudgetTracker < DSPy::Events::BaseSubscriber
def initialize(budget:)
super()
@budget = budget
@usage = 0
subscribe
end
def subscribe
add_subscription('lm.tokens') do |_event, attrs|
@usage += attrs.fetch(:total_tokens, 0)
warn("Budget hit") if @usage >= @budget
end
end
end
Call subscriber.unsubscribe when you are done.
Module-Scoped Subscribers
Every DSPy::Module can now declare listeners that automatically scope to its instance (and, by default, all descendants invoked inside it). Here is a grounded multi-module agent:
class OutlineSignature < DSPy::Signature
description "Identify report sections"
input { const :question, String }
output { const :sections, T::Array[String] }
end
class SectionWriterSignature < DSPy::Signature
description "Write a section paragraph"
input do
const :question, String
const :section_title, String
end
output { const :paragraph, String }
end
class ResearchReport < DSPy::Module
subscribe 'lm.tokens', :track_tokens # default scope: descendants
def initialize
super
@outliner = DSPy::Predict.new(OutlineSignature)
@section_writer = DSPy::Predict.new(SectionWriterSignature)
@token_count = 0
end
def forward(question:)
outline = @outliner.call(question: question)
outline.sections.map do |section_title|
draft = @section_writer.call(
question: question,
section_title: section_title
)
{ title: section_title, body: draft.paragraph }
end
end
def track_tokens(_event, attrs)
@token_count += attrs.fetch(:total_tokens, 0)
end
end
Because the subscribe call does not specify a scope, it listens to events emitted by the ResearchReport module and both nested Predict instances. You only need to opt into scope: DSPy::Module::SubcriptionScope::SelfOnly when you truly want to ignore descendants (for example, to log only the parent module’s own search.result events).
Additional details:
DSPy::Module::SubcriptionScope::Descendantsis the default and covers the module plus nested modules.SubcriptionScope::SelfOnlyrestricts delivery to the module instance itself.- Instance methods
registered_module_subscriptionsandunsubscribe_module_eventsallow inspection and teardown (useful in long-running services or tests). - DSPy merges module metadata into every event (
module_path,module_leaf,module_scope.ancestry_token, etc.). Listeners can filter manually by inspecting those keys.
Module Stack Metadata
The context layer tracks a stack of modules whenever DSPy::Module#forward runs. Each entry contains:
id: stable UUID per instance (safe across forks)class: module class namelabel: optional label (set viamodule_scope_label=or derived from named predictors)
Events include:
{
module_path: [
{id: "root_uuid", class: "DeepSearch", label: nil},
{id: "planner_uuid", class: "DSPy::Predict", label: "planner"}
],
module_root: {...},
module_leaf: {...},
module_scope: {
ancestry_token: "root_uuid>planner_uuid",
depth: 2
}
}
Use this metadata to power Langfuse filters, scoped metrics, or custom routing.
Best Practices
- Global observability? Mount a single
'*'listener that forwards events to your logging/metrics pipeline. Use module metadata to fan out to feature-specific sinks. - Tight modules? Prefer the per-module
subscribeDSL so subscriptions live with the logic they instrument. Callunsubscribe_module_eventsin teardown hooks (e.g., when a job finishes) to prevent leaks. - Testing: Clear global listeners in
before/afterblocks and assert on collected events. For module-scoped specs, instantiate the module and inspectregistered_module_subscriptions. - Versioning: These APIs ship in the main
dspygem. Upgrading the gem automatically brings the event bus and module listener features into every sub-gem (dspy-code_act,dspy-o11y, etc.) because they depend on the core runtime.
With the event system in place you can observe anything—from token usage and Langfuse traces to custom domain signals—without scattering instrumentation across your agents.