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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 98d5bfa7c39f556f24bccb7eb24e8cb76ca2d0ae53d892fa834d050956d37fc0
4
+ data.tar.gz: 5db4e456e34c6aa0cec5bff1682d2cce332a0ff448eede068ba60591b40e10ec
5
+ SHA512:
6
+ metadata.gz: bf88ab0dfacc03ca122b46973c6101ed6ebdfa293667bd1ec8c41e113cd810e82c6973c781f577fff1e0233d1e65fab51cffe338a210dbe48eded8701fcd43be
7
+ data.tar.gz: 925d22853cf17afdaecdda5091a6c8b2c0feda44b57d0599f581180aa5c540234e9407b8ab270415e46776eeb62daec61de687fc1206bf027918198d20fee310
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.5
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "orange_tap_" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["uchio.kondo@smarthr.co.jp"](mailto:"uchio.kondo@smarthr.co.jp").
data/DESIGN.md ADDED
@@ -0,0 +1,628 @@
1
+ # Ruby メソッドトレーシング Gem "OrangeTap" 実装プラン
2
+
3
+ ## 1. 目的
4
+
5
+ Rubyプロセス内のメソッド呼び出し(call/return)を `TracePoint` でフックし、
6
+ 同一プロセス内のバックグラウンドスレッドで非同期に Span へ組み立て、
7
+ OpenTelemetry(OTel) 形式の JSON ファイルとして出力する Pure Ruby gem を作成する。
8
+
9
+ **中央デーモンプロセス・共有メモリ・プロセス間通信は一切使用しない。**
10
+
11
+ - 対象OS: 制約なし(Pure Ruby 実装)。
12
+ - 対象Ruby: MRI (CRuby) 4.0 系を主対象として開発・テストする。
13
+ `TracePoint#enable(target:)` は 2.6+、`Data.define` は 3.2+ で利用可能なため、
14
+ gemspec の `required_ruby_version` は `>= 3.2.0` とする(= 3.2 以降で動作するが、
15
+ CI での検証は 4.0 系を主とする)。C レベル `trace_func` の既知バグ回避のため
16
+ 意図的に Ruby レベル `TracePoint` を採用する点は README に明記する。
17
+
18
+ ---
19
+
20
+ ## 2. この改訂で確定した設計判断(旧「残課題」の解決)
21
+
22
+ 精査の結果、旧 DESIGN.md の前提のうち **「既存の payload→OTel 変換実装をそのまま流用する」
23
+ が実態と食い違っていた**ため、以下を確定した。
24
+
25
+ 1. **OTel 変換は自己完結の内蔵 Writer とする(Vivarium 非依存)。**
26
+ - Vivarium の `OtelExporter`(`lib/vivarium/otel_exporter.rb`)は「1 Span → 1 OTel Span」の
27
+ 変換器ではなく、**BPF イベントストリーム全体 + meta を受け取り、Span 組み立て(CALL/RETURN
28
+ スタック管理)を内部でやり直す**構造で、`ev.ktime_ns / ev.tid / ev.trace_hi` や
29
+ `Vivarium.synth_span_id`、BPF ペイロードのバイナリデコード等に密結合している。
30
+ OrangeTap の Worker と役割が重複し、そのままでは呼び出せない。
31
+ - よって **OTLP/JSON の出力フォーマット規約のみを踏襲**し、OrangeTap 内に
32
+ `OrangeTap::OtelConverter` を新規実装する。`vivarium` gem への依存は持たない
33
+ (Pure Ruby・OS 非依存という目的と矛盾するため)。
34
+ - 将来の差し替えのため、`OrangeTap.config.otel_converter` で変換器を注入できる
35
+ アダプタ継ぎ目(seam)を用意する(デフォルトは `OrangeTap::OtelConverter`)。
36
+
37
+ 2. **Span 階層はセッションroot + スレッド + メソッドの3層とする(Vivarium と同型)。**
38
+ - 1 セッション = 1 トレース(`trace_id` はセッションごとに 1 個生成)。
39
+ - `session root span` を 1 個、`thread span`(tid ごと)を 0..N 個、その配下に
40
+ `method span` を配置する。トップレベルのメソッドはそのスレッドの thread span を親とし、
41
+ `parent` の付かない孤立 root が複数生じないようにする。
42
+
43
+ 3. **gem 名は `orange_tap`(末尾アンダースコアなし)に統一する。**
44
+ - 現状スキャフォールドは `orange_tap_` になっており、実装前に rename する(§8)。
45
+
46
+ 4. **時刻は「セッション開始時の壁時計 unix ns」でアンカーして OTLP の絶対時刻に変換する。**
47
+ - フック内では `Process.clock_gettime(CLOCK_MONOTONIC, :nanosecond)` のみを記録し(安価・単調)、
48
+ セッション開始時に一度だけ `CLOCK_MONOTONIC` と `CLOCK_REALTIME`(=壁時計 unix ns)を
49
+ 同時取得してアンカーとする。OTLP 出力時に
50
+ `unix_ns = start_unix_ns + (mono_ns - start_mono_ns)` で換算する。
51
+ (旧 DESIGN は monotonic ns をそのまま出力する想定で、OTel の絶対時刻が不正になる欠陥があった。)
52
+
53
+ 5. **スレッドをまたぐ非同期処理(`Thread.new`/`Fiber`/`Ractor`)の親子関係追跡は本バージョンではスコープ外。**
54
+ - `thread_id` 単位のスタックで完結させる。将来 `Thread.current[:orange_tap_parent_span_id]`
55
+ 等での伝播を検討(§9)。
56
+
57
+ 6. **Worker 例外は `Thread#value` の標準動作(再送出)に委ねる。**
58
+ - ただし `stop` は「TracePoint の disable → Queue close → `Thread#value`」の順で行い、
59
+ Worker が途中で例外を投げても **TracePoint は必ず先に disable 済み**になるようにする
60
+ (フックのリークを防ぐ)。
61
+
62
+ 7. **並行セッションの二重フックオーバーヘッドは v1 では許容し、README に明記する。**
63
+ - 各セッションが独立した TracePoint 群を持つため、同一メソッドを複数セッションが対象にすると
64
+ そのメソッドに複数 TracePoint が enable される。v1 はシンプルさを優先し許容する。
65
+
66
+ ---
67
+
68
+ ## 3. アーキテクチャ全体像
69
+
70
+ ```
71
+ ┌──────────────────────────────────────────────────────────────┐
72
+ │ 監視対象 Rubyプロセス(1プロセス内で完結・中央デーモンなし) │
73
+ │ │
74
+ │ OrangeTap.trace_method(method_obj) ← 静的登録: 対象メソッドのISeq │
75
+ │ │ │
76
+ │ tape = OrangeTap.open / OrangeTap.open do ... end │
77
+ │ │ (open時にアンカー時刻取得 → trace_id生成 → Worker起動) │
78
+ │ ▼ │
79
+ │ TracePoint(:call,:return).enable(target: iseq) ×対象ISeq個数 │
80
+ │ │ フック内: Event(Data) を1個生成し Queue へ push するのみ │
81
+ │ ▼ │
82
+ │ Thread::Queue(セッション専用・スレッドセーフFIFO) │
83
+ │ ▼ │
84
+ │ Worker スレッド(セッション専用) │
85
+ │ - tid 単位で CALL/RETURN スタック管理 → PendingSpan 生成 │
86
+ │ - Queue#close で終了 → dangling span を強制クローズ │
87
+ │ - session root / thread / method の3層 Span を OtelConverter へ │
88
+ │ - OTLP/JSON を1ファイルに書き出し、そのパスを run の戻り値に │
89
+ │ ▼ │
90
+ │ tape.stop / open{...} の戻り値 = 出力JSONファイルパス(String) │
91
+ └──────────────────────────────────────────────────────────────┘
92
+ ```
93
+
94
+ **設計の核心:** `open` のたびにセッション専用の `Queue` と `Worker` スレッドを新規生成する。
95
+ これにより「どのイベントがどのセッションに属するか」のタグ付け(旧設計の `session_id`)が不要になり、
96
+ **Queue インスタンス自体がセッション境界**を表す。`stop` は `Queue#close` を終端シグナルにし、
97
+ `Thread#value` で Worker の完了(Span 確定・JSON 書き出し完了)を待って戻り値(パス)を得る。
98
+
99
+ ---
100
+
101
+ ## 4. コンポーネント詳細
102
+
103
+ ### 4.1 Event(軽量・イミュータブル) — `lib/orange_tap/event.rb`
104
+
105
+ ```ruby
106
+ module OrangeTap
107
+ # フック内で1個だけ生成する軽量イベント。tp.binding / tp.parameters は取得しない。
108
+ # defined_class は Module 参照をそのまま持ち、文字列化は Worker 側で行う(フックを軽く保つ)。
109
+ Event = Data.define(:type, :thread_id, :method_id, :defined_class, :timestamp_ns)
110
+ # type: :call | :return
111
+ end
112
+ ```
113
+
114
+ - `timestamp_ns` は `Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)`。
115
+ - `thread_id` は `Thread.current.object_id`(C レベル・安価。生存中スレッドの識別には十分)。
116
+
117
+ ### 4.2 対象メソッド登録 — `lib/orange_tap/method_registry.rb`
118
+
119
+ 公開 API(`OrangeTap` トップレベルに委譲メソッドを置く):
120
+
121
+ ```ruby
122
+ OrangeTap.trace_method(SomeClass.instance_method(:foo)) # インスタンスメソッド(UnboundMethod)
123
+ OrangeTap.trace_method(SomeClass.method(:bar)) # クラス/特異メソッド(Method)
124
+ OrangeTap.trace_method(obj.method(:baz)) # 特定オブジェクトの特異メソッド(Method)
125
+ OrangeTap.trace_all_instance_methods(SomeClass) # klass.instance_methods(false) を一括
126
+ OrangeTap.untrace_method(SomeClass.instance_method(:foo))
127
+ ```
128
+
129
+ 要点:
130
+
131
+ - 引数は `Method` / `UnboundMethod` を **1 個直接**受け取る(`(klass, name)` 2 引数にしない)。
132
+ これにより特異メソッドも統一 I/F で登録できる。いずれでもなければ `ArgumentError`。
133
+ - ISeq は `RubyVM::InstructionSequence.of(method_obj)` で取得。取得できない
134
+ (C 実装メソッド等)場合は `OrangeTap::UntraceableMethodError`。
135
+ - **登録キーはメソッドとしての同一性で作る**(Method/UnboundMethod は呼び出しごとに別オブジェクト)。
136
+ キー = `[method_obj.owner, method_obj.name]`。
137
+ - インスタンスメソッド: owner はクラス/モジュール。
138
+ - クラスメソッド: owner は `#<Class:SomeClass>`(特異クラス)。
139
+ - 特定オブジェクトの特異メソッド: owner はそのオブジェクトの特異クラス(オブジェクトごとに別)。
140
+ いずれも `[owner, name]` で一意に識別・解除できる。
141
+ - レジストリは `{ key => iseq }` を保持。`untrace_method` は同じキーで削除。重複登録は upsert で吸収。
142
+ - `OrangeTap.default_registry` はプロセスグローバル。登録・解除は `Mutex` で保護する。
143
+ `open` 時に `targets`(= ISeq の配列)のスナップショットを取り、以後そのセッションは固定。
144
+
145
+ ```ruby
146
+ module OrangeTap
147
+ class MethodRegistry
148
+ def initialize
149
+ @entries = {} # [owner, name] => iseq
150
+ @mutex = Mutex.new
151
+ end
152
+
153
+ def register(method_obj)
154
+ unless method_obj.is_a?(Method) || method_obj.is_a?(UnboundMethod)
155
+ raise ArgumentError, "Method / UnboundMethod を渡してください: #{method_obj.inspect}"
156
+ end
157
+ iseq = RubyVM::InstructionSequence.of(method_obj)
158
+ raise OrangeTap::UntraceableMethodError, method_obj.inspect unless iseq
159
+ @mutex.synchronize { @entries[key_for(method_obj)] = iseq }
160
+ end
161
+
162
+ def unregister(method_obj)
163
+ @mutex.synchronize { @entries.delete(key_for(method_obj)) }
164
+ end
165
+
166
+ def register_all_instance_methods(klass)
167
+ klass.instance_methods(false).each { |m| register(klass.instance_method(m)) }
168
+ end
169
+
170
+ def targets # ISeq のスナップショット
171
+ @mutex.synchronize { @entries.values.dup }
172
+ end
173
+
174
+ private
175
+
176
+ def key_for(m) = [m.owner, m.name]
177
+ end
178
+ end
179
+ ```
180
+
181
+ ### 4.3 セッション制御 — `lib/orange_tap/session.rb` と `lib/orange_tap.rb`
182
+
183
+ 公開 API:
184
+
185
+ ```ruby
186
+ # ブロック形式(内部でインスタンス形式を利用するシンタックスシュガー)
187
+ path = OrangeTap.open do
188
+ # この区間の CALL/RETURN が1セッションとして記録される
189
+ end
190
+ # => 出力JSONファイルパス(String)
191
+
192
+ # インスタンス形式
193
+ tape = OrangeTap.new # OrangeTap.new は Session.new を返すモジュールメソッド
194
+ tape.open
195
+ # ...
196
+ path = tape.stop # => 出力JSONファイルパス(String)
197
+ ```
198
+
199
+ トップレベル(`lib/orange_tap.rb`):
200
+
201
+ ```ruby
202
+ module OrangeTap
203
+ class Error < StandardError; end
204
+ class AlreadyOpenError < Error; end
205
+ class NotOpenError < Error; end
206
+ class UntraceableMethodError < Error; end
207
+
208
+ module_function
209
+
210
+ def new(**opts) = Session.new(**opts) # tape = OrangeTap.new
211
+ def default_registry = (@default_registry ||= MethodRegistry.new)
212
+ def config = (@config ||= Config.new)
213
+
214
+ def trace_method(m) = default_registry.register(m)
215
+ def untrace_method(m) = default_registry.unregister(m)
216
+ def trace_all_instance_methods(k)= default_registry.register_all_instance_methods(k)
217
+
218
+ def open(&block)
219
+ tape = new
220
+ tape.open
221
+ return tape unless block
222
+ begin
223
+ block.call
224
+ tape.stop # 正常時: パスを返す
225
+ rescue Exception => e # 異常時: 後始末してから元例外を再送出
226
+ tape.stop rescue nil # TracePoint の disable を必ず実行(パスは失われてよい)
227
+ raise e
228
+ end
229
+ end
230
+ end
231
+ ```
232
+
233
+ `Session`:
234
+
235
+ ```ruby
236
+ module OrangeTap
237
+ class Session
238
+ def initialize(registry: OrangeTap.default_registry, config: OrangeTap.config)
239
+ @registry = registry
240
+ @config = config
241
+ @queue = nil
242
+ @worker = nil
243
+ @tracepoints = nil
244
+ end
245
+
246
+ def open
247
+ raise AlreadyOpenError if @queue
248
+
249
+ # セッション開始アンカー: monotonic と wall-clock を同時取得
250
+ start_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
251
+ start_unix = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
252
+ trace_id = SecureRandom.hex(16) # 16バイト = 32 hex
253
+
254
+ @queue = Thread::Queue.new
255
+ # 対象 ISeq ごとに TracePoint を1個生成(1 TP に複数 target を混ぜない)
256
+ @tracepoints = @registry.targets.map { |iseq| build_tracepoint(iseq, @queue) }
257
+
258
+ worker_ctx = Worker::Context.new(
259
+ queue: @queue, config: @config, trace_id: trace_id,
260
+ start_mono_ns: start_mono, start_unix_ns: start_unix
261
+ )
262
+ @worker = Thread.new(worker_ctx) { |ctx| Worker.new(ctx).run }
263
+
264
+ @tracepoints.each(&:enable) # target: は生成時に紐付け済み
265
+ self
266
+ end
267
+
268
+ def stop
269
+ raise NotOpenError unless @queue
270
+ @tracepoints.each(&:disable) # 1) 先にフックを止める(例外時もリークさせない)
271
+ @queue.close # 2) Worker ループの終端シグナル
272
+ path = @worker.value # 3) Worker 完了を待ち、戻り値(パス)を得る(例外は再送出)
273
+ @queue = nil
274
+ path
275
+ end
276
+
277
+ private
278
+
279
+ def build_tracepoint(iseq, queue)
280
+ tp = TracePoint.new(:call, :return) do |t|
281
+ # フック内は最小限: 数値/シンボル/Module参照のみで Event を1個作り push
282
+ queue << Event.new(
283
+ type: t.event, # :call | :return
284
+ thread_id: Thread.current.object_id,
285
+ method_id: t.method_id,
286
+ defined_class: t.defined_class,
287
+ timestamp_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
288
+ )
289
+ end
290
+ tp.enable(target: iseq) { } # 実際の有効化は open 内の each(&:enable) で行うため、
291
+ tp.disable # ここでは target 紐付けのみ確定させて即 disable する
292
+ tp
293
+ end
294
+ end
295
+ end
296
+ ```
297
+
298
+ > 注: `TracePoint#enable(target:)` は「target を紐付けて有効化」する API。上記のように
299
+ > `enable(target:)`→`disable` で target を固定してから `open` で一括 enable する方式か、
300
+ > あるいは `@tracepoints` を `[tp, iseq]` の組で保持して `open`/`stop` で
301
+ > `tp.enable(target: iseq)` / `tp.disable` する方式のいずれか、実装時に安定する方を採用する
302
+ > (target 再指定の可否は Ruby バージョン依存の余地があるため、テストで確認する)。
303
+
304
+ 設計ポイント:
305
+
306
+ - **二重 `open` は `AlreadyOpenError`、未 `open` の `stop` は `NotOpenError`。**(1 インスタンス 1 セッション)
307
+ - ブロック形式は例外時も `ensure` 相当で `stop`(= disable)を必ず実行。ただし例外時は
308
+ 正常なパスを返せないため、**元の例外をそのまま再送出**する(PROMPT 準拠)。
309
+ - **並行セッション**は各々独立した Queue/Thread/TracePoint 群を持つため干渉しない。
310
+ グローバル可変状態は `default_registry` と `config` のみで、どちらも読み取りは open 時スナップショット。
311
+
312
+ ### 4.4 Worker — `lib/orange_tap/worker.rb`
313
+
314
+ ```ruby
315
+ module OrangeTap
316
+ class Worker
317
+ Context = Data.define(:queue, :config, :trace_id, :start_mono_ns, :start_unix_ns)
318
+
319
+ def initialize(ctx)
320
+ @ctx = ctx
321
+ @stacks = Hash.new { |h, k| h[k] = [] } # thread_id => [PendingSpan,...]
322
+ @thread_spans = {} # thread_id => PendingSpan(thread層)
323
+ @completed = [] # method層の確定 PendingSpan
324
+ @session_span = nil
325
+ end
326
+
327
+ # 戻り値: 出力した JSON ファイルのフルパス(Session が Thread#value で受け取る)
328
+ def run
329
+ @session_span = new_session_span
330
+ loop do
331
+ ev = @ctx.queue.pop
332
+ break if ev.nil? # Queue#close で pop が nil を返したら終了
333
+ case ev.type
334
+ when :call then on_call(ev)
335
+ when :return then on_return(ev)
336
+ end
337
+ end
338
+ finalize_dangling
339
+ write_json
340
+ end
341
+
342
+ private
343
+
344
+ def on_call(ev)
345
+ ts = thread_span_for(ev.thread_id)
346
+ stack = @stacks[ev.thread_id]
347
+ parent_id = stack.empty? ? ts.span_id : stack.last.span_id
348
+ span = PendingSpan.new(
349
+ span_id: SecureRandom.hex(8), # 8バイト = 16 hex
350
+ parent_span_id: parent_id,
351
+ name: method_name(ev.defined_class, ev.method_id),
352
+ thread_id: ev.thread_id,
353
+ start_mono_ns: ev.timestamp_ns
354
+ )
355
+ stack.push(span)
356
+ end
357
+
358
+ def on_return(ev)
359
+ span = @stacks[ev.thread_id].pop
360
+ return unless span # 対応 CALL が無い RETURN は無視(エラーにしない)
361
+ span.end_mono_ns = ev.timestamp_ns
362
+ @completed << span
363
+ end
364
+
365
+ def finalize_dangling
366
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
367
+ @stacks.each_value do |stack|
368
+ while (span = stack.pop)
369
+ span.end_mono_ns ||= now
370
+ span.incomplete = true # RETURN 未着を明示(OTel 属性に反映)
371
+ @completed << span
372
+ end
373
+ end
374
+ end
375
+
376
+ def thread_span_for(tid)
377
+ @thread_spans[tid] ||= PendingSpan.new(
378
+ span_id: SecureRandom.hex(8),
379
+ parent_span_id: @session_span.span_id,
380
+ name: "tid=#{tid}",
381
+ thread_id: tid,
382
+ start_mono_ns: @ctx.start_mono_ns # end は finalize 時に確定
383
+ )
384
+ end
385
+
386
+ def new_session_span
387
+ PendingSpan.new(
388
+ span_id: SecureRandom.hex(8),
389
+ parent_span_id: nil, # ルート(parentSpanId 省略)
390
+ name: "orange_tap session",
391
+ thread_id: nil,
392
+ start_mono_ns: @ctx.start_mono_ns
393
+ )
394
+ end
395
+
396
+ # クラスメソッド(特異クラス)は "Owner.method"、それ以外は "Owner#method"
397
+ def method_name(defined_class, method_id)
398
+ if defined_class.respond_to?(:singleton_class?) && defined_class.singleton_class?
399
+ owner = defined_class.inspect[/#<Class:(.+)>/, 1] || defined_class.inspect
400
+ "#{owner}.#{method_id}"
401
+ else
402
+ "#{defined_class}##{method_id}"
403
+ end
404
+ end
405
+
406
+ def write_json
407
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
408
+ @session_span.end_mono_ns = now
409
+ @thread_spans.each_value { |ts| ts.end_mono_ns ||= now }
410
+
411
+ spans = [@session_span, *@thread_spans.values, *@completed]
412
+ converter = @ctx.config.otel_converter # 差し替え可能な継ぎ目
413
+ document = converter.build_document(
414
+ spans: spans,
415
+ trace_id: @ctx.trace_id,
416
+ start_mono_ns: @ctx.start_mono_ns,
417
+ start_unix_ns: @ctx.start_unix_ns,
418
+ service_name: @ctx.config.service_name
419
+ )
420
+ FileUtils.mkdir_p(@ctx.config.output_dir)
421
+ path = File.join(@ctx.config.output_dir,
422
+ "orange_tap-#{Time.now.to_i}-#{SecureRandom.hex(4)}.json")
423
+ File.write(path, JSON.generate(document))
424
+ path
425
+ end
426
+ end
427
+ end
428
+ ```
429
+
430
+ ### 4.5 PendingSpan(中間表現) — `lib/orange_tap/pending_span.rb`
431
+
432
+ ```ruby
433
+ module OrangeTap
434
+ # モノトニック ns で保持する組み立て中の Span。OTLP への時刻換算は OtelConverter が行う。
435
+ class PendingSpan
436
+ attr_accessor :span_id, :parent_span_id, :name, :thread_id,
437
+ :start_mono_ns, :end_mono_ns, :incomplete
438
+ def initialize(span_id:, parent_span_id:, name:, thread_id:, start_mono_ns:, end_mono_ns: nil)
439
+ @span_id = span_id; @parent_span_id = parent_span_id; @name = name
440
+ @thread_id = thread_id; @start_mono_ns = start_mono_ns; @end_mono_ns = end_mono_ns
441
+ @incomplete = false
442
+ end
443
+ end
444
+ end
445
+ ```
446
+
447
+ ### 4.6 OtelConverter(自己完結の内蔵 Writer) — `lib/orange_tap/otel_converter.rb`
448
+
449
+ Vivarium の OTLP/JSON フォーマット規約(`resourceSpans → scopeSpans → spans`、`traceId`=32hex・
450
+ `spanId`=16hex、時刻は文字列 nano、属性は `{key,value:{stringValue|intValue|boolValue}}`)を踏襲した
451
+ **新規・自己完結実装**。`vivarium` gem には依存しない。差し替えられるよう `config.otel_converter`
452
+ で注入する(デフォルトが本モジュール)。
453
+
454
+ インターフェース(Worker から呼ばれる契約):
455
+
456
+ ```ruby
457
+ module OrangeTap
458
+ module OtelConverter
459
+ SPAN_KIND_INTERNAL = 1
460
+ module_function
461
+
462
+ # spans: Array<PendingSpan>(session/thread/method 混在、mono ns 保持)
463
+ # 戻り値: JSON.generate 可能な OTLP/JSON ドキュメント(Hash)
464
+ def build_document(spans:, trace_id:, start_mono_ns:, start_unix_ns:, service_name:)
465
+ to_unix = ->(mono) { (start_unix_ns + (mono.to_i - start_mono_ns.to_i)).to_s }
466
+ otlp_spans = spans.map { |s| span_hash(s, trace_id, to_unix) }
467
+ {
468
+ resourceSpans: [{
469
+ resource: { attributes: [str_attr("service.name", service_name)] },
470
+ scopeSpans: [{
471
+ scope: { name: service_name, version: OrangeTap::VERSION },
472
+ spans: otlp_spans
473
+ }]
474
+ }]
475
+ }
476
+ end
477
+
478
+ def span_hash(s, trace_id, to_unix)
479
+ attrs = []
480
+ attrs << int_attr("thread.id", s.thread_id) if s.thread_id
481
+ attrs << bool_attr("orange_tap.incomplete", true) if s.incomplete
482
+ h = {
483
+ traceId: trace_id,
484
+ spanId: s.span_id,
485
+ name: s.name,
486
+ kind: SPAN_KIND_INTERNAL,
487
+ startTimeUnixNano: to_unix.call(s.start_mono_ns),
488
+ endTimeUnixNano: to_unix.call(s.end_mono_ns),
489
+ attributes: attrs
490
+ }
491
+ h[:parentSpanId] = s.parent_span_id if s.parent_span_id
492
+ h
493
+ end
494
+
495
+ def str_attr(k, v) = { key: k, value: { stringValue: v.to_s } }
496
+ def int_attr(k, v) = { key: k, value: { intValue: v.to_i.to_s } }
497
+ def bool_attr(k, v) = { key: k, value: { boolValue: !!v } }
498
+ end
499
+ end
500
+ ```
501
+
502
+ > `trace_id`(32hex) / `span_id`(16hex) は `SecureRandom.hex` で最初から hex 文字列として
503
+ > 生成するため、Vivarium のような int→hex 変換(`synth_span_id`/`hex16`)は不要。
504
+
505
+ ### 4.7 Config — `lib/orange_tap/config.rb`
506
+
507
+ ```ruby
508
+ module OrangeTap
509
+ class Config
510
+ attr_accessor :output_dir, :service_name, :otel_converter
511
+ def initialize
512
+ @output_dir = File.join(Dir.tmpdir, "orange_tap") # 書き出し先(存在しなければ mkdir_p)
513
+ @service_name = "orange_tap"
514
+ @otel_converter = OrangeTap::OtelConverter # 差し替え可能な継ぎ目
515
+ end
516
+ end
517
+ end
518
+ ```
519
+
520
+ - 出力ファイルの保持・削除は呼び出し側に委ねる(gem 側で自動削除しない)。
521
+
522
+ ---
523
+
524
+ ## 5. ディレクトリ構造(Pure Ruby / ext なし)
525
+
526
+ ```
527
+ orange_tap/
528
+ ├── orange_tap.gemspec
529
+ ├── lib/
530
+ │ ├── orange_tap.rb # トップレベルAPI + require + 例外クラス
531
+ │ └── orange_tap/
532
+ │ ├── version.rb
533
+ │ ├── config.rb
534
+ │ ├── event.rb
535
+ │ ├── method_registry.rb
536
+ │ ├── session.rb
537
+ │ ├── worker.rb
538
+ │ ├── pending_span.rb
539
+ │ └── otel_converter.rb
540
+ ├── sig/orange_tap.rbs
541
+ ├── test/ # 既存スキャフォールド(minitest)を踏襲
542
+ └── README.md
543
+ ```
544
+
545
+ - 依存: 標準ライブラリのみ(`json`, `securerandom`, `tmpdir`, `fileutils`, `time`)。外部 gem 依存なし。
546
+
547
+ ---
548
+
549
+ ## 6. テスト計画(PROMPT 準拠、minitest)
550
+
551
+ 1. `trace_method` で登録した対象メソッド呼び出しがイベントとして捕捉される。
552
+ 2. インスタンスメソッド/クラス(特異)メソッド/特定オブジェクトの特異メソッドを
553
+ `trace_method(obj.method(:foo))` 等で登録・捕捉できる。
554
+ 3. 未登録メソッドは捕捉されない(`target:` で絞られている)。
555
+ 4. `OrangeTap.open do...end` がパス(String)を返し、ファイルが実在し、妥当な JSON である
556
+ (`resourceSpans` を含む OTLP 構造・`traceId`=32桁・`spanId`=16桁を検証)。
557
+ 5. 二重 `open` → `AlreadyOpenError`。
558
+ 6. 未 `open` の `stop` → `NotOpenError`。
559
+ 7. ブロック内例外時も TracePoint が確実に disable される(後続テストに影響しない)。
560
+ 8. 再帰・ネスト呼び出しで CALL/RETURN 対応付けが正しい(parent 関係の検証)。
561
+ 9. RETURN 未着(強制終了)ケースで `orange_tap.incomplete=true` の Span が出力される。
562
+ 10. 複数インスタンスの並行 `open` が互いに干渉しない(別ファイル・別 trace_id)。
563
+ 11. `UntraceableMethodError`(C 実装メソッド登録時)と `ArgumentError`(非 Method 引数)。
564
+ 12. `untrace_method` が別オブジェクト由来の同一メソッドでも解除できる(`[owner, name]` キー)。
565
+
566
+ 補足: TracePoint の非同期性のため、`stop`(= `Thread#value` で Worker 完了を待つ)後に
567
+ アサートする。フックは stop 内で必ず disable されるので、テスト間のフック残留は起きない。
568
+
569
+ ---
570
+
571
+ ## 7. README に明記する事項
572
+
573
+ - C レベル(`rb_add_event_hook`/`trace_func`)ではなく Ruby レベル `TracePoint` を採用した理由
574
+ (Ruby 4.0 の C レベル `trace_func` 既知バグの回避)。
575
+ - 中央デーモンを持たず、プロセス内 Thread + Queue で完結する設計。複数プロセス横断の
576
+ Trace 相関はスコープ外。
577
+ - `open`/`stop` ごとに専用 Queue/Thread を生成する設計思想(`session_id` タグ付けが不要になる理由)。
578
+ - スレッドをまたぐ非同期処理の親子関係追跡は非対応。
579
+ - 並行セッションが同一メソッドを対象にすると二重フックのオーバーヘッドが生じ得る点。
580
+ - Worker 内例外は `Thread#value` 経由で `stop` 呼び出し元に再送出される(ただし TracePoint は
581
+ 必ず先に disable 済み)。
582
+ - OTLP 変換は自己完結実装であり、`config.otel_converter` で差し替え可能なこと。
583
+
584
+ ---
585
+
586
+ ## 8. gem 名 rename(実装着手前の作業)
587
+
588
+ `orange_tap_` → `orange_tap` に統一する。対象:
589
+
590
+ - `orange_tap_.gemspec` → `orange_tap.gemspec`(`spec.name` / `require_relative` パス / TODO メタ情報の記入)
591
+ - `lib/orange_tap_.rb` → `lib/orange_tap.rb`
592
+ - `lib/orange_tap_/` → `lib/orange_tap/`(配下 `version.rb` 等)
593
+ - `sig/orange_tap_.rbs` → `sig/orange_tap.rbs`
594
+ - `test/orange_tap__test.rb` → `test/orange_tap_test.rb`、`test/test_helper.rb` の require 修正
595
+ - `Rakefile` / `bin/console` 等に `orange_tap_` 参照があれば修正
596
+ - モジュール名 `OrangeTap` は変更不要(既に一致)。
597
+
598
+ ---
599
+
600
+ ## 9. 実装時に判明した既知の制約
601
+
602
+ - **`define_method`/ブロックベースで定義したメソッドは `trace_method` の対象外。**
603
+ `RubyVM::InstructionSequence.of` は取得できる(`nil` にならない)が、そのISeqは
604
+ `:block` タイプであり、`TracePoint#enable(target:)` に渡すと
605
+ `ArgumentError: can not enable any hooks` になる(Ruby 4.0.5 で確認)。
606
+ 現状は `UntraceableMethodError` 等で明示的に弾いておらず、`enable` 時に例外化するため、
607
+ README にこの制約を明記し、対象は `def` で定義された通常のメソッドに限定する。
608
+ ブロックベースメソッドの検出・早期エラー化は将来課題とする。
609
+
610
+ ## 10. 将来課題(v1 スコープ外・明示的に先送り)
611
+
612
+ - `define_method`/ブロックベースメソッドを `register` 時点で検出し、
613
+ `UntraceableMethodError` を送出する(現状は `open` 時の `enable` で初めて失敗する)。
614
+ - スレッド/Fiber/Ractor をまたぐ非同期処理の親子関係伝播
615
+ (`Thread.current[:orange_tap_parent_span_id]` 等の context 伝播)。
616
+ - 並行セッションで同一メソッドを共有する場合の二重フック解消
617
+ (registry をセッション間共有しフック内で振り分ける方式。ただし「どのセッションに属すか」の
618
+ 判定ロジックが再び必要になり、現行のシンプルさとのトレードオフ)。
619
+ - OTLP/HTTP エクスポート(現状はファイル出力のみ。`otel_converter` 差し替え or 別 exporter で拡張可)。
620
+ - code.filepath / code.lineno 等の属性付与(フックを軽く保つ方針との兼ね合いで v1 は見送り)。
621
+
622
+ ---
623
+
624
+ ## 11. 参考
625
+
626
+ - 既存実装(Vivarium): `file:///Users/uchio.kondo/ghq/github.com/udzura/vivarium`
627
+ - OTLP/JSON フォーマット規約の参照元は `lib/vivarium/otel_exporter.rb`
628
+ (ただし前述の通り密結合のため**コードは流用せず、フォーマットのみ踏襲**)。
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Uchio Kondo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.