Sashiko
Concurrency-boundary observability for Ruby on top of OpenTelemetry.
The OTel context is fiber-local. The moment work crosses a Thread, Fiber, queue, or Ractor boundary, vanilla OpenTelemetry Ruby drops the trace —spans from the spawned work become root spans, disconnected from the request that started them. Sashiko keeps the trace stitched together across every concurrency boundary Ruby has, with a small declarative DSL for instrumenting your own code.
API docs: o6lvl4.github.io/sashiko/
Status: early. API may change. Tests green in both default and
RUBY_BOX=1modes; 0 type errors.
Named after sashiko (刺し子), a Japanese stitching technique that reinforces fabric with small, deliberate stitches.
The boundary problem
Without Sashiko: With Sashiko:
[trace A] [trace A]
└─ POST /orders └─ POST /orders
├─ external_call
[trace B] ← orphan thread span 😢 ├─ external_call
└─ external_call └─ external_call
[trace C] ← orphan thread span 😢
└─ external_call
A 30-line runnable demonstration is in examples/thread_fanout_demo.rb. It prints both trace trees side-by-side from the same code.
What it gives you
-
Boundary-aware Context helpers —
Sashiko::Context.thread,.fiber,.parallel_mappreserve the OTel Context across Ruby’s in-process concurrency primitives.Sashiko::Context.carrierreturns a deep-frozen, Ractor-shareable Hash of W3C Trace Context headers that survives Sidekiq job args, Kafka message attributes, HTTP headers, andRactor.new(...)arguments —Sashiko::Context.attachreconnects the trace on the other side. -
Declarative span DSL —
extend Sashiko::Traced; trace :methodinstead oftracer.in_span { ... }blocks. Exceptions and error status are recorded automatically. -
Spans from inside a Ractor — vanilla OTel Ruby raises
Ractor::IsolationErrorontracer.in_spaninside a Ractor.Sashikorecords the work as plainSpanEventdata inside the Ractor and replays it as real OTel spans on the main Ractor, with the original timestamps and parent linkage. Caveats below. -
Per-Box (per-tenant) instrumentation — under
RUBY_BOX=1, eachSashiko::Box.newgets its ownSashikoand OTel SDK. Useful for multi-tenant processes; see the Box section for known constraints. -
Tracer DI everywhere — every public entry point accepts a
tracer:keyword that bypasses the global memoized tracer. The recommended escape hatch for routing spans through a non-default provider (Box-local, alt backend, test fixture). -
Typed — ships RBS signatures, type-checked with Steep in CI.
Sashiko is intended as a companion to the SIG-maintained opentelemetry-instrumentation-* gems, not a replacement. Use those for Rails / Sidekiq / Faraday / etc.; reach for Sashiko for the custom-code parts and the boundary handoffs they don’t cover.
When to use Sashiko
OpenTelemetry has three signal types — traces, metrics, and logs. Sashiko is tracing-only. It does not emit metrics or logs, and it is not an analytics platform.
✅ Use Sashiko if you have:
-
A Ruby backend (Rails / Sidekiq / Hanami / custom workers) using OpenTelemetry, or planning to.
-
Code that spawns parallel work via
Thread/Fiber/parallel_map/Ractor, and you want the spans to stay connected to the parent request. -
Sidekiq / ActiveJob / Kafka / custom queue work where you want the trace continuous across the queue boundary.
-
Ractor-based CPU-bound work that you want traced (vanilla OTel raises
Ractor::IsolationErrorwhen you try to emit spans from inside a Ractor; Sashiko’s span replay handles it). -
Multi-tenant Ruby processes via
Ruby::Boxwhere each tenant should get its own OTel pipeline.
❌ Don’t use Sashiko for:
-
Web / mobile analytics (page views, conversions, user cohorts) — use Google Analytics, Mixpanel, Amplitude. OpenTelemetry traces are not analytics events.
-
Application metrics (counters, gauges, histograms, real-time dashboards) — use OpenTelemetry metrics + Prometheus / Grafana.
-
Application logs — use lograge / structured logging / OTel logs.
Sashikodoes not handle logs. -
Frameworks that already have first-party OTel support (Rails, Sidekiq, Faraday, gRPC, …) — pick up the corresponding
opentelemetry-instrumentation-*SIG gem instead. UseSashikofor your own code that lives next to those frameworks.
The shortest discriminator: if your question is “why did this particular request take 3 seconds and where was the time spent”, Sashiko is in scope. If your question is “how many users converted last week” or “what is our p99 latency over time”, it isn’t.
Requirements
-
Ruby 4.0 or later (4.0.0 shipped 2025-12-25)
-
opentelemetry-api~> 1.4 -
opentelemetry-sdk~> 1.5
Ruby::Box and Ractor::Port-backed features need Ruby 4.0+. Box is opt-in via RUBY_BOX=1 and is flagged experimental upstream — see Misc #21681. The Thread, Fiber, queue, and HTTP boundary helpers work on any Ruby 3.x tested release, but the gem currently targets 4.0+ only because adapters use Data.define and other 3.2+ idioms.
Quick start
# Gemfile gem "sashiko", "~> 0.1" gem "opentelemetry-sdk" gem "opentelemetry-exporter-otlp"
(While 0.x.x, expect the API to change. Pin to a patch version if you want zero surprises. Pre-1.0 follows Keep-a-Changelog style — see CHANGELOG.md for migration notes between releases.)
# config/initializers/otel.rb require "opentelemetry/sdk" require "opentelemetry/exporter/otlp" OpenTelemetry::SDK.configure { it.service_name = "my-app" } require "sashiko"
class OrderService extend Sashiko::Traced trace :checkout, attributes: ->(order) { { "order.id" => order.id } } def checkout(order) charge(order) notify(order) end trace :charge trace :notify def charge(order); ...; end def notify(order); ...; end end
Every call to checkout produces a span named OrderService#checkout, with charge and notify as children. Exceptions are recorded and the span is marked errored automatically.
Core API
Sashiko::Traced — declarative spans
class Svc extend Sashiko::Traced # Wrap a single method. trace :work, attributes: ->(x) { { "work.id" => x.id } } # Wrap every method matching a pattern (declare AFTER the defs). trace_all matching: /^handle_/ end
trace options: - name: — override the span name (defaults to ClassName#method). - kind: — :internal (default), :client, :server, etc. - attributes: — a Proc receiving call args, or a static Hash. - record_args: true — include arg count as code.args.count.
Implementation: one anonymous module is prepended per target class at trace time; subsequent trace calls redefine methods on the same overlay, keeping super chains intact.
Sashiko::Context — propagation across Thread / Fiber
# Thread.new that preserves the current OTel Context. Sashiko::Context.thread { do_work }.join # Fiber.new likewise. Sashiko::Context.fiber { do_work }.resume # Fan-out helper: one thread per item, all with the captured Context, # results returned in input order. results = Sashiko::Context.parallel_map(jobs) { |j| process(j) }
Without these helpers, plain Thread.new drops the OTel Context and your spans become orphans.
Sashiko::Context.carrier — across processes, queues, Ractors
The same primitive works for any boundary where you can pass strings:
# Producer side: capture the current trace context as a serializable Hash. queue.push(payload: "...", trace_context: Sashiko::Context.carrier) # Worker side: re-attach it before doing traced work. job = queue.pop Sashiko::Context.attach(job[:trace_context]) do process(job) end
carrier is a deep-frozen, Ractor-shareable Hash of W3C Trace Context headers (traceparent, tracestate). It survives JSON serialization, Sidekiq job args, Kafka message attributes, HTTP headers, and Ractor.new(...) arguments.
propagation across Web → Sidekiq → external API
Sashiko::Ractor — parallel execution with span replay
For CPU-bound work that should actually use multiple cores:
module PrimePipeline def self.run(upper_bound) candidates = Sashiko::Ractor.span("enumerate") { (2..upper_bound).to_a } primes = Sashiko::Ractor.span("sieve") { candidates.select { |i| (2..Math.sqrt(i)).none? { |d| i % d == 0 } } } Sashiko::Ractor.span("summarize", attributes: { "prime.count" => primes.length }) { primes.last } end end Sashiko.tracer.in_span("main.batch") do Sashiko::Ractor.parallel_map([5_000, 10_000, 15_000], via: PrimePipeline.method(:run)) end
How it works:
-
Each item runs in its own Ractor (true parallelism, no GVL).
-
Inside each Ractor,
Sashiko::Ractor.span(...)records a frozenSpanEvent(name, start/end ns, attributes, parent event id) — no OTel calls, because those don’t work inside a Ractor. -
When the Ractor finishes, the batch of events is sent back via
Ractor::Portto the main Ractor. -
A
Sashiko::Ractor::Sinkon the main side callstracer.start_span(..., start_timestamp: …)/span.finish(end_timestamp: …)for each event, rebuilding the parent chain from the recorded event ids, all under the context of the span that wrappedparallel_map.
Resulting tree:
main.batch (20ms) ← main Ractor ├─ PrimePipeline.run (7.5ms) [item.index=0] ← recorded inside a Ractor │ ├─ enumerate ← ← nested Ractor-side span │ ├─ sieve │ └─ summarize ├─ PrimePipeline.run (13.5ms) [item.index=1] │ └─ ... └─ PrimePipeline.run (19.7ms) [item.index=2] └─ ...
span replay sequence
bundle exec ruby examples/ractor_span_replay_demo.rb
Caveats — what “replay” does and doesn’t preserve:
-
trace_id/span_idare assigned by the main-side tracer at replay time; the Ractor never sees a realSpanContext. Parent linkage inside the replayed batch is correct, and the batch’s root attaches to whatever main-side context wrappedparallel_map. -
OpenTelemetry::Baggageset inside the Ractor is not propagated out; only what’s inSashiko::Context.carrieratparallel_maptime reaches the replay. -
Sampling is decided at replay time on the main side, not when the work actually ran.
-
Constraints:
via:must be aMethodwhose receiver is Ractor-shareable (aModuleor frozen class). Nested spans inside the Ractor must useSashiko::Ractor.span—tracer.in_spanwould crash.
For threads, prefer Sashiko::Context.thread / parallel_map — Ractor replay is for genuine multi-core CPU work.
Sashiko::Box — per-Box instrumentation (Ruby 4, experimental)
Vanilla Module#prepend-based instrumentation is process-global: once you patch Anthropic::Messages for tenant A, tenant B inherits the patch. Ruby::Box provides a separate loading namespace, so each Box can have its own instrumented classes, OTel tracer provider, and exporter.
# Run with: RUBY_BOX=1 bundle exec ruby your_script.rb tenant_a = Sashiko::Box.new tenant_a.eval(<<~RUBY) OpenTelemetry::SDK.configure { |c| c.service_name = "tenant-a" } # ... tenant-a's business code + instrumentation ... RUBY tenant_b = Sashiko::Box.new tenant_b.eval(<<~RUBY) OpenTelemetry::SDK.configure { |c| c.service_name = "tenant-b" } RUBY
Sashiko::Box.new creates a Ruby::Box with Sashiko already required inside. It raises NotEnabledError if the process wasn’t started with RUBY_BOX=1. For a bare box without Sashiko, use Ruby::Box.new directly.
Inside a Box, pass a
tracer:explicitly. Ruby::Box does isolateOpenTelemetry.tracer_provider(each Box has its own object). The reasonSashiko.tracerdoesn’t follow it is that the method is defined under arespond_to?(:tracer)guard at require time — when sashiko is re-required inside a Box, the guard skips redefinition, so the existing method body (whose constant resolution scope was main’s) keeps returning main’s tracer. The behavior is reproducible inexamples/talk/06_box_otel_pollution.rb.Every place
Sashikoemits a span accepts an explicittracer:that bypasses the default lookup:tracer = OpenTelemetry.tracer_provider.tracer("my-component") # Per-method trace :foo, tracer: tracer # Or every method trace_all matching: /^handle_/, tracer: tracer # Adapters f.use Sashiko::Adapters::Faraday::Middleware, tracer: tracer Sashiko::Adapters::Anthropic.instrument!(Anthropic::Messages, tracer: tracer) # Ractor Sashiko::Ractor.parallel_map(items, via: M.method(:run), tracer: tracer)
Sashiko::Adapters::Anthropic.instrument_in_box!does this binding for you automatically when called with a Box.
multi-tenant isolation
For instrumenting a specific class only inside a box:
box = Sashiko::Box.new box.eval('require "anthropic"') Sashiko::Adapters::Anthropic.instrument_in_box!(box, "Anthropic::Messages")
Caveats: Ruby::Box is experimental in Ruby 4.0. Known upstream issues include native-extension loading, bundler/inline, and parts of active_support failing under Box. See Misc #21681 for the roadmap.
See examples/box_multitenant_demo.rb for a runnable demo.
Adapters
Adapters are not loaded by default — require them explicitly. The core gem has zero vendor-specific code.
Rails
require "sashiko/rails" Sashiko::Rails.install!(notifications: /^my_app\./)
Rails companion that fills gaps left by SIG’s opentelemetry-instrumentation-rails:
-
Sashiko::Rails.async("name") { ... }— spawn a Thread that preserves OTel context (orphans-be-gone forThread.newin controllers). -
include Sashiko::Rails::TracedJobinApplicationJob— ride the trace carrier across any ActiveJob backend (Sidekiq, GoodJob, SolidQueue, etc.). -
Sashiko::Rails.bridge_notifications(/regex/)— turn matchingActiveSupport::Notificationsevents into OTel spans.
Full walkthrough: docs/rails_integration.md. None of this monkey-patches Rails — pieces are independent and opt-in.
Faraday
require "sashiko/adapters/faraday" conn = Faraday.new("https://api.example.com") do |f| f.use Sashiko::Adapters::Faraday::Middleware end
Produces client-kind spans named after the HTTP method (GET, POST, etc.) per OTel HTTP semantic conventions, with the standard request / response attributes (http.request.method, url.full, server.address, server.port, http.response.status_code, error.type).
Anthropic (optional, may move to a separate gem)
require "sashiko/adapters/anthropic" Sashiko::Adapters::Anthropic.instrument!(Anthropic::Messages)
Produces GenAI semantic-convention spans on every messages.create call, including token counts, cache hit ratio, and an estimated USD cost. Pricing is a frozen Data.define(Price) value, deep-frozen at load time; override via Sashiko::Adapters::Anthropic.pricing =.
The Anthropic adapter is outside Sashiko’s core “concurrency-boundary observability” scope and is shipped here as a working reference. It may be extracted into a
sashiko-anthropicgem in a future release; any move will be announced in the CHANGELOG with a deprecation notice. Model names, pricing, and the GenAI semantic conventions are still moving targets — treat this adapter as a convenience, not a stable contract.
Types
bundle exec rake typecheck
.......... No type error detected. 🫖
If you embed Sashiko in your own Steep project, add the sig path:
# Steepfile target :app do signature "sig" signature "vendor/bundle/ruby/4.0.0/gems/sashiko-*/sig" check "app" end
Examples
The full list with one-line descriptions and a quick-reference shell block is in examples/README.md. Highlights:
-
examples/thread_fanout_demo.rb— before/after ofThread.newlosing OTel context vs.Sashiko::Context.parallel_mapkeeping it. Start here. -
examples/queue_demo.rb— producer enqueues jobs; workers continue the same distributed trace. -
examples/ractor_span_replay_demo.rb— each Ractor records nested spans; the main side reconstructs them as one trace tree. -
examples/box_multitenant_demo.rb— two tenants share one Ruby process. RequiresRUBY_BOX=1.
Development
bundle install bundle exec rake test # run tests bundle exec rake docs # generate RDoc to doc/
Appendix: Ruby version targeting
Sashiko targets Ruby 4.0+ today. The boundary helpers (Context.thread/fiber/parallel_map/carrier/attach) and the Traced DSL would also work on Ruby 3.2+ — the floor is set by two specifically-4.0 capabilities and a handful of 3.x idioms used throughout the codebase.
Ruby 4.0-specific:
| Feature | Where in Sashiko |
|---|---|
Ractor::Port |
Sashiko::Ractor.parallel_map — Port-based result collection. |
Ruby::Box |
Sashiko::Box.new and Sashiko::Adapters::Anthropic.instrument_in_box! — per-Box instrumentation. Tests run in both default and RUBY_BOX=1 modes in CI. |
Ruby 3.x idioms applied where they help:
| Feature | Where in Sashiko |
|---|---|
Data.define (3.2+) |
Sashiko::Traced::Options, Adapters::Anthropic::Price, Ractor::SpanEvent — frozen, Ractor-shareable values. |
| Pattern matching (3.0+) | Attribute extraction in Traced, HTTP status classification in Faraday adapter, GenAI response disjunction in Anthropic adapter. |
Ractor.make_shareable (3.0+) |
Sashiko::Context.carrier returns a deep-frozen, Ractor-shareable Hash. DEFAULT_PRICING is shareable too. |
| Endless methods (3.0+), anonymous block forwarding (3.1+) | Used in lib/ where they remove a line without obscuring intent. |
it block param (3.4+) |
Used in tests and examples; lib/ keeps named block params for now since Steep does not yet recognize it. |
| RBS + Steep | sig/sashiko.rbs, type-checked in CI. |
Class-load-time Module#prepend is the only mechanism used for instrumentation; no method_added hooks, no runtime define_method at call time. Inline caches stay warm.
License
MIT.