OrangeTap
OrangeTap hooks Ruby method calls (call/return) with TracePoint, assembles
them into OpenTelemetry-style spans on a background thread inside the same
process, and writes the result as an OTLP/JSON file. There is no central
daemon, no shared memory, and no inter-process communication of any kind —
tracing is entirely contained within the observed Ruby process.
Design notes
Why Ruby-level TracePoint instead of rb_add_event_hook
OrangeTap intentionally uses the Ruby-level TracePoint API rather than the
C-level rb_add_event_hook/trace_func mechanism. Ruby 4.0's C-level
trace_func has a known bug, so this gem avoids it by design and pays the
(small, and in practice dominated by the traced call itself) overhead of a
Ruby-level hook instead. Target methods are narrowed with
TracePoint#enable(target: iseq) so untargeted methods incur no hook
overhead at all.
No central daemon — everything lives in one process's Thread + Queue
Earlier iterations of this idea used a shared-memory ring buffer and an XPC
daemon process. OrangeTap drops all of that: a session is just a
Thread::Queue plus a background Thread inside the same process that
called OrangeTap.open. Correlating traces across multiple OS processes is
explicitly out of scope for this gem.
Why open/stop need no session_id
Each call to open creates a brand new Queue and worker Thread dedicated
to that session. Because a Worker only ever drains events pushed by the
TracePoints that same Session enabled, the Queue instance itself is what
scopes an event to a session — there is no session_id field anywhere, and
running multiple sessions concurrently (multiple OrangeTap.new.open calls
at once) works without any extra bookkeeping.
What isn't tracked
- Cross-thread/Fiber/Ractor causality. Spans are grouped strictly by
thread_id, so if a traced method spawns work on another thread (Thread.new, a Fiber, a Ractor), that work's spans are not linked back to the caller as parent/child. This is out of scope for the current version. - Methods defined via
define_methodor a block.trace_methodrequires aMethod/UnboundMethodbacked by adef-defined instance sequence. Block-based method bodies (define_method,define_singleton_method) have an ISeq of type:block, whichTracePoint#enable(target:)cannot target and raisesArgumentError— attempting to trace one will fail atopentime, not attrace_methodtime. - Errors inside the Worker thread. If the worker thread raises while
assembling spans,
Session#stopre-raises that error viaThread#value's standard behavior. TracePoints are always disabled before the worker is waited on, so a worker crash never leaves hooks enabled. - Double-hook overhead across concurrent sessions. If two sessions are
open at once and both target the same method, that method gets two
independent
TracePoints enabled on it. This is accepted for simplicity in the current version.
Installation
bundle add orange_tap
Or, without Bundler:
gem install orange_tap
Usage
require "orange_tap"
class Worker
def process(job)
# ...
end
end
OrangeTap.trace_method(Worker.instance_method(:process))
path = OrangeTap.open do
Worker.new.process(job)
end
# => path to a written OTLP/JSON file
Or using the instance form:
tape = OrangeTap.new
tape.open
# ...
path = tape.stop
Other registration entry points:
OrangeTap.trace_method(SomeClass.method(:some_class_method)) # class/singleton method
OrangeTap.trace_method(some_object.method(:some_method)) # singleton method on one object
OrangeTap.trace_all_instance_methods(SomeClass) # all instance methods at once
OrangeTap.untrace_method(SomeClass.instance_method(:some_method))
# Register several methods in one call, and/or use "Foo.bar" / "Foo#bar"
# notation strings instead of resolving Method/UnboundMethod objects yourself:
OrangeTap.trace_method("SomeClass.some_class_method", "SomeClass#some_method")
OrangeTap.untrace_method("SomeClass.some_class_method", "SomeClass#some_method")
Output location is configurable:
OrangeTap.config.output_dir = "/path/to/traces"
Example
examples/order_demo.rb is a runnable,
self-contained example. It defines a small Order/Pricing/Receipt set of
classes:
class Order
def total
@items.sum { |item| Pricing.price_for(item) }
end
def checkout
amount = total
Receipt.new(amount).print
amount
end
end
module Pricing
def self.price_for(item) = PRICES.fetch(item, 0)
end
class Receipt
def print = puts "Total: #{@amount} yen"
end
traces a mix of instance and module methods, and wraps the call in
OrangeTap.open:
OrangeTap.trace_method(
"Order#total",
"Order#checkout",
"Pricing.price_for",
"Receipt#print"
)
path = OrangeTap.open do
Order.new(%w[coffee cake tea coffee]).checkout
end
Run it with:
bundle exec ruby -Ilib examples/order_demo.rb
This prints the OTLP/JSON file path and pretty-prints its contents. A sample
of that output is checked in at
examples/trace-example.json.
Importing that JSON into Jaeger (all-in-one) as an OTLP trace renders the
following span tree, which matches the example's call graph exactly —
orange_tap session → tid=... → Order#checkout → Order#total →
Pricing.price_for ×4, plus Receipt#print:

Development
After checking out the repo, run bin/setup to install dependencies. Then,
run rake test to run the tests. You can also run bin/console for an
interactive prompt that will allow you to experiment.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/udzura/orange_tap. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
Code of Conduct
Everyone interacting in the OrangeTap project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.