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

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

Source: Dev.to

Background: Why run shadow execution? ## Why Ruby::Box (vs “just extracting code”)? ## High-level architecture ## How to run it ## Implementation (key points) ## 1) Rack middleware: return Real, run Shadow in the background ## 2) Shadow logic: keep “shadow world” definitions inside a file ## Example logs: diffs only ## Operational notes / caveats ## 0) It’s still experimental ## 1) Ruby::Box is not a sandbox ## 2) Shadow execution adds cost ## Summary 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. Ruby::Box docs: https://docs.ruby-lang.org/en/master/Ruby/Box.html https://github.com/geeknees/ruby_box_shadow_universe When you change production behavior, there’s always a lingering fear: 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. 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: 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 😉 A Rack middleware keeps the Real → Response path intact, runs Shadow asynchronously, and logs diffs. Ruby::Box needs to be enabled at startup via an environment variable: For readability, diff collection is extracted into a small helper (status / content-type / body bytes, etc.): Reference: https://github.com/geeknees/ruby_box_shadow_universe/blob/main/shadow_box_middleware.rb#L87 What you do in shadow is up to you, but common “experiment” patterns include: In this repo, I include an intentionally obvious example: changing Time/Random/I18n rules only in shadow, so differences are easy to observe. Shadow results are not returned to the client. Only differences are logged (which is easier to treat as observability). Reference: https://github.com/geeknees/ruby_box_shadow_universe/blob/main/shadow_box_middleware.rb#L79 At runtime you’ll see a warning like: 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. It increases per-request work. In practice, shadow is often used with: Repo: https://github.com/geeknees/ruby_box_shadow_universe Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: bundle install RUBY_BOX=1 bundle exec rackup -p 9292 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: bundle install RUBY_BOX=1 bundle exec rackup -p 9292 CODE_BLOCK: bundle install RUBY_BOX=1 bundle exec rackup -p 9292 COMMAND_BLOCK: def add_diff(diff, label, before, after) return if before == after diff << "#{label}: #{before.inspect} -> #{after.inspect}" end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: def add_diff(diff, label, before, after) return if before == after diff << "#{label}: #{before.inspect} -> #{after.inspect}" end COMMAND_BLOCK: def add_diff(diff, label, before, after) return if before == after diff << "#{label}: #{before.inspect} -> #{after.inspect}" end COMMAND_BLOCK: 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 COMMAND_BLOCK: 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 COMMAND_BLOCK: 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 CODE_BLOCK: warning: Ruby::Box is experimental, and the behavior may change in the future! Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: warning: Ruby::Box is experimental, and the behavior may change in the future! CODE_BLOCK: warning: Ruby::Box is experimental, and the behavior may change in the future! - 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) - Did I break compatibility (status / headers / body)? - Did exceptions or latency increase? - Does a bug only trigger on specific paths? - 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 - 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 - 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) - Sampling (only a percentage of requests) - Path-based targeting - Time limits (timeouts) - Async execution (threads, job queue, etc.) - 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