orange_tap 0.1.0 → 0.2.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 +4 -4
- data/README.md +111 -1
- data/examples/order_demo.rb +1 -1
- data/examples/sinatra_trace_all_demo.rb +127 -0
- data/examples/trace_all_app_demo.rb +85 -0
- data/lib/orange_tap/builtin_filter.rb +55 -0
- data/lib/orange_tap/config.rb +17 -1
- data/lib/orange_tap/method_registry.rb +38 -2
- data/lib/orange_tap/session.rb +74 -8
- data/lib/orange_tap/version.rb +1 -1
- data/lib/orange_tap/worker.rb +4 -2
- data/lib/orange_tap.rb +51 -2
- data/sig/orange_tap.rbs +45 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca009ebd31ae0ff1849d7975d602f1f87dccbada30458cd4943e6a526aa35647
|
|
4
|
+
data.tar.gz: 662aa3e960659b36c5febb1b1f59e2d39d2010146a208584155f7e860206b77a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdd6c3f5b6158f7df4dd462c4aa6be2b38bd060d5a9c26596487c93510b03d3c9259efbeee60d1d5c960060f05265608b56e1edea922a3de534414ef5578934f
|
|
7
|
+
data.tar.gz: bbc112874962e22531f59e7fca2b5569831fbb956a690692c24f219fb32c67295d48aaf632ecb276f2ac6c22e4122e3992d8942f9976a96a9e6f5238a044b7f8
|
data/README.md
CHANGED
|
@@ -46,7 +46,13 @@ at once) works without any extra bookkeeping.
|
|
|
46
46
|
Block-based method bodies (`define_method`, `define_singleton_method`) have
|
|
47
47
|
an ISeq of type `:block`, which `TracePoint#enable(target:)` cannot target
|
|
48
48
|
and raises `ArgumentError` — attempting to trace one will fail at `open`
|
|
49
|
-
time, not at `trace_method` time.
|
|
49
|
+
time, not at `trace_method` time. (The
|
|
50
|
+
[trace-all-application-methods mode](#tracing-all-application-methods-opt-in)
|
|
51
|
+
captures these, since it uses a global hook rather than a per-ISeq target.)
|
|
52
|
+
- **C-implemented methods, unless explicitly opted in.** Methods with no Ruby
|
|
53
|
+
ISeq are rejected by default. They can be traced via a global hook by
|
|
54
|
+
setting `config.trace_c_methods = true`, at a process-wide performance cost
|
|
55
|
+
— see [Tracing C-implemented methods](#tracing-c-implemented-methods-opt-in).
|
|
50
56
|
- **Errors inside the Worker thread.** If the worker thread raises while
|
|
51
57
|
assembling spans, `Session#stop` re-raises that error via `Thread#value`'s
|
|
52
58
|
standard behavior. TracePoints are always disabled *before* the worker is
|
|
@@ -96,6 +102,46 @@ tape.open
|
|
|
96
102
|
path = tape.stop
|
|
97
103
|
```
|
|
98
104
|
|
|
105
|
+
The root (session) span is named `"orange_tap session"` by default. Pass a
|
|
106
|
+
name to override it — handy for telling sessions apart when the output is
|
|
107
|
+
imported into a trace viewer:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
OrangeTap.open("checkout-flow") { ... }
|
|
111
|
+
|
|
112
|
+
tape = OrangeTap.new
|
|
113
|
+
tape.open("checkout-flow")
|
|
114
|
+
# ...
|
|
115
|
+
tape.stop
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `record`: measure a block with config and error handling taken care of
|
|
119
|
+
|
|
120
|
+
`OrangeTap.record` wraps a single session for the common "measure this block
|
|
121
|
+
once" case (e.g. a Rails `around_action`). Compared to `open`, it handles two
|
|
122
|
+
things every caller otherwise re-implements by hand:
|
|
123
|
+
|
|
124
|
+
- **`config_overrides`** are applied for the duration of the block and
|
|
125
|
+
restored afterwards, *even on error*. `OrangeTap.config` is a process-global
|
|
126
|
+
singleton, so a leaked `trace_all_app_methods = true` would keep every later
|
|
127
|
+
session in the heaviest mode — `record` guarantees it is reset.
|
|
128
|
+
- **`on_output`** is called with the written JSON path in an `ensure`, so you
|
|
129
|
+
receive the path on **both success and failure**. (Block-form `open` cannot
|
|
130
|
+
return the path when the block raises.) The trace file is written either way,
|
|
131
|
+
since the worker drains on `stop`.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
OrangeTap.record(
|
|
135
|
+
"checkout-flow",
|
|
136
|
+
trace_all_app_methods: true, # applied, then restored
|
|
137
|
+
on_output: ->(path) { Rails.logger.info("OrangeTap: #{path}") }
|
|
138
|
+
) do
|
|
139
|
+
MyApp.handle(request)
|
|
140
|
+
end
|
|
141
|
+
# => output path on success; the original error is re-raised on failure
|
|
142
|
+
# (an on_output that itself raises is swallowed so it never masks it)
|
|
143
|
+
```
|
|
144
|
+
|
|
99
145
|
Other registration entry points:
|
|
100
146
|
|
|
101
147
|
```ruby
|
|
@@ -116,6 +162,70 @@ Output location is configurable:
|
|
|
116
162
|
OrangeTap.config.output_dir = "/path/to/traces"
|
|
117
163
|
```
|
|
118
164
|
|
|
165
|
+
### Tracing all application methods (opt-in)
|
|
166
|
+
|
|
167
|
+
Instead of registering methods one by one, you can trace **every non-builtin
|
|
168
|
+
Ruby method call** in the process by enabling a single flag before opening a
|
|
169
|
+
session:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
OrangeTap.config.trace_all_app_methods = true
|
|
173
|
+
|
|
174
|
+
OrangeTap.open("request") do
|
|
175
|
+
# every application (and gem) method called here is captured automatically
|
|
176
|
+
MyApp.handle(request)
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
In this mode a session installs one global `:call`/`:return` `TracePoint` (no
|
|
181
|
+
`target:`) and decides what to keep by the **definition path** of each called
|
|
182
|
+
method:
|
|
183
|
+
|
|
184
|
+
- **Excluded:** Ruby core internals (`<internal:...>`) and the standard library
|
|
185
|
+
(under Ruby's `rubylibdir`/`rubyarchdir`), plus OrangeTap's own code.
|
|
186
|
+
- **Excluded automatically:** all C-implemented methods (`String#upcase`,
|
|
187
|
+
`Array#each`, `Integer#+`, …) — `TracePoint(:call)` never fires for them.
|
|
188
|
+
- **Traced:** everything else, i.e. your application code **and gems**.
|
|
189
|
+
|
|
190
|
+
Because it needs no ISeq target, this mode also captures methods defined via
|
|
191
|
+
`define_method` / `define_singleton_method`, which the per-method
|
|
192
|
+
`trace_method` API cannot target.
|
|
193
|
+
|
|
194
|
+
When enabled, this mode **supersedes** explicit `trace_method` registration:
|
|
195
|
+
per-method (and C-method opt-in) hooks are not installed for that session.
|
|
196
|
+
|
|
197
|
+
> **Performance warning:** the hook fires on **every Ruby method call in the
|
|
198
|
+
> process**, running a (memoized) path check each time. This is the heaviest
|
|
199
|
+
> mode OrangeTap offers — use it for focused debugging sessions, not always-on
|
|
200
|
+
> production tracing. Concurrent sessions each add their own global hook.
|
|
201
|
+
|
|
202
|
+
### Tracing C-implemented methods (opt-in)
|
|
203
|
+
|
|
204
|
+
By default, registering a C-implemented method (one with no Ruby ISeq, e.g.
|
|
205
|
+
`String#upcase`) raises `OrangeTap::UntraceableMethodError`, because the
|
|
206
|
+
low-overhead `TracePoint#enable(target:)` mechanism requires an ISeq.
|
|
207
|
+
|
|
208
|
+
You can opt in to tracing C methods through the same `trace_method` API by
|
|
209
|
+
enabling a config flag **before** registering them:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
OrangeTap.config.trace_c_methods = true
|
|
213
|
+
OrangeTap.trace_method(String.instance_method(:upcase)) # now accepted
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Performance trade-off:** when enabled and at least one C method is
|
|
217
|
+
registered, each session installs a single global `:c_call`/`:c_return`
|
|
218
|
+
`TracePoint` (no `target:`). That hook fires on **every C call in the
|
|
219
|
+
process** — including very hot ones like `Array#each`, `Hash#[]`, `Integer#+`
|
|
220
|
+
— and filters by `[owner, name]` inside the hook. So the "zero overhead for
|
|
221
|
+
unregistered methods" guarantee no longer holds once any C method is traced.
|
|
222
|
+
Concurrent sessions each add their own global hook, compounding the cost.
|
|
223
|
+
Enable it only when you specifically need C-method spans.
|
|
224
|
+
|
|
225
|
+
A C method that is a *singleton method on a specific object* (rather than a
|
|
226
|
+
class/singleton method like `Foo.bar`) is not supported: it is skipped with a
|
|
227
|
+
warning at registration time.
|
|
228
|
+
|
|
119
229
|
### Example
|
|
120
230
|
|
|
121
231
|
[`examples/order_demo.rb`](examples/order_demo.rb) is a runnable,
|
data/examples/order_demo.rb
CHANGED
|
@@ -56,7 +56,7 @@ OrangeTap.trace_method(
|
|
|
56
56
|
path = nil
|
|
57
57
|
# Avoid YJIT compile overhead in this example by running the code multiple times
|
|
58
58
|
3.times do
|
|
59
|
-
path = OrangeTap.open do
|
|
59
|
+
path = OrangeTap.open("DummyController#dummy") do
|
|
60
60
|
order = Order.new(%w[coffee cake tea coffee])
|
|
61
61
|
order.checkout
|
|
62
62
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Sinatra demo: enable OrangeTap's "trace all application methods" mode and
|
|
5
|
+
# record ONE OTLP/JSON trace per HTTP request, using before/after filters to
|
|
6
|
+
# open and stop a session around each request.
|
|
7
|
+
#
|
|
8
|
+
# It is self-driving: a handful of in-process requests are issued via
|
|
9
|
+
# Rack::MockRequest, so no server or `curl` is needed. Just run:
|
|
10
|
+
#
|
|
11
|
+
# ruby examples/sinatra_trace_all_demo.rb
|
|
12
|
+
#
|
|
13
|
+
# The first run installs sinatra (and its rack dependency) via bundler/inline.
|
|
14
|
+
|
|
15
|
+
require "bundler/inline"
|
|
16
|
+
gemfile do
|
|
17
|
+
source "https://rubygems.org"
|
|
18
|
+
gem "sinatra", require: "sinatra/base"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require_relative "../lib/orange_tap"
|
|
22
|
+
require "json"
|
|
23
|
+
require "tmpdir"
|
|
24
|
+
|
|
25
|
+
# --- application (domain) code --------------------------------------------
|
|
26
|
+
# These are the methods we actually care about seeing in the trace. Nothing
|
|
27
|
+
# here is registered with trace_method: the "trace all" mode picks them up
|
|
28
|
+
# automatically because they are non-builtin Ruby methods.
|
|
29
|
+
|
|
30
|
+
module Pricing
|
|
31
|
+
PRICES = { "coffee" => 400, "tea" => 350, "cake" => 500 }.freeze
|
|
32
|
+
|
|
33
|
+
def self.price_for(item)
|
|
34
|
+
PRICES.fetch(item, 0) # Hash#fetch is a C method -> excluded from the trace
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Order
|
|
39
|
+
MENUS = { 1 => %w[coffee cake], 2 => %w[tea tea coffee] }.freeze
|
|
40
|
+
|
|
41
|
+
def initialize(id)
|
|
42
|
+
@items = MENUS.fetch(id, [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def total
|
|
46
|
+
@items.sum { |item| Pricing.price_for(item) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def summary
|
|
50
|
+
"#{@items.size} item(s), #{total} yen"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Greeter
|
|
55
|
+
def greet(name)
|
|
56
|
+
"Hello, #{normalize(name)}!"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize(name)
|
|
60
|
+
name.to_s.strip.empty? ? "world" : name.strip
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# --- OrangeTap configuration ----------------------------------------------
|
|
65
|
+
OUTPUT_DIR = Dir.mktmpdir("orange_tap_sinatra")
|
|
66
|
+
OrangeTap.config.output_dir = OUTPUT_DIR
|
|
67
|
+
OrangeTap.config.trace_all_app_methods = true
|
|
68
|
+
|
|
69
|
+
# --- Sinatra app ----------------------------------------------------------
|
|
70
|
+
class DemoApp < Sinatra::Base
|
|
71
|
+
# One OrangeTap session per request. In trace_all_app_methods mode every
|
|
72
|
+
# non-builtin Ruby call between `before` and `after` is captured -- your
|
|
73
|
+
# domain code AND gem code (Sinatra itself), since gems are traced. C methods
|
|
74
|
+
# and the standard library are excluded automatically.
|
|
75
|
+
before do
|
|
76
|
+
@tape = OrangeTap.new
|
|
77
|
+
@tape.open("#{request.request_method} #{request.path_info}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
after do
|
|
81
|
+
path = @tape.stop
|
|
82
|
+
# Surface the trace file so the driver below can report / read it.
|
|
83
|
+
response.headers["X-Trace-Path"] = path
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
get "/" do
|
|
87
|
+
Greeter.new.greet(params["name"])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
get "/orders/:id" do
|
|
91
|
+
Order.new(params["id"].to_i).summary
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- drive a few requests in-process --------------------------------------
|
|
96
|
+
DOMAIN = %w[Greeter Order Pricing].freeze
|
|
97
|
+
|
|
98
|
+
def domain_spans(names)
|
|
99
|
+
names.select { |n| DOMAIN.any? { |d| n.start_with?("#{d}#", "#{d}.") } }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def span_names(path)
|
|
103
|
+
document = JSON.parse(File.read(path))
|
|
104
|
+
document.fetch("resourceSpans").flat_map do |rs|
|
|
105
|
+
rs.fetch("scopeSpans").flat_map { |ss| ss.fetch("spans") }
|
|
106
|
+
end.map { |s| s["name"] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
mock = Rack::MockRequest.new(DemoApp)
|
|
110
|
+
requests = ["/", "/?name=Alice", "/orders/1", "/orders/2"]
|
|
111
|
+
|
|
112
|
+
puts "Traces written under: #{OUTPUT_DIR}\n\n"
|
|
113
|
+
|
|
114
|
+
requests.each do |path|
|
|
115
|
+
response = mock.get(path)
|
|
116
|
+
trace_path = response["X-Trace-Path"]
|
|
117
|
+
names = span_names(trace_path)
|
|
118
|
+
|
|
119
|
+
puts "GET #{path}"
|
|
120
|
+
puts " -> #{response.status} #{response.body.strip.inspect}"
|
|
121
|
+
puts " trace: #{trace_path}"
|
|
122
|
+
puts " spans: #{names.size} total, #{domain_spans(names.uniq).sort.join(', ')} (domain)"
|
|
123
|
+
puts
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
puts "Each request produced its own OTLP/JSON file (open one to see the full,"
|
|
127
|
+
puts "framework-inclusive span tree)."
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# "Trace all application methods" mode: instead of registering methods one by
|
|
5
|
+
# one, flip a single config flag and every non-builtin Ruby method call is
|
|
6
|
+
# captured automatically. Built-ins are excluded by definition path — Ruby
|
|
7
|
+
# core internals and the standard library are dropped, and C methods
|
|
8
|
+
# (String#upcase, Array#each, ...) never fire the :call hook at all. Your
|
|
9
|
+
# application code (and gems) is traced.
|
|
10
|
+
#
|
|
11
|
+
# ruby -Ilib examples/trace_all_app_demo.rb
|
|
12
|
+
|
|
13
|
+
require "orange_tap"
|
|
14
|
+
require "json"
|
|
15
|
+
|
|
16
|
+
class Order
|
|
17
|
+
def initialize(items)
|
|
18
|
+
@items = items
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def total
|
|
22
|
+
# Enumerable#sum + the block call into C methods (Array iteration, Integer
|
|
23
|
+
# addition): those are NOT traced. Pricing.price_for IS (app code).
|
|
24
|
+
@items.sum { |item| Pricing.price_for(item) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def checkout
|
|
28
|
+
amount = total
|
|
29
|
+
label = describe # app method, traced
|
|
30
|
+
Receipt.new(amount).print(label)
|
|
31
|
+
amount
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Defined with define_method: the per-method `trace_method` API cannot target
|
|
35
|
+
# this (its ISeq is a :block), but the global app hook captures it fine.
|
|
36
|
+
define_method(:describe) do
|
|
37
|
+
"order of #{@items.size} item(s)"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module Pricing
|
|
42
|
+
PRICES = { "coffee" => 400, "tea" => 350, "cake" => 500 }.freeze
|
|
43
|
+
|
|
44
|
+
def self.price_for(item)
|
|
45
|
+
PRICES.fetch(item, 0) # Hash#fetch is a C method -> not traced
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Receipt
|
|
50
|
+
def initialize(amount)
|
|
51
|
+
@amount = amount
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def print(label)
|
|
55
|
+
puts "#{label}: #{@amount} yen" # Kernel#puts is C -> not traced
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# No OrangeTap.trace_method calls needed: just enable the mode.
|
|
60
|
+
OrangeTap.config.trace_all_app_methods = true
|
|
61
|
+
|
|
62
|
+
path = nil
|
|
63
|
+
# Avoid YJIT compile overhead in this example by running the code multiple times
|
|
64
|
+
3.times do
|
|
65
|
+
path = OrangeTap.open("DummyController#dummy") do
|
|
66
|
+
order = Order.new(%w[coffee cake tea coffee])
|
|
67
|
+
order.checkout
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts "\nOTLP/JSON written to: #{path}\n\n"
|
|
72
|
+
|
|
73
|
+
document = JSON.parse(File.read(path))
|
|
74
|
+
spans = document.fetch("resourceSpans").flat_map do |rs|
|
|
75
|
+
rs.fetch("scopeSpans").flat_map { |ss| ss.fetch("spans") }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# App methods (Order#total, Order#checkout, Order#describe, Pricing.price_for,
|
|
79
|
+
# Receipt#initialize, Receipt#print) show up automatically; built-ins such as
|
|
80
|
+
# String#upcase, Hash#fetch, and Kernel#puts do not.
|
|
81
|
+
puts "Captured span names:"
|
|
82
|
+
spans.map { |s| s["name"] }.uniq.sort.each { |name| puts " - #{name}" }
|
|
83
|
+
|
|
84
|
+
puts "\nFull document:\n\n"
|
|
85
|
+
puts JSON.pretty_generate(document)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module OrangeTap
|
|
6
|
+
# Classifies a source path (as given by TracePoint#path, i.e. the definition
|
|
7
|
+
# file of the called method) into "application code" vs "built-in", for the
|
|
8
|
+
# trace_all_app_methods mode.
|
|
9
|
+
#
|
|
10
|
+
# C-implemented methods never reach here: TracePoint(:call) does not fire for
|
|
11
|
+
# them, so they are excluded upstream. What remains to filter out are the
|
|
12
|
+
# Ruby-implemented built-ins, which are identified purely by path prefix:
|
|
13
|
+
#
|
|
14
|
+
# - "<...>" core internals (e.g. "<internal:array>")
|
|
15
|
+
# - rubylibdir/arch the Ruby standard library
|
|
16
|
+
# - OrangeTap itself so the tracer never traces its own code
|
|
17
|
+
#
|
|
18
|
+
# Gems (Gem.path) are intentionally NOT excluded: application-owned gem calls
|
|
19
|
+
# are considered part of the app for this mode.
|
|
20
|
+
class BuiltinFilter
|
|
21
|
+
def initialize
|
|
22
|
+
@excluded_prefixes = [
|
|
23
|
+
RbConfig::CONFIG["rubylibdir"],
|
|
24
|
+
RbConfig::CONFIG["rubyarchdir"],
|
|
25
|
+
# lib/orange_tap/builtin_filter.rb -> lib/orange_tap -> lib
|
|
26
|
+
File.expand_path("..", __dir__)
|
|
27
|
+
].compact
|
|
28
|
+
@cache = {}
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True when `path` looks like application (or gem) code that should be
|
|
33
|
+
# traced; false for core internals, the standard library, and OrangeTap's
|
|
34
|
+
# own source. Decisions are memoized per path.
|
|
35
|
+
def app_method?(path)
|
|
36
|
+
return false if path.nil? || path.empty?
|
|
37
|
+
return @cache[path] if @cache.key?(path)
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
# Re-check inside the lock: another thread may have filled it in.
|
|
41
|
+
return @cache[path] if @cache.key?(path)
|
|
42
|
+
|
|
43
|
+
@cache[path] = compute(path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def compute(path)
|
|
50
|
+
return false if path.start_with?("<") # "<internal:...>", "<compiled>", etc.
|
|
51
|
+
|
|
52
|
+
@excluded_prefixes.none? { |prefix| path.start_with?(prefix) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/orange_tap/config.rb
CHANGED
|
@@ -4,12 +4,28 @@ require "tmpdir"
|
|
|
4
4
|
|
|
5
5
|
module OrangeTap
|
|
6
6
|
class Config
|
|
7
|
-
attr_accessor :output_dir, :service_name, :otel_converter
|
|
7
|
+
attr_accessor :output_dir, :service_name, :otel_converter, :trace_c_methods, :trace_all_app_methods
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@output_dir = File.join(Dir.tmpdir, "orange_tap")
|
|
11
11
|
@service_name = "orange_tap"
|
|
12
12
|
@otel_converter = OrangeTap::OtelConverter
|
|
13
|
+
# Opt-in "trace everything" mode. When true, a session installs a single
|
|
14
|
+
# global :call/:return TracePoint that records every non-builtin Ruby
|
|
15
|
+
# method call in the process, instead of the per-method registry hooks.
|
|
16
|
+
# Built-ins are excluded by definition path (core internals + stdlib);
|
|
17
|
+
# gems ARE traced, and C methods are always excluded (:call never fires
|
|
18
|
+
# for them). This fires on every Ruby call process-wide, so it is heavy;
|
|
19
|
+
# see BuiltinFilter and README. It supersedes explicit trace_method
|
|
20
|
+
# registration when enabled.
|
|
21
|
+
@trace_all_app_methods = false
|
|
22
|
+
# Opt-in flag for tracing C-implemented methods. When false (default),
|
|
23
|
+
# registering a C method raises UntraceableMethodError, as before. When
|
|
24
|
+
# true, C methods are traced via a single global :c_call/:c_return
|
|
25
|
+
# TracePoint per session, filtered by [owner, name] inside the hook.
|
|
26
|
+
# This trades away the "zero overhead for unregistered methods"
|
|
27
|
+
# guarantee for every C call in the process. See TODO-c-support.md.
|
|
28
|
+
@trace_c_methods = false
|
|
13
29
|
end
|
|
14
30
|
end
|
|
15
31
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module OrangeTap
|
|
4
6
|
# Holds the set of methods to be traced, keyed by [owner, name] rather than
|
|
5
7
|
# by Method/UnboundMethod object identity. Method/UnboundMethod instances
|
|
@@ -17,6 +19,10 @@ module OrangeTap
|
|
|
17
19
|
|
|
18
20
|
def initialize
|
|
19
21
|
@entries = {}
|
|
22
|
+
# [owner, name] keys for C-implemented (ISeq-less) methods, traced via a
|
|
23
|
+
# global :c_call/:c_return TracePoint. Only populated when
|
|
24
|
+
# OrangeTap.config.trace_c_methods is enabled at registration time.
|
|
25
|
+
@c_entries = Set.new
|
|
20
26
|
@mutex = Mutex.new
|
|
21
27
|
end
|
|
22
28
|
|
|
@@ -40,19 +46,49 @@ module OrangeTap
|
|
|
40
46
|
@mutex.synchronize { @entries.values.dup }
|
|
41
47
|
end
|
|
42
48
|
|
|
49
|
+
# Snapshot of registered C-method keys ([owner, name]), taken once per
|
|
50
|
+
# Session#open. Empty unless trace_c_methods was enabled at registration
|
|
51
|
+
# time. Session uses this to decide whether to build a global C TracePoint.
|
|
52
|
+
def c_targets
|
|
53
|
+
@mutex.synchronize { @c_entries.dup }
|
|
54
|
+
end
|
|
55
|
+
|
|
43
56
|
private
|
|
44
57
|
|
|
45
58
|
def register_one(method_obj)
|
|
46
59
|
method_obj = resolve(method_obj)
|
|
47
60
|
iseq = RubyVM::InstructionSequence.of(method_obj)
|
|
48
|
-
|
|
61
|
+
return register_c_method(method_obj) unless iseq
|
|
49
62
|
|
|
50
63
|
@mutex.synchronize { @entries[key_for(method_obj)] = iseq }
|
|
51
64
|
end
|
|
52
65
|
|
|
66
|
+
# A method with no ISeq is C-implemented. Rejected by default; only
|
|
67
|
+
# accepted (into the global-hook set) when trace_c_methods is opted in.
|
|
68
|
+
def register_c_method(method_obj)
|
|
69
|
+
raise OrangeTap::UntraceableMethodError, method_obj.inspect unless OrangeTap.config.trace_c_methods
|
|
70
|
+
|
|
71
|
+
owner = method_obj.owner
|
|
72
|
+
# A C method that is a singleton method on a specific object (not a
|
|
73
|
+
# Class/Module) is unsupported: warn and skip rather than register it.
|
|
74
|
+
# Class/singleton methods ("Foo.bar") have a Module attached_object and
|
|
75
|
+
# are traced normally.
|
|
76
|
+
if owner.singleton_class? && !owner.attached_object.is_a?(Module)
|
|
77
|
+
warn "OrangeTap: singleton C method #{owner}##{method_obj.name} " \
|
|
78
|
+
"is not supported for C-method tracing; skipping"
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@mutex.synchronize { @c_entries << key_for(method_obj) }
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
def unregister_one(method_obj)
|
|
54
86
|
method_obj = resolve(method_obj)
|
|
55
|
-
|
|
87
|
+
key = key_for(method_obj)
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
@entries.delete(key)
|
|
90
|
+
@c_entries.delete(key)
|
|
91
|
+
end
|
|
56
92
|
end
|
|
57
93
|
|
|
58
94
|
# Accepts a Method/UnboundMethod as-is, or a notation String ("Foo.bar"
|
data/lib/orange_tap/session.rb
CHANGED
|
@@ -18,7 +18,7 @@ module OrangeTap
|
|
|
18
18
|
@tracepoint_targets = nil
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def open
|
|
21
|
+
def open(session_name = nil)
|
|
22
22
|
raise AlreadyOpenError if @queue
|
|
23
23
|
|
|
24
24
|
# Anchor monotonic time to wall-clock time once, at session start, so
|
|
@@ -30,18 +30,22 @@ module OrangeTap
|
|
|
30
30
|
|
|
31
31
|
@queue = Thread::Queue.new
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Start the worker before building the TracePoints so its Thread can be
|
|
34
|
+
# captured by the global app hook (which must skip the worker's own
|
|
35
|
+
# calls). Tracing is not active yet, so nothing the worker does now is
|
|
36
|
+
# recorded.
|
|
38
37
|
ctx = Worker::Context.new(
|
|
39
38
|
queue: @queue, config: @config, trace_id: trace_id,
|
|
40
|
-
start_mono_ns: start_mono_ns, start_unix_ns: start_unix_ns
|
|
39
|
+
start_mono_ns: start_mono_ns, start_unix_ns: start_unix_ns,
|
|
40
|
+
session_name: session_name
|
|
41
41
|
)
|
|
42
42
|
@worker_thread = Thread.new(ctx) { |worker_ctx| Worker.new(worker_ctx).run }
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# Entries are [tracepoint, iseq]; a nil iseq marks a global (targetless)
|
|
45
|
+
# hook, which is enabled without a target: below.
|
|
46
|
+
@tracepoint_targets = build_tracepoint_targets
|
|
47
|
+
|
|
48
|
+
@tracepoint_targets.each { |tp, iseq| iseq ? tp.enable(target: iseq) : tp.enable }
|
|
45
49
|
self
|
|
46
50
|
end
|
|
47
51
|
|
|
@@ -62,6 +66,27 @@ module OrangeTap
|
|
|
62
66
|
|
|
63
67
|
private
|
|
64
68
|
|
|
69
|
+
# Global "trace all app methods" mode supersedes per-method registration:
|
|
70
|
+
# a single :call/:return hook records every non-builtin Ruby method call.
|
|
71
|
+
# Otherwise, use the registry's per-ISeq hooks plus the optional global
|
|
72
|
+
# C-method hook. One TracePoint per target ISeq: whether a single
|
|
73
|
+
# TracePoint can safely enable(target:) more than one ISeq is
|
|
74
|
+
# version-dependent, so each ISeq gets its own instance.
|
|
75
|
+
def build_tracepoint_targets
|
|
76
|
+
if @config.trace_all_app_methods
|
|
77
|
+
return [[build_global_app_tracepoint(@queue, BuiltinFilter.new, @worker_thread), nil]]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
targets = @registry.targets.map { |iseq| [build_tracepoint(@queue), iseq] }
|
|
81
|
+
|
|
82
|
+
# Opt-in: a single global :c_call/:c_return TracePoint for any registered
|
|
83
|
+
# C methods, filtered inside the hook. Only added when there is at least
|
|
84
|
+
# one C method to trace, so the default path keeps zero C-call overhead.
|
|
85
|
+
c_targets = @registry.c_targets
|
|
86
|
+
targets << [build_c_tracepoint(@queue, c_targets), nil] unless c_targets.empty?
|
|
87
|
+
targets
|
|
88
|
+
end
|
|
89
|
+
|
|
65
90
|
def build_tracepoint(queue)
|
|
66
91
|
TracePoint.new(:call, :return) do |tp|
|
|
67
92
|
# Hook body stays minimal: push a single Event built from cheap
|
|
@@ -75,5 +100,46 @@ module OrangeTap
|
|
|
75
100
|
)
|
|
76
101
|
end
|
|
77
102
|
end
|
|
103
|
+
|
|
104
|
+
# Global hook for C methods. Fires on EVERY C call in the process, so the
|
|
105
|
+
# first thing it does is filter by [owner, name]; unregistered calls exit
|
|
106
|
+
# immediately. :c_call/:c_return are normalized to :call/:return so the
|
|
107
|
+
# Worker's event handling stays uniform. (TracePoint suppresses its own
|
|
108
|
+
# re-entry, so the C calls made inside this hook do not recurse.)
|
|
109
|
+
def build_c_tracepoint(queue, c_targets)
|
|
110
|
+
TracePoint.new(:c_call, :c_return) do |tp|
|
|
111
|
+
next unless c_targets.include?([tp.defined_class, tp.method_id])
|
|
112
|
+
|
|
113
|
+
queue << Event.new(
|
|
114
|
+
type: tp.event == :c_call ? :call : :return,
|
|
115
|
+
thread_id: Thread.current.object_id,
|
|
116
|
+
method_id: tp.method_id,
|
|
117
|
+
defined_class: tp.defined_class,
|
|
118
|
+
timestamp_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Global hook for the trace_all_app_methods mode. Fires on EVERY Ruby
|
|
124
|
+
# method call in the process (C methods never fire :call). Two cheap
|
|
125
|
+
# guards run first: skip the worker's own thread (so its bookkeeping and
|
|
126
|
+
# JSON writing are never traced, avoiding a feedback loop), and skip
|
|
127
|
+
# built-in definition paths via the memoized filter. TracePoint suppresses
|
|
128
|
+
# its own re-entry, so the Ruby calls in this body (filter.app_method?)
|
|
129
|
+
# do not recurse.
|
|
130
|
+
def build_global_app_tracepoint(queue, filter, worker_thread)
|
|
131
|
+
TracePoint.new(:call, :return) do |tp|
|
|
132
|
+
next if Thread.current == worker_thread
|
|
133
|
+
next unless filter.app_method?(tp.path)
|
|
134
|
+
|
|
135
|
+
queue << Event.new(
|
|
136
|
+
type: tp.event,
|
|
137
|
+
thread_id: Thread.current.object_id,
|
|
138
|
+
method_id: tp.method_id,
|
|
139
|
+
defined_class: tp.defined_class,
|
|
140
|
+
timestamp_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
78
144
|
end
|
|
79
145
|
end
|
data/lib/orange_tap/version.rb
CHANGED
data/lib/orange_tap/worker.rb
CHANGED
|
@@ -13,7 +13,9 @@ module OrangeTap
|
|
|
13
13
|
# tag on Event, because a Worker only ever drains events produced by the
|
|
14
14
|
# TracePoints of the Session that spawned it.
|
|
15
15
|
class Worker
|
|
16
|
-
Context = Data.define(:queue, :config, :trace_id, :start_mono_ns, :start_unix_ns)
|
|
16
|
+
Context = Data.define(:queue, :config, :trace_id, :start_mono_ns, :start_unix_ns, :session_name)
|
|
17
|
+
|
|
18
|
+
DEFAULT_SESSION_NAME = "orange_tap session"
|
|
17
19
|
|
|
18
20
|
def initialize(ctx)
|
|
19
21
|
@ctx = ctx
|
|
@@ -95,7 +97,7 @@ module OrangeTap
|
|
|
95
97
|
PendingSpan.new(
|
|
96
98
|
span_id: SecureRandom.hex(8),
|
|
97
99
|
parent_span_id: nil,
|
|
98
|
-
name:
|
|
100
|
+
name: @ctx.session_name || DEFAULT_SESSION_NAME,
|
|
99
101
|
thread_id: nil,
|
|
100
102
|
start_mono_ns: @ctx.start_mono_ns
|
|
101
103
|
)
|
data/lib/orange_tap.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "orange_tap/event"
|
|
|
5
5
|
require_relative "orange_tap/pending_span"
|
|
6
6
|
require_relative "orange_tap/otel_converter"
|
|
7
7
|
require_relative "orange_tap/config"
|
|
8
|
+
require_relative "orange_tap/builtin_filter"
|
|
8
9
|
require_relative "orange_tap/method_registry"
|
|
9
10
|
require_relative "orange_tap/worker"
|
|
10
11
|
require_relative "orange_tap/session"
|
|
@@ -46,9 +47,9 @@ module OrangeTap
|
|
|
46
47
|
default_registry.register_all_instance_methods(klass)
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
def open(&block)
|
|
50
|
+
def open(name = nil, &block)
|
|
50
51
|
tape = new
|
|
51
|
-
tape.open
|
|
52
|
+
tape.open(name)
|
|
52
53
|
return tape unless block
|
|
53
54
|
|
|
54
55
|
begin
|
|
@@ -66,4 +67,52 @@ module OrangeTap
|
|
|
66
67
|
raise
|
|
67
68
|
end
|
|
68
69
|
end
|
|
70
|
+
|
|
71
|
+
# A batteries-included wrapper around a single session, meant for the
|
|
72
|
+
# common "measure this block once" case (e.g. a Rails around_action):
|
|
73
|
+
#
|
|
74
|
+
# OrangeTap.record("checkout", trace_all_app_methods: true,
|
|
75
|
+
# on_output: ->(path) { Rails.logger.info(path) }) { do_work }
|
|
76
|
+
#
|
|
77
|
+
# It handles the two things every caller otherwise has to re-implement by
|
|
78
|
+
# hand around #open:
|
|
79
|
+
#
|
|
80
|
+
# * config_overrides are applied for the duration of the block and restored
|
|
81
|
+
# afterwards, even on error. OrangeTap.config is a process-global
|
|
82
|
+
# singleton, so a leaked `trace_all_app_methods = true` would keep every
|
|
83
|
+
# later session in the heaviest mode; this guarantees it is reset.
|
|
84
|
+
# * on_output is called with the written JSON path in the ensure, so the
|
|
85
|
+
# path is delivered on BOTH success and failure. (Block form #open cannot
|
|
86
|
+
# return the path when the block raises.) The trace file is written either
|
|
87
|
+
# way, since the worker drains on stop.
|
|
88
|
+
#
|
|
89
|
+
# Returns the output path on success; re-raises the original error on
|
|
90
|
+
# failure (an on_output that raises is swallowed so it never masks it).
|
|
91
|
+
def record(name = nil, on_output: nil, **config_overrides)
|
|
92
|
+
previous = config_overrides.to_h { |key, _| [key, config.public_send(key)] }
|
|
93
|
+
config_overrides.each { |key, value| config.public_send("#{key}=", value) }
|
|
94
|
+
|
|
95
|
+
tape = new
|
|
96
|
+
tape.open(name)
|
|
97
|
+
path = nil
|
|
98
|
+
begin
|
|
99
|
+
yield
|
|
100
|
+
path = tape.stop
|
|
101
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
102
|
+
path = begin
|
|
103
|
+
tape.stop
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
raise
|
|
108
|
+
ensure
|
|
109
|
+
previous.each { |key, value| config.public_send("#{key}=", value) }
|
|
110
|
+
begin
|
|
111
|
+
on_output&.call(path)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
path
|
|
117
|
+
end
|
|
69
118
|
end
|
data/sig/orange_tap.rbs
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
module OrangeTap
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class AlreadyOpenError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class NotOpenError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class UntraceableMethodError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A Method/UnboundMethod, or a "Foo#bar" / "Foo.bar" notation String.
|
|
17
|
+
type method_obj = Method | UnboundMethod | String
|
|
18
|
+
|
|
19
|
+
def self.new: (**untyped opts) -> Session
|
|
20
|
+
|
|
21
|
+
def self.default_registry: () -> MethodRegistry
|
|
22
|
+
|
|
23
|
+
def self.config: () -> Config
|
|
24
|
+
|
|
25
|
+
def self.trace_method: (*method_obj method_objs) -> void
|
|
26
|
+
|
|
27
|
+
def self.untrace_method: (*method_obj method_objs) -> void
|
|
28
|
+
|
|
29
|
+
def self.trace_all_instance_methods: (Class klass) -> void
|
|
30
|
+
|
|
31
|
+
# Block form returns the output path; blockless form returns the Session.
|
|
32
|
+
def self.open: (?String? name) ?{ () -> void } -> (String | Session)
|
|
33
|
+
|
|
34
|
+
# Runs the block in a single session, applying config_overrides for its
|
|
35
|
+
# duration (restored afterwards, even on error) and delivering the output
|
|
36
|
+
# path to on_output on both success and failure. Returns the path on success.
|
|
37
|
+
def self.record: (?String? name, ?on_output: (^(String?) -> void)?, **untyped config_overrides) { () -> void } -> String?
|
|
38
|
+
|
|
39
|
+
class Config
|
|
40
|
+
attr_accessor output_dir: String
|
|
41
|
+
attr_accessor service_name: String
|
|
42
|
+
attr_accessor otel_converter: untyped
|
|
43
|
+
attr_accessor trace_c_methods: bool
|
|
44
|
+
attr_accessor trace_all_app_methods: bool
|
|
45
|
+
|
|
46
|
+
def initialize: () -> void
|
|
47
|
+
end
|
|
4
48
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: orange_tap
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Uchio Kondo
|
|
@@ -26,8 +26,11 @@ files:
|
|
|
26
26
|
- Rakefile
|
|
27
27
|
- examples/jaeger-screenshot.png
|
|
28
28
|
- examples/order_demo.rb
|
|
29
|
+
- examples/sinatra_trace_all_demo.rb
|
|
29
30
|
- examples/trace-example.json
|
|
31
|
+
- examples/trace_all_app_demo.rb
|
|
30
32
|
- lib/orange_tap.rb
|
|
33
|
+
- lib/orange_tap/builtin_filter.rb
|
|
31
34
|
- lib/orange_tap/config.rb
|
|
32
35
|
- lib/orange_tap/event.rb
|
|
33
36
|
- lib/orange_tap/method_registry.rb
|