orange_tap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # OrangeTap
2
+
3
+ OrangeTap hooks Ruby method calls (call/return) with `TracePoint`, assembles
4
+ them into OpenTelemetry-style spans on a background thread inside the same
5
+ process, and writes the result as an OTLP/JSON file. There is no central
6
+ daemon, no shared memory, and no inter-process communication of any kind —
7
+ tracing is entirely contained within the observed Ruby process.
8
+
9
+ ## Design notes
10
+
11
+ ### Why Ruby-level `TracePoint` instead of `rb_add_event_hook`
12
+
13
+ OrangeTap intentionally uses the Ruby-level `TracePoint` API rather than the
14
+ C-level `rb_add_event_hook`/`trace_func` mechanism. Ruby 4.0's C-level
15
+ `trace_func` has a known bug, so this gem avoids it by design and pays the
16
+ (small, and in practice dominated by the traced call itself) overhead of a
17
+ Ruby-level hook instead. Target methods are narrowed with
18
+ `TracePoint#enable(target: iseq)` so untargeted methods incur no hook
19
+ overhead at all.
20
+
21
+ ### No central daemon — everything lives in one process's Thread + Queue
22
+
23
+ Earlier iterations of this idea used a shared-memory ring buffer and an XPC
24
+ daemon process. OrangeTap drops all of that: a session is just a
25
+ `Thread::Queue` plus a background `Thread` inside the same process that
26
+ called `OrangeTap.open`. Correlating traces across multiple OS processes is
27
+ explicitly out of scope for this gem.
28
+
29
+ ### Why `open`/`stop` need no `session_id`
30
+
31
+ Each call to `open` creates a brand new `Queue` and worker `Thread` dedicated
32
+ to that session. Because a Worker only ever drains events pushed by the
33
+ TracePoints that same `Session` enabled, the `Queue` instance itself is what
34
+ scopes an event to a session — there is no `session_id` field anywhere, and
35
+ running multiple sessions concurrently (multiple `OrangeTap.new.open` calls
36
+ at once) works without any extra bookkeeping.
37
+
38
+ ### What isn't tracked
39
+
40
+ - **Cross-thread/Fiber/Ractor causality.** Spans are grouped strictly by
41
+ `thread_id`, so if a traced method spawns work on another thread
42
+ (`Thread.new`, a Fiber, a Ractor), that work's spans are not linked back to
43
+ the caller as parent/child. This is out of scope for the current version.
44
+ - **Methods defined via `define_method` or a block.** `trace_method` requires
45
+ a `Method`/`UnboundMethod` backed by a `def`-defined instance sequence.
46
+ Block-based method bodies (`define_method`, `define_singleton_method`) have
47
+ an ISeq of type `:block`, which `TracePoint#enable(target:)` cannot target
48
+ and raises `ArgumentError` — attempting to trace one will fail at `open`
49
+ time, not at `trace_method` time.
50
+ - **Errors inside the Worker thread.** If the worker thread raises while
51
+ assembling spans, `Session#stop` re-raises that error via `Thread#value`'s
52
+ standard behavior. TracePoints are always disabled *before* the worker is
53
+ waited on, so a worker crash never leaves hooks enabled.
54
+ - **Double-hook overhead across concurrent sessions.** If two sessions are
55
+ open at once and both target the same method, that method gets two
56
+ independent `TracePoint`s enabled on it. This is accepted for simplicity in
57
+ the current version.
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ bundle add orange_tap
63
+ ```
64
+
65
+ Or, without Bundler:
66
+
67
+ ```bash
68
+ gem install orange_tap
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ ```ruby
74
+ require "orange_tap"
75
+
76
+ class Worker
77
+ def process(job)
78
+ # ...
79
+ end
80
+ end
81
+
82
+ OrangeTap.trace_method(Worker.instance_method(:process))
83
+
84
+ path = OrangeTap.open do
85
+ Worker.new.process(job)
86
+ end
87
+ # => path to a written OTLP/JSON file
88
+ ```
89
+
90
+ Or using the instance form:
91
+
92
+ ```ruby
93
+ tape = OrangeTap.new
94
+ tape.open
95
+ # ...
96
+ path = tape.stop
97
+ ```
98
+
99
+ Other registration entry points:
100
+
101
+ ```ruby
102
+ OrangeTap.trace_method(SomeClass.method(:some_class_method)) # class/singleton method
103
+ OrangeTap.trace_method(some_object.method(:some_method)) # singleton method on one object
104
+ OrangeTap.trace_all_instance_methods(SomeClass) # all instance methods at once
105
+ OrangeTap.untrace_method(SomeClass.instance_method(:some_method))
106
+
107
+ # Register several methods in one call, and/or use "Foo.bar" / "Foo#bar"
108
+ # notation strings instead of resolving Method/UnboundMethod objects yourself:
109
+ OrangeTap.trace_method("SomeClass.some_class_method", "SomeClass#some_method")
110
+ OrangeTap.untrace_method("SomeClass.some_class_method", "SomeClass#some_method")
111
+ ```
112
+
113
+ Output location is configurable:
114
+
115
+ ```ruby
116
+ OrangeTap.config.output_dir = "/path/to/traces"
117
+ ```
118
+
119
+ ### Example
120
+
121
+ [`examples/order_demo.rb`](examples/order_demo.rb) is a runnable,
122
+ self-contained example. It defines a small `Order`/`Pricing`/`Receipt` set of
123
+ classes:
124
+
125
+ ```ruby
126
+ class Order
127
+ def total
128
+ @items.sum { |item| Pricing.price_for(item) }
129
+ end
130
+
131
+ def checkout
132
+ amount = total
133
+ Receipt.new(amount).print
134
+ amount
135
+ end
136
+ end
137
+
138
+ module Pricing
139
+ def self.price_for(item) = PRICES.fetch(item, 0)
140
+ end
141
+
142
+ class Receipt
143
+ def print = puts "Total: #{@amount} yen"
144
+ end
145
+ ```
146
+
147
+ traces a mix of instance and module methods, and wraps the call in
148
+ `OrangeTap.open`:
149
+
150
+ ```ruby
151
+ OrangeTap.trace_method(
152
+ "Order#total",
153
+ "Order#checkout",
154
+ "Pricing.price_for",
155
+ "Receipt#print"
156
+ )
157
+
158
+ path = OrangeTap.open do
159
+ Order.new(%w[coffee cake tea coffee]).checkout
160
+ end
161
+ ```
162
+
163
+ Run it with:
164
+
165
+ ```bash
166
+ bundle exec ruby -Ilib examples/order_demo.rb
167
+ ```
168
+
169
+ This prints the OTLP/JSON file path and pretty-prints its contents. A sample
170
+ of that output is checked in at
171
+ [`examples/trace-example.json`](examples/trace-example.json).
172
+
173
+ Importing that JSON into Jaeger (all-in-one) as an OTLP trace renders the
174
+ following span tree, which matches the example's call graph exactly —
175
+ `orange_tap session` → `tid=...` → `Order#checkout` → `Order#total` →
176
+ `Pricing.price_for` ×4, plus `Receipt#print`:
177
+
178
+ ![Example trace visualized in Jaeger](examples/jaeger-screenshot.png)
179
+
180
+ ## Development
181
+
182
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
183
+ run `rake test` to run the tests. You can also run `bin/console` for an
184
+ interactive prompt that will allow you to experiment.
185
+
186
+ ## Contributing
187
+
188
+ Bug reports and pull requests are welcome on GitHub at
189
+ https://github.com/udzura/orange_tap. This project is intended to be a safe,
190
+ welcoming space for collaboration, and contributors are expected to adhere to
191
+ the [code of conduct](https://github.com/udzura/orange_tap/blob/main/CODE_OF_CONDUCT.md).
192
+
193
+ ## Code of Conduct
194
+
195
+ Everyone interacting in the OrangeTap project's codebases, issue trackers,
196
+ chat rooms and mailing lists is expected to follow the
197
+ [code of conduct](https://github.com/udzura/orange_tap/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
Binary file
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Minimal end-to-end example: define some classes, register the methods you
5
+ # want traced, wrap the code you want to observe in OrangeTap.open, and print
6
+ # the resulting OTLP/JSON file.
7
+ #
8
+ # ruby -Ilib examples/order_demo.rb
9
+
10
+ require "orange_tap"
11
+ require "json"
12
+
13
+ class Order
14
+ def initialize(items)
15
+ @items = items
16
+ end
17
+
18
+ def total
19
+ @items.sum { |item| Pricing.price_for(item) }
20
+ end
21
+
22
+ def checkout
23
+ amount = total
24
+ Receipt.new(amount).print
25
+ amount
26
+ end
27
+ end
28
+
29
+ module Pricing
30
+ PRICES = { "coffee" => 400, "tea" => 350, "cake" => 500 }.freeze
31
+
32
+ def self.price_for(item)
33
+ PRICES.fetch(item, 0)
34
+ end
35
+ end
36
+
37
+ class Receipt
38
+ def initialize(amount)
39
+ @amount = amount
40
+ end
41
+
42
+ def print
43
+ puts "Total: #{@amount} yen"
44
+ end
45
+ end
46
+
47
+ # Register the methods we care about in one call. "Foo#bar" resolves to an
48
+ # instance method, "Foo.bar" to a module/class (singleton) method.
49
+ OrangeTap.trace_method(
50
+ "Order#total",
51
+ "Order#checkout",
52
+ "Pricing.price_for",
53
+ "Receipt#print"
54
+ )
55
+
56
+ path = nil
57
+ # Avoid YJIT compile overhead in this example by running the code multiple times
58
+ 3.times do
59
+ path = OrangeTap.open do
60
+ order = Order.new(%w[coffee cake tea coffee])
61
+ order.checkout
62
+ end
63
+ end
64
+
65
+ puts "\nOTLP/JSON written to: #{path}\n\n"
66
+ puts JSON.pretty_generate(JSON.parse(File.read(path)))
@@ -0,0 +1 @@
1
+ {"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"orange_tap"}}]},"scopeSpans":[{"scope":{"name":"orange_tap","version":"0.1.0"},"spans":[{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"d18866e452c53566","name":"orange_tap session","kind":1,"startTimeUnixNano":"1783516109569910000","endTimeUnixNano":"1783516109569995000","attributes":[]},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"53fc59a2efc177df","name":"tid=704","kind":1,"startTimeUnixNano":"1783516109569910000","endTimeUnixNano":"1783516109569995000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"d18866e452c53566"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"0917347853e48ab3","name":"Pricing.price_for","kind":1,"startTimeUnixNano":"1783516109569944000","endTimeUnixNano":"1783516109569945000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"21e20b4ea2a8f28a"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"227bec23cab15004","name":"Pricing.price_for","kind":1,"startTimeUnixNano":"1783516109569946000","endTimeUnixNano":"1783516109569946000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"21e20b4ea2a8f28a"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"43e1d5bb5d47a207","name":"Pricing.price_for","kind":1,"startTimeUnixNano":"1783516109569947000","endTimeUnixNano":"1783516109569948000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"21e20b4ea2a8f28a"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"defa3633ea1bce01","name":"Pricing.price_for","kind":1,"startTimeUnixNano":"1783516109569948000","endTimeUnixNano":"1783516109569949000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"21e20b4ea2a8f28a"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"21e20b4ea2a8f28a","name":"Order#total","kind":1,"startTimeUnixNano":"1783516109569944000","endTimeUnixNano":"1783516109569950000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"007fe473ad175bc2"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"edfcf55ab44605d8","name":"Receipt#print","kind":1,"startTimeUnixNano":"1783516109569950000","endTimeUnixNano":"1783516109569952000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"007fe473ad175bc2"},{"traceId":"85993b8744f30aaabce32f8f68762816","spanId":"007fe473ad175bc2","name":"Order#checkout","kind":1,"startTimeUnixNano":"1783516109569941000","endTimeUnixNano":"1783516109569953000","attributes":[{"key":"thread.id","value":{"intValue":"704"}}],"parentSpanId":"53fc59a2efc177df"}]}]}]}
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module OrangeTap
6
+ class Config
7
+ attr_accessor :output_dir, :service_name, :otel_converter
8
+
9
+ def initialize
10
+ @output_dir = File.join(Dir.tmpdir, "orange_tap")
11
+ @service_name = "orange_tap"
12
+ @otel_converter = OrangeTap::OtelConverter
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrangeTap
4
+ # Lightweight, immutable event created inside the TracePoint hook. Only
5
+ # numeric/symbol/Module references are captured here (no tp.binding,
6
+ # no tp.parameters, no string building) so the hook body stays cheap.
7
+ Event = Data.define(:type, :thread_id, :method_id, :defined_class, :timestamp_ns)
8
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrangeTap
4
+ # Holds the set of methods to be traced, keyed by [owner, name] rather than
5
+ # by Method/UnboundMethod object identity. Method/UnboundMethod instances
6
+ # are freshly allocated on every `obj.method(:foo)` call, so identity-based
7
+ # bookkeeping would make untrace_method impossible to use correctly. Using
8
+ # owner (the singleton class for class/singleton methods) + name gives a
9
+ # stable identity for instance methods, class methods, and singleton
10
+ # methods on a specific object alike.
11
+ class MethodRegistry
12
+ # "Foo::Bar.baz" -> class/singleton method; "Foo::Bar#baz" -> instance method.
13
+ # The class-path side never contains "." or "#", so a greedy match up to
14
+ # the last separator is unambiguous.
15
+ CLASS_METHOD_NOTATION = /\A(.+)\.([^.#]+)\z/
16
+ INSTANCE_METHOD_NOTATION = /\A(.+)#([^.#]+)\z/
17
+
18
+ def initialize
19
+ @entries = {}
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def register(*method_objs)
24
+ method_objs.each { |m| register_one(m) }
25
+ nil
26
+ end
27
+
28
+ def unregister(*method_objs)
29
+ method_objs.each { |m| unregister_one(m) }
30
+ nil
31
+ end
32
+
33
+ def register_all_instance_methods(klass)
34
+ klass.instance_methods(false).each { |m| register(klass.instance_method(m)) }
35
+ nil
36
+ end
37
+
38
+ # Snapshot of currently registered ISeqs, taken once per Session#open.
39
+ def targets
40
+ @mutex.synchronize { @entries.values.dup }
41
+ end
42
+
43
+ private
44
+
45
+ def register_one(method_obj)
46
+ method_obj = resolve(method_obj)
47
+ iseq = RubyVM::InstructionSequence.of(method_obj)
48
+ raise OrangeTap::UntraceableMethodError, method_obj.inspect unless iseq
49
+
50
+ @mutex.synchronize { @entries[key_for(method_obj)] = iseq }
51
+ end
52
+
53
+ def unregister_one(method_obj)
54
+ method_obj = resolve(method_obj)
55
+ @mutex.synchronize { @entries.delete(key_for(method_obj)) }
56
+ end
57
+
58
+ # Accepts a Method/UnboundMethod as-is, or a notation String ("Foo.bar"
59
+ # for a class/singleton method, "Foo#bar" for an instance method) that is
60
+ # resolved to one via Object.const_get + #method/#instance_method.
61
+ def resolve(method_obj)
62
+ return method_obj if method_obj.is_a?(Method) || method_obj.is_a?(UnboundMethod)
63
+
64
+ unless method_obj.is_a?(String)
65
+ raise ArgumentError,
66
+ "Method / UnboundMethod、または 'Foo.bar' / 'Foo#bar' 形式の文字列を渡してください: " \
67
+ "#{method_obj.inspect}"
68
+ end
69
+
70
+ if (m = CLASS_METHOD_NOTATION.match(method_obj))
71
+ Object.const_get(m[1]).method(m[2].to_sym)
72
+ elsif (m = INSTANCE_METHOD_NOTATION.match(method_obj))
73
+ Object.const_get(m[1]).instance_method(m[2].to_sym)
74
+ else
75
+ raise ArgumentError,
76
+ "'Foo.bar'(クラス/特異メソッド)または 'Foo#bar'(インスタンスメソッド)形式で指定してください: " \
77
+ "#{method_obj.inspect}"
78
+ end
79
+ end
80
+
81
+ def key_for(method_obj)
82
+ [method_obj.owner, method_obj.name]
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrangeTap
4
+ # Self-contained OTLP/JSON writer. Follows the wire format conventions of
5
+ # OTLP/JSON (resourceSpans -> scopeSpans -> spans, 32-hex traceId, 16-hex
6
+ # spanId, string-encoded nanosecond timestamps) but does not depend on any
7
+ # external gem: PendingSpan already carries hex-encoded span_id/parent_span_id
8
+ # (see Worker), so no int-to-hex conversion is needed here.
9
+ #
10
+ # Pluggable via Config#otel_converter so callers can swap in their own
11
+ # converter (e.g. to stream spans elsewhere) without touching Worker.
12
+ module OtelConverter
13
+ SPAN_KIND_INTERNAL = 1
14
+
15
+ module_function
16
+
17
+ # spans: Array<PendingSpan> holding monotonic-ns timestamps.
18
+ # Returns a Hash ready for JSON.generate.
19
+ def build_document(spans:, trace_id:, start_mono_ns:, start_unix_ns:, service_name:)
20
+ to_unix = ->(mono_ns) { (start_unix_ns.to_i + (mono_ns.to_i - start_mono_ns.to_i)).to_s }
21
+
22
+ {
23
+ resourceSpans: [
24
+ {
25
+ resource: { attributes: [str_attr("service.name", service_name)] },
26
+ scopeSpans: [
27
+ {
28
+ scope: { name: service_name, version: OrangeTap::VERSION },
29
+ spans: spans.map { |s| span_hash(s, trace_id, to_unix) }
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ }
35
+ end
36
+
37
+ def span_hash(span, trace_id, to_unix)
38
+ attributes = []
39
+ attributes << int_attr("thread.id", span.thread_id) if span.thread_id
40
+ attributes << bool_attr("orange_tap.incomplete", true) if span.incomplete
41
+
42
+ hash = {
43
+ traceId: trace_id,
44
+ spanId: span.span_id,
45
+ name: span.name,
46
+ kind: SPAN_KIND_INTERNAL,
47
+ startTimeUnixNano: to_unix.call(span.start_mono_ns),
48
+ endTimeUnixNano: to_unix.call(span.end_mono_ns),
49
+ attributes: attributes
50
+ }
51
+ hash[:parentSpanId] = span.parent_span_id if span.parent_span_id
52
+ hash
53
+ end
54
+
55
+ def str_attr(key, value)
56
+ { key: key, value: { stringValue: value.to_s } }
57
+ end
58
+
59
+ def int_attr(key, value)
60
+ { key: key, value: { intValue: value.to_i.to_s } }
61
+ end
62
+
63
+ def bool_attr(key, value)
64
+ { key: key, value: { boolValue: !!value } }
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrangeTap
4
+ # Mutable, in-progress span assembled by Worker while draining the queue.
5
+ # Timestamps are kept in monotonic ns; OtelConverter is responsible for
6
+ # anchoring them to wall-clock unix ns at output time.
7
+ class PendingSpan
8
+ attr_accessor :span_id, :parent_span_id, :name, :thread_id,
9
+ :start_mono_ns, :end_mono_ns, :incomplete
10
+
11
+ def initialize(span_id:, parent_span_id:, name:, thread_id:, start_mono_ns:, end_mono_ns: nil)
12
+ @span_id = span_id
13
+ @parent_span_id = parent_span_id
14
+ @name = name
15
+ @thread_id = thread_id
16
+ @start_mono_ns = start_mono_ns
17
+ @end_mono_ns = end_mono_ns
18
+ @incomplete = false
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OrangeTap
6
+ # Controls a single recording session's lifecycle. Each #open call creates
7
+ # a dedicated Queue and Worker Thread; there is no session_id, because the
8
+ # Queue instance itself is what scopes events to this session. This also
9
+ # makes concurrent sessions (multiple Session instances open at once) work
10
+ # without any cross-session bookkeeping: each has its own Queue, Thread,
11
+ # and TracePoint set.
12
+ class Session
13
+ def initialize(registry: OrangeTap.default_registry, config: OrangeTap.config)
14
+ @registry = registry
15
+ @config = config
16
+ @queue = nil
17
+ @worker_thread = nil
18
+ @tracepoint_targets = nil
19
+ end
20
+
21
+ def open
22
+ raise AlreadyOpenError if @queue
23
+
24
+ # Anchor monotonic time to wall-clock time once, at session start, so
25
+ # OtelConverter can later translate CLOCK_MONOTONIC-based timestamps
26
+ # (cheap, used inside the hot hook) into absolute OTLP unix ns.
27
+ start_mono_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
28
+ start_unix_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
29
+ trace_id = SecureRandom.hex(16)
30
+
31
+ @queue = Thread::Queue.new
32
+
33
+ # One TracePoint per target ISeq: whether a single TracePoint can
34
+ # safely enable(target:) more than one ISeq is version-dependent, so
35
+ # each ISeq gets its own TracePoint instance to enable/disable.
36
+ @tracepoint_targets = @registry.targets.map { |iseq| [build_tracepoint(@queue), iseq] }
37
+
38
+ ctx = Worker::Context.new(
39
+ queue: @queue, config: @config, trace_id: trace_id,
40
+ start_mono_ns: start_mono_ns, start_unix_ns: start_unix_ns
41
+ )
42
+ @worker_thread = Thread.new(ctx) { |worker_ctx| Worker.new(worker_ctx).run }
43
+
44
+ @tracepoint_targets.each { |tp, iseq| tp.enable(target: iseq) }
45
+ self
46
+ end
47
+
48
+ def stop
49
+ raise NotOpenError unless @queue
50
+
51
+ # Disable hooks before closing the queue / waiting on the worker, so
52
+ # that even if the worker raises, tracing has already stopped and
53
+ # cannot leak into whatever runs next.
54
+ @tracepoint_targets.each { |tp, _iseq| tp.disable }
55
+ @queue.close
56
+ path = @worker_thread.value
57
+ @queue = nil
58
+ @tracepoint_targets = nil
59
+ @worker_thread = nil
60
+ path
61
+ end
62
+
63
+ private
64
+
65
+ def build_tracepoint(queue)
66
+ TracePoint.new(:call, :return) do |tp|
67
+ # Hook body stays minimal: push a single Event built from cheap
68
+ # primitives only. No tp.binding, no tp.parameters, no string work.
69
+ queue << Event.new(
70
+ type: tp.event,
71
+ thread_id: Thread.current.object_id,
72
+ method_id: tp.method_id,
73
+ defined_class: tp.defined_class,
74
+ timestamp_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrangeTap
4
+ VERSION = "0.1.0"
5
+ end