Ruby Reactor Now Has Middlewares and OpenTelemetry — Here's Why That Matters

ruby dev.to

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
Enter fullscreen mode Exit fullscreen mode

This middleware times every step. Register it globally:

RubyReactor.configure do |config|
  config.middlewares = [TimingMiddleware]
end
Enter fullscreen mode Exit fullscreen mode

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: :info on 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

If a step fails and compensation runs:

├── step.charge_card (span, ERROR)
├── compensate.charge_card (span)
└── undo.reserve_inventory (span)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials