You've built a checkout reactor that reserves inventory, charges a card, generates a shipping label, and sends a confirmation email. It runs through Sidekiq. When something fails, compensation logic rolls it back. It works.
Then your team asks: "How many checkouts failed this week? Which step? How long does the charge step take at p99? Can we see a trace through the entire system?"
Before v0.5.0, you'd need to add logging calls to every step, build a custom Sidekiq middleware, and figure out how to correlate traces across async job boundaries. Now it's one line of config.
Enter Middlewares
Ruby Reactor 0.5.0 introduces a middleware pipeline — the same pattern that powers Rack, but designed for saga execution. A middleware is a plain Ruby object that hooks into the reactor lifecycle:
class TimingMiddleware < RubyReactor::Middleware
def initialize(**options)
super
@started = {}
end
def on_start_step(step_name, _arguments, _context)
@started[step_name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def on_complete_step(step_name, _result, _context)
started = @started.delete(step_name)
return unless started
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
logger.info("step #{step_name} took #{elapsed.round(4)}s")
end
end
This middleware times every step. Register it globally:
RubyReactor.configure do |config|
config.middlewares = [TimingMiddleware]
end
Now every reactor — every checkout, every refund, every data import — gets step-level timing, for free.
The full lifecycle (20+ events)
Middlewares can observe the complete execution lifecycle:
| Phase | Events |
|---|---|
| Reactor |
on_start_reactor, on_complete_reactor, on_failed_reactor
|
| Step |
on_start_step, on_complete_step, on_failed_step, on_retry_attempt
|
| Compensation |
on_start_compensation, on_complete_compensation, on_failed_compensation
|
| Undo |
on_start_undo, on_complete_undo, on_failed_undo
|
| Coordination |
on_lock_acquired, on_lock_failed, on_semaphore_acquired, … |
| Async | on_before_async_enqueue |
You can build custom logging, metrics, audit trails, and alerting — all without touching your reactor code.
Why this matters
- Separation of concerns — business logic lives in steps, observability lives in middlewares
- Composability — stack multiple middlewares (timing + logging + alerting) like Lego
- Safety — middleware errors are caught and logged, never crash your reactor
-
Per-reactor override — declare
middleware AuditMiddleware, level: :infoon specific reactors
OpenTelemetry: Distributed Tracing, Zero Config
The most powerful middleware ships built-in: RubyReactor::OpenTelemetry.
One line of configuration:
RubyReactor.configure do |config|
config.middlewares = [RubyReactor::OpenTelemetry]
end
That's it. Every reactor run becomes a full OpenTelemetry trace:
CheckoutReactor (span)
├── step.reserve_inventory (span)
├── step.charge_card (span)
│ └── step.charge_card.enqueue (span) ← async hand-off
├── step.generate_label (span)
└── step.send_confirmation (span)
If a step fails and compensation runs:
├── step.charge_card (span, ERROR)
├── compensate.charge_card (span)
└── undo.reserve_inventory (span)
Async boundaries? Handled.
When a reactor hands work to a Sidekiq worker — an async step, a retry, or a map element — the middleware automatically injects the trace context into the serialized payload. The worker picks it up and continues the trace. The result is a single, connected trace across processes.
Sensitive data? Redacted.
class LoginReactor < RubyReactor::Reactor
input :email
input :password, redact: true # shows as [REDACTED] in traces
end
No secrets leaking into your observability platform.
Any exporter works
Ruby Reactor produces OpenTelemetry spans. Where they go is up to your OTel SDK configuration:
# Datadog, Honeycomb, Jaeger, Grafana — pick your exporter
OpenTelemetry::SDK.configure do |c|
c.service_name = "checkout_service"
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new
)
)
end
What else is new in the 0.5.x line
v0.5.1 — Enhanced Validations
The input DSL got streamlined. Cleaner syntax for dry-validation schemas, better error messages when validation fails.
v0.5.2 — Nonce Lock
with_ordered_lock assigns a monotonically increasing nonce to each enqueued execution. Guarantees exactly-once processing with strict FIFO ordering — perfect for ledgers and payment processing where order matters.
Enhanced Testing
New RSpec matchers for coordination primitives: be_skipped, be_locked, have_ordered_lock_next, have_ordered_lock_in_flight, be_ordered_lock_drained. Plus sidekiq_helpers and storage_reset utilities.
Why this matters for production Ruby apps
Observability is no longer optional. If you're running business workflows in production, you need to know:
- Which steps are slow? (timing middleware)
- What's failing and why? (tracing + error events)
- Is the system healthy? (metrics middleware)
- Who changed what and when? (audit middleware)
Ruby Reactor 0.5.x gives you this without pulling in external workflow engines, without custom Sidekiq middleware, and without scattering logging calls through your business logic.
Gem: gem 'ruby_reactor', '~> 0.5'
Repo: github.com/arturictus/ruby_reactor
Middleware docs: documentation/middlewares.md
If you've been putting off observability for your Sidekiq workflows, there's no longer an excuse. ⭐ the repo and give it a try.