DEV Community

Masumi Kawasaki 💭
Masumi Kawasaki 💭

Posted on

Ruby::Box Shadow Execution for Rack: Observing “Shadow Behavior” Without Changing the Production Response

Ruby 4 introduced Ruby::Box, and I built a minimal setup to run shadow execution against a Rack app—meaning the same request is evaluated a second time “behind the scenes” with an alternate implementation.

  • Real (primary path): run the production app normally and return the real response as-is
  • Shadow (secondary path): run alternate logic inside Ruby::Box using the same input and log only the diffs
  • In my “shadow universe,” I intentionally play with rules like “Y2K time,” “fixed rand,” “gyaru-style I18n,” and “/coffee returns 418” (just for visibility)

Ruby::Box docs: https://docs.ruby-lang.org/en/master/Ruby/Box.html

https://github.com/geeknees/ruby_box_shadow_universe


Background: Why run shadow execution?

When you change production behavior, there’s always a lingering fear:

  • Did I break compatibility (status / headers / body)?
  • Did exceptions or latency increase?
  • Does a bug only trigger on specific paths?

The goal of shadow execution is to evaluate “new logic” with the same inputs without changing the production response, and to create a state where you can observe differences safely.

Why Ruby::Box (vs “just extracting code”)?

Shadow execution itself can be done by extracting logic into another class.
In practice, though, once you start experimenting on the shadow side, you often want to do things like:

  • Apply monkey patches only on the shadow side (Time/Random/I18n/HTTP, etc.)
  • Allow “risky dependencies” or “experimental code” only in shadow
  • Try behavior/version differences without polluting the main app

The problem is that within a single process, constants, autoload, top-level definitions, and monkey patches can easily leak and contaminate the main world.

Ruby::Box provides a model of separation “per box” (you require/load files inside the box so their definitions live in that world).

Whether it’s “okay” to overwrite Time.now or rand in the first place is a separate discussion 😉

High-level architecture

A Rack middleware keeps the Real → Response path intact, runs Shadow asynchronously, and logs diffs.

Flow

How to run it

Ruby::Box needs to be enabled at startup via an environment variable:

bundle install
RUBY_BOX=1 bundle exec rackup -p 9292
Enter fullscreen mode Exit fullscreen mode

Implementation (key points)

1) Rack middleware: return Real, run Shadow in the background

  • Return the result of @app.call(env) as-is
  • On the shadow side, load shadow_logic.rb into a Ruby::Box, then call ShadowLogic.call
  • Compare the “shadow response” vs the “real response” and log diffs

For readability, diff collection is extracted into a small helper (status / content-type / body bytes, etc.):

def add_diff(diff, label, before, after)
  return if before == after
  diff << "#{label}: #{before.inspect} -> #{after.inspect}"
end
Enter fullscreen mode Exit fullscreen mode

Reference:
https://github.com/geeknees/ruby_box_shadow_universe/blob/main/shadow_box_middleware.rb#L87

2) Shadow logic: keep “shadow world” definitions inside a file

What you do in shadow is up to you, but common “experiment” patterns include:

  • Trying transforms / corrections / validations only in shadow
  • Adding extra observability data only in shadow
  • Applying alternate rules only in shadow (e.g. certain paths return 418)

In this repo, I include an intentionally obvious example: changing Time/Random/I18n rules only in shadow, so differences are easy to observe.

Example logs: diffs only

Shadow results are not returned to the client. Only differences are logged (which is easier to treat as observability).

127.0.0.1 - - [29/Dec/2025:15:23:27 +0900] "GET /hello HTTP/1.1" 200 - 0.0029
[shadow_box] 🌚 alternate universe detected: x-shadow-universe: nil -> "Y2K+RAND2+GYARU", body(bytes): 11 -> 123
[shadow_box] x-shadow-universe Y2K+RAND2+GYARU
[shadow_box] outside: Time.now=2025-12-29T15:23:27+09:00 rand=13
[shadow_box] inside : (computed per-request in alt_body)
[shadow_box] --- shadow report ---
req: GET /hello
at:  1999-12-31T23:59:59+09:00
rand: 2
say: こんちわ〜⭐️
original bytes: 11
Enter fullscreen mode Exit fullscreen mode

Reference:
https://github.com/geeknees/ruby_box_shadow_universe/blob/main/shadow_box_middleware.rb#L79

Operational notes / caveats

0) It’s still experimental

At runtime you’ll see a warning like:

warning: Ruby::Box is experimental, and the behavior may change in the future!
Enter fullscreen mode Exit fullscreen mode

1) Ruby::Box is not a sandbox

Ruby::Box is not OS-level isolation.
It won’t prevent external I/O (network, files, processes). For safety, shadow logic should be designed to be side-effect free whenever possible.

2) Shadow execution adds cost

It increases per-request work. In practice, shadow is often used with:

  • Sampling (only a percentage of requests)
  • Path-based targeting
  • Time limits (timeouts)
  • Async execution (threads, job queue, etc.)

Summary

  • A Rack middleware can provide a solid base for shadow execution
  • Ruby::Box makes it easier to define/override behavior only in the shadow world
  • Because you keep the production response unchanged, you can introduce changes progressively while observing diffs

Repo: https://github.com/geeknees/ruby_box_shadow_universe

Top comments (0)