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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/DESIGN.md +628 -0
- data/LICENSE.txt +21 -0
- data/README.md +197 -0
- data/Rakefile +12 -0
- data/examples/jaeger-screenshot.png +0 -0
- data/examples/order_demo.rb +66 -0
- data/examples/trace-example.json +1 -0
- data/lib/orange_tap/config.rb +15 -0
- data/lib/orange_tap/event.rb +8 -0
- data/lib/orange_tap/method_registry.rb +85 -0
- data/lib/orange_tap/otel_converter.rb +67 -0
- data/lib/orange_tap/pending_span.rb +21 -0
- data/lib/orange_tap/session.rb +79 -0
- data/lib/orange_tap/version.rb +5 -0
- data/lib/orange_tap/worker.rb +136 -0
- data/lib/orange_tap.rb +69 -0
- data/sig/orange_tap.rbs +4 -0
- metadata +64 -0
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
|
+

|
|
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
|
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
|