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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98d5bfa7c39f556f24bccb7eb24e8cb76ca2d0ae53d892fa834d050956d37fc0
4
- data.tar.gz: 5db4e456e34c6aa0cec5bff1682d2cce332a0ff448eede068ba60591b40e10ec
3
+ metadata.gz: ca009ebd31ae0ff1849d7975d602f1f87dccbada30458cd4943e6a526aa35647
4
+ data.tar.gz: 662aa3e960659b36c5febb1b1f59e2d39d2010146a208584155f7e860206b77a
5
5
  SHA512:
6
- metadata.gz: bf88ab0dfacc03ca122b46973c6101ed6ebdfa293667bd1ec8c41e113cd810e82c6973c781f577fff1e0233d1e65fab51cffe338a210dbe48eded8701fcd43be
7
- data.tar.gz: 925d22853cf17afdaecdda5091a6c8b2c0feda44b57d0599f581180aa5c540234e9407b8ab270415e46776eeb62daec61de687fc1206bf027918198d20fee310
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,
@@ -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
@@ -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
- raise OrangeTap::UntraceableMethodError, method_obj.inspect unless iseq
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
- @mutex.synchronize { @entries.delete(key_for(method_obj)) }
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"
@@ -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
- # 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
-
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
- @tracepoint_targets.each { |tp, iseq| tp.enable(target: iseq) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OrangeTap
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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: "orange_tap session",
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
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
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.1.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