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.
@@ -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
@@ -0,0 +1,4 @@
1
+ module OrangeTap
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []