Event System
DSPy.rb provides a structured event bus for module instrumentation and integrations. Emit events, subscribe globally or by module scope, and unsubscribe when the listener’s lifetime ends.
Two Subscription Patterns
Choose a subscription scope by listener lifetime:
Pattern 1: Module-Scoped Subscriptions
Use the subscribe DSL inside your modules. Subscriptions automatically scope to the module instance and its descendants:
class MyAgent < DSPy::Module
subscribe 'lm.tokens', :track_tokens, scope: :descendants
def track_tokens(_event, attrs)
@total_tokens += attrs.fetch(:total_tokens, 0)
end
end
When to use: Modules with internal state or any listener whose lifetime should match a module instance.
Pattern 2: Global Subscriptions (For Observability/Integrations)
Use DSPy.events.subscribe directly for cross-cutting concerns:
subscription_id = DSPy.events.subscribe('score.create') do |event, attrs|
Langfuse.export_score(attrs)
end
When to use: Observability exporters (Langfuse, Datadog), centralized logging, metrics collection, or any cross-cutting concern that spans multiple modules.
Emitting Events
Call DSPy.event for application events that subscribers should receive:
DSPy.event('chain_of_thought.reasoning_complete', {
question: question,
reasoning_steps: reasoning.count("\n") + 1
})
Event naming, attributes, and types:
- 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 receive every event, subscribe directly to the registry:
DSPy.events.subscribe('*') do |event_name, attrs|
puts "[#{event_name}] tokens=#{attrs[:total_tokens]}"
end
Wildcard and teardown rules:
- Wildcards (
llm.*) are supported. An exact string listens to one event. DSPy.events.unsubscribe(id)removes a listener by subscription ID.- Use
DSPy.events.clear_listenersin tests to avoid cross-contamination.
For custom tracking, create a class that manages subscriptions:
class TokenBudgetTracker
def initialize(budget:)
@budget = budget
@usage = 0
@subscriptions = []
subscribe
end
def subscribe
@subscriptions << DSPy.events.subscribe('lm.tokens') do |_event, attrs|
@usage += attrs.fetch(:total_tokens, 0)
warn("Budget hit") if @usage >= @budget
end
end
def unsubscribe
@subscriptions.each { |id| DSPy.events.unsubscribe(id) }
@subscriptions.clear
end
end
Call tracker.unsubscribe when you are done.
Module-Scoped Subscribers
Every DSPy::Module can declare listeners scoped to its instance and, by default, descendants invoked inside it:
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. Use scope: DSPy::Module::SubcriptionScope::SelfOnly to ignore descendants, for example when logging only the parent module’s own search.result events.
Scope and metadata rules:
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.
Choose Listener Ownership and Teardown
- Global observability: Mount a single
'*'listener that forwards events to your logging or metrics pipeline. Use module metadata to fan out to feature-specific sinks. - Module-owned instrumentation: Use the per-module
subscribeDSL so subscriptions live with the logic they instrument. Callunsubscribe_module_eventsin teardown hooks, such as 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.
The event bus separates module behavior from token accounting, tracing exports, and custom domain instrumentation.