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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module OrangeTap
|
|
8
|
+
# Runs on a dedicated Thread created by Session#open. Drains the session's
|
|
9
|
+
# Queue, reconstructs a 3-layer span tree (session root / per-thread /
|
|
10
|
+
# per-method) from CALL/RETURN pairs, and writes a single OTLP/JSON file.
|
|
11
|
+
#
|
|
12
|
+
# The Queue instance itself is the session boundary: there is no session_id
|
|
13
|
+
# tag on Event, because a Worker only ever drains events produced by the
|
|
14
|
+
# TracePoints of the Session that spawned it.
|
|
15
|
+
class Worker
|
|
16
|
+
Context = Data.define(:queue, :config, :trace_id, :start_mono_ns, :start_unix_ns)
|
|
17
|
+
|
|
18
|
+
def initialize(ctx)
|
|
19
|
+
@ctx = ctx
|
|
20
|
+
@stacks = Hash.new { |h, k| h[k] = [] } # thread_id => [PendingSpan, ...]
|
|
21
|
+
@thread_spans = {} # thread_id => PendingSpan
|
|
22
|
+
@completed = [] # confirmed method-layer spans
|
|
23
|
+
@session_span = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the full path of the written JSON file. Session#stop receives
|
|
27
|
+
# this via Thread#value.
|
|
28
|
+
def run
|
|
29
|
+
@session_span = new_session_span
|
|
30
|
+
|
|
31
|
+
loop do
|
|
32
|
+
event = @ctx.queue.pop
|
|
33
|
+
break if event.nil? # Queue#close makes pop return nil once drained
|
|
34
|
+
|
|
35
|
+
case event.type
|
|
36
|
+
when :call then on_call(event)
|
|
37
|
+
when :return then on_return(event)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
finalize_dangling_spans
|
|
42
|
+
write_json
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def on_call(event)
|
|
48
|
+
thread_span = thread_span_for(event.thread_id)
|
|
49
|
+
stack = @stacks[event.thread_id]
|
|
50
|
+
parent_id = stack.empty? ? thread_span.span_id : stack.last.span_id
|
|
51
|
+
|
|
52
|
+
span = PendingSpan.new(
|
|
53
|
+
span_id: SecureRandom.hex(8),
|
|
54
|
+
parent_span_id: parent_id,
|
|
55
|
+
name: method_name(event.defined_class, event.method_id),
|
|
56
|
+
thread_id: event.thread_id,
|
|
57
|
+
start_mono_ns: event.timestamp_ns
|
|
58
|
+
)
|
|
59
|
+
stack.push(span)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_return(event)
|
|
63
|
+
span = @stacks[event.thread_id].pop
|
|
64
|
+
return unless span # RETURN with no matching CALL is silently ignored
|
|
65
|
+
|
|
66
|
+
span.end_mono_ns = event.timestamp_ns
|
|
67
|
+
@completed << span
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Any CALL still on a stack when the queue closes never got its RETURN
|
|
71
|
+
# (e.g. session stopped mid-call). Force-close it as incomplete so it is
|
|
72
|
+
# still represented in the output rather than silently dropped.
|
|
73
|
+
def finalize_dangling_spans
|
|
74
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
75
|
+
@stacks.each_value do |stack|
|
|
76
|
+
while (span = stack.pop)
|
|
77
|
+
span.end_mono_ns ||= now
|
|
78
|
+
span.incomplete = true
|
|
79
|
+
@completed << span
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def thread_span_for(thread_id)
|
|
85
|
+
@thread_spans[thread_id] ||= PendingSpan.new(
|
|
86
|
+
span_id: SecureRandom.hex(8),
|
|
87
|
+
parent_span_id: @session_span.span_id,
|
|
88
|
+
name: "tid=#{thread_id}",
|
|
89
|
+
thread_id: thread_id,
|
|
90
|
+
start_mono_ns: @ctx.start_mono_ns
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def new_session_span
|
|
95
|
+
PendingSpan.new(
|
|
96
|
+
span_id: SecureRandom.hex(8),
|
|
97
|
+
parent_span_id: nil,
|
|
98
|
+
name: "orange_tap session",
|
|
99
|
+
thread_id: nil,
|
|
100
|
+
start_mono_ns: @ctx.start_mono_ns
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Class/singleton methods render as "Owner.method"; instance methods as
|
|
105
|
+
# "Owner#method". defined_class for a singleton method is the singleton
|
|
106
|
+
# class itself, whose #inspect is "#<Class:Owner>".
|
|
107
|
+
def method_name(defined_class, method_id)
|
|
108
|
+
if defined_class.respond_to?(:singleton_class?) && defined_class.singleton_class?
|
|
109
|
+
owner = defined_class.inspect[/\A#<Class:(.+)>\z/, 1] || defined_class.inspect
|
|
110
|
+
"#{owner}.#{method_id}"
|
|
111
|
+
else
|
|
112
|
+
"#{defined_class}##{method_id}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def write_json
|
|
117
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
118
|
+
@session_span.end_mono_ns = now
|
|
119
|
+
@thread_spans.each_value { |ts| ts.end_mono_ns ||= now }
|
|
120
|
+
|
|
121
|
+
spans = [@session_span, *@thread_spans.values, *@completed]
|
|
122
|
+
document = @ctx.config.otel_converter.build_document(
|
|
123
|
+
spans: spans,
|
|
124
|
+
trace_id: @ctx.trace_id,
|
|
125
|
+
start_mono_ns: @ctx.start_mono_ns,
|
|
126
|
+
start_unix_ns: @ctx.start_unix_ns,
|
|
127
|
+
service_name: @ctx.config.service_name
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
FileUtils.mkdir_p(@ctx.config.output_dir)
|
|
131
|
+
path = File.join(@ctx.config.output_dir, "orange_tap-#{@ctx.trace_id}.json")
|
|
132
|
+
File.write(path, JSON.generate(document))
|
|
133
|
+
path
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/orange_tap.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "orange_tap/version"
|
|
4
|
+
require_relative "orange_tap/event"
|
|
5
|
+
require_relative "orange_tap/pending_span"
|
|
6
|
+
require_relative "orange_tap/otel_converter"
|
|
7
|
+
require_relative "orange_tap/config"
|
|
8
|
+
require_relative "orange_tap/method_registry"
|
|
9
|
+
require_relative "orange_tap/worker"
|
|
10
|
+
require_relative "orange_tap/session"
|
|
11
|
+
|
|
12
|
+
module OrangeTap
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class AlreadyOpenError < Error; end
|
|
15
|
+
class NotOpenError < Error; end
|
|
16
|
+
class UntraceableMethodError < Error; end
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# OrangeTap.new -> Session, so `tape = OrangeTap.new; tape.open; ...; tape.stop`
|
|
21
|
+
# reads like constructing a recorder, while OrangeTap itself stays a module.
|
|
22
|
+
def new(**opts)
|
|
23
|
+
Session.new(**opts)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def default_registry
|
|
27
|
+
@default_registry ||= MethodRegistry.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Config.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Accepts one or more Method/UnboundMethod objects, or notation strings
|
|
35
|
+
# ("Foo.bar" for a class/singleton method, "Foo#bar" for an instance
|
|
36
|
+
# method), and registers them all in a single call.
|
|
37
|
+
def trace_method(*method_objs)
|
|
38
|
+
default_registry.register(*method_objs)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def untrace_method(*method_objs)
|
|
42
|
+
default_registry.unregister(*method_objs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def trace_all_instance_methods(klass)
|
|
46
|
+
default_registry.register_all_instance_methods(klass)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def open(&block)
|
|
50
|
+
tape = new
|
|
51
|
+
tape.open
|
|
52
|
+
return tape unless block
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
block.call
|
|
56
|
+
tape.stop
|
|
57
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
58
|
+
# Make sure TracePoints are disabled and the worker is drained even
|
|
59
|
+
# when the block raises. The output path is unrecoverable here, so we
|
|
60
|
+
# re-raise the original error instead of returning it.
|
|
61
|
+
begin
|
|
62
|
+
tape.stop
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/sig/orange_tap.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: orange_tap
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Uchio Kondo
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: OrangeTap hooks Ruby method calls (call/return) with TracePoint, assembles
|
|
13
|
+
them into OpenTelemetry-style spans on a background thread inside the same process,
|
|
14
|
+
and writes the result as an OTLP/JSON file.
|
|
15
|
+
email:
|
|
16
|
+
- uchio.kondo@smarthr.co.jp
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- ".ruby-version"
|
|
22
|
+
- CODE_OF_CONDUCT.md
|
|
23
|
+
- DESIGN.md
|
|
24
|
+
- LICENSE.txt
|
|
25
|
+
- README.md
|
|
26
|
+
- Rakefile
|
|
27
|
+
- examples/jaeger-screenshot.png
|
|
28
|
+
- examples/order_demo.rb
|
|
29
|
+
- examples/trace-example.json
|
|
30
|
+
- lib/orange_tap.rb
|
|
31
|
+
- lib/orange_tap/config.rb
|
|
32
|
+
- lib/orange_tap/event.rb
|
|
33
|
+
- lib/orange_tap/method_registry.rb
|
|
34
|
+
- lib/orange_tap/otel_converter.rb
|
|
35
|
+
- lib/orange_tap/pending_span.rb
|
|
36
|
+
- lib/orange_tap/session.rb
|
|
37
|
+
- lib/orange_tap/version.rb
|
|
38
|
+
- lib/orange_tap/worker.rb
|
|
39
|
+
- sig/orange_tap.rbs
|
|
40
|
+
homepage: https://github.com/udzura/orange_tap
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
homepage_uri: https://github.com/udzura/orange_tap
|
|
45
|
+
source_code_uri: https://github.com/udzura/orange_tap
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 3.2.0
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 4.0.10
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Trace Ruby method calls with TracePoint and export the result as OpenTelemetry
|
|
63
|
+
spans — no daemon, no shared memory, single process only.
|
|
64
|
+
test_files: []
|