vivarium 0.2.0 → 0.3.1

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,593 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "http_decoder"
5
+
6
+ module Vivarium
7
+ class TreeRenderer
8
+ SPAN_EVENT_NAMES = %w[span_start span_stop].to_set.freeze
9
+ FORK_EVENT_NAME = "proc_fork"
10
+ EXEC_EVENT_NAME = "proc_exec"
11
+ SSL_WRITE_EVENT_NAME = "ssl_write"
12
+
13
+ LSM_EVENT_NAMES = %w[
14
+ path_open sock_connect odd_socket
15
+ ptrace_check sb_mount kernel_read_file task_kill
16
+ setid_change capable_check bprm_creds
17
+ file_symlink file_hardlink file_rename file_chmod
18
+ ].to_set.freeze
19
+
20
+ TP_EVENT_NAMES = %w[
21
+ dns_req proc_exec file_getdents proc_fork
22
+ ].to_set.freeze
23
+
24
+ UPROBE_EVENT_NAMES = %w[ssl_write].to_set.freeze
25
+
26
+ SYNTHETIC_SPAN_NAME = "<no-span>"
27
+ UNRESOLVED_METHOD_PREFIX = "<method_id="
28
+
29
+ Span = Struct.new(
30
+ :tid, :method_id, :file_id, :lineno, :start_ktime, :stop_ktime, :exit_kind,
31
+ :events, :descendant_pids, :synthetic, :raised,
32
+ keyword_init: true
33
+ ) do
34
+ def duration_ns
35
+ return nil unless stop_ktime && start_ktime
36
+
37
+ stop_ktime - start_ktime
38
+ end
39
+
40
+ # Span is mutable (stop_ktime, events, descendant_pids are written after creation).
41
+ # Use object identity for Hash/Set so keys remain stable across mutations.
42
+ def hash
43
+ object_id
44
+ end
45
+
46
+ def eql?(other)
47
+ equal?(other)
48
+ end
49
+ end
50
+
51
+ EventNode = Struct.new(:kind, :name, :target, :offset_ns, :child_proc, keyword_init: true)
52
+ ProcNode = Struct.new(:pid, :comm, :parent_pid, :children, keyword_init: true)
53
+
54
+ def initialize(events:, method_table:, observer_pid:, main_tid:,
55
+ session_start_iso:, session_start_ktime:,
56
+ session_stop_iso:, session_stop_ktime:, filter: nil, dest:)
57
+ @events = events
58
+ @method_table = method_table
59
+ @observer_pid = observer_pid
60
+ @main_tid = main_tid
61
+ @session_start_iso = session_start_iso
62
+ @session_start_ktime = session_start_ktime
63
+ @session_stop_iso = session_stop_iso
64
+ @session_stop_ktime = session_stop_ktime
65
+ @display_filter = Vivarium::DisplayFilter.compile(filter)
66
+ @dest = dest
67
+
68
+ @pid_comm = { observer_pid => "ruby" }
69
+ @pid_parent = {}
70
+ @unresolved_method_ids = []
71
+ end
72
+
73
+ def render
74
+ sorted = @events.sort_by { |e| [e.ktime_ns, e.pid, e.tid] }
75
+
76
+ real_spans, @children_map = build_real_spans(sorted)
77
+ @child_span_set = @children_map.values.flatten.to_set
78
+
79
+ assign_descendants(real_spans, sorted)
80
+
81
+ root_real_spans = real_spans.reject { |s| @child_span_set.include?(s) }
82
+ root_with_synthetics = interleave_synthetic_spans(root_real_spans)
83
+
84
+ synthetic_spans = root_with_synthetics.select(&:synthetic)
85
+ all_spans_for_assign = (synthetic_spans + real_spans).sort_by { |s| s.start_ktime || 0 }
86
+ assign_events_to_spans(all_spans_for_assign, sorted)
87
+
88
+ print_header
89
+ print_warnings
90
+ print_observer_proc(root_with_synthetics)
91
+ end
92
+
93
+ private
94
+
95
+ def build_real_spans(events)
96
+ open_by_tid = Hash.new { |h, k| h[k] = [] }
97
+ closed = []
98
+ children_map = {}
99
+
100
+ events.each do |ev|
101
+ case ev.event_name
102
+ when "span_start"
103
+ mid, fid, lno = read_span_payload(ev.payload)
104
+ span = Span.new(
105
+ tid: ev.tid,
106
+ method_id: mid,
107
+ file_id: fid,
108
+ lineno: lno,
109
+ start_ktime: ev.ktime_ns,
110
+ stop_ktime: nil,
111
+ exit_kind: nil,
112
+ events: [],
113
+ descendant_pids: Set.new,
114
+ synthetic: false,
115
+ raised: false
116
+ )
117
+ parent = open_by_tid[ev.tid].last
118
+ (children_map[parent] ||= []) << span if parent
119
+ open_by_tid[ev.tid].push(span)
120
+ when "span_stop"
121
+ stack = open_by_tid[ev.tid]
122
+ next if stack.empty?
123
+
124
+ span = stack.pop
125
+ span.stop_ktime = ev.ktime_ns
126
+ span.exit_kind = :stopped
127
+ closed << span
128
+ when "span_raise"
129
+ span = open_by_tid[ev.tid].last
130
+ span.raised = true if span
131
+ end
132
+ end
133
+
134
+ open_by_tid.each_value do |stack|
135
+ stack.each do |span|
136
+ span.stop_ktime = @session_stop_ktime || (events.last&.ktime_ns)
137
+ span.exit_kind = :dangling
138
+ closed << span
139
+ end
140
+ end
141
+
142
+ closed.sort_by!(&:start_ktime)
143
+ [closed, children_map]
144
+ end
145
+
146
+ def assign_descendants(spans, events)
147
+ sorted_spans = spans.reject(&:synthetic).sort_by(&:start_ktime)
148
+
149
+ events.each do |ev|
150
+ next unless ev.event_name == FORK_EVENT_NAME
151
+
152
+ child_pid = read_proc_fork_child_pid(ev.payload)
153
+ next if child_pid.zero?
154
+
155
+ @pid_parent[child_pid] = ev.pid
156
+
157
+ owning = innermost_span_for_event(sorted_spans, ev)
158
+ owning&.descendant_pids&.add(child_pid)
159
+
160
+ # Closure: if ev.pid is itself a descendant of some span, that span also gains child_pid
161
+ sorted_spans.each do |span|
162
+ next if span == owning
163
+ next unless event_in_span?(ev, span)
164
+
165
+ span.descendant_pids.add(child_pid) if span.descendant_pids.include?(ev.pid)
166
+ end
167
+ end
168
+ end
169
+
170
+ def interleave_synthetic_spans(real_spans)
171
+ result = []
172
+ cursor = @session_start_ktime || (real_spans.first&.start_ktime) || 0
173
+ session_end = @session_stop_ktime ||
174
+ real_spans.map(&:stop_ktime).compact.max ||
175
+ cursor
176
+
177
+ real_spans.each do |span|
178
+ if span.start_ktime > cursor
179
+ syn = synthetic_span(cursor, span.start_ktime)
180
+ result << syn if syn
181
+ end
182
+ result << span
183
+ cursor = [cursor, span.stop_ktime || span.start_ktime].max
184
+ end
185
+
186
+ if session_end > cursor
187
+ syn = synthetic_span(cursor, session_end)
188
+ result << syn if syn
189
+ end
190
+
191
+ result
192
+ end
193
+
194
+ def synthetic_span(start_ktime, stop_ktime)
195
+ Span.new(
196
+ tid: @main_tid,
197
+ method_id: nil,
198
+ file_id: nil,
199
+ lineno: nil,
200
+ start_ktime: start_ktime,
201
+ stop_ktime: stop_ktime,
202
+ exit_kind: :stopped,
203
+ events: [],
204
+ descendant_pids: Set.new,
205
+ synthetic: true,
206
+ raised: false
207
+ )
208
+ end
209
+
210
+ def assign_events_to_spans(spans, events)
211
+ events.each do |ev|
212
+ next if SPAN_EVENT_NAMES.include?(ev.event_name)
213
+
214
+ if ev.event_name == "proc_exec"
215
+ @pid_comm[ev.pid] = exec_basename(ev.payload) || @pid_comm[ev.pid] || "?"
216
+ end
217
+
218
+ host = find_host_span(spans, ev)
219
+ next unless host
220
+
221
+ host.events << ev
222
+ if ev.event_name == FORK_EVENT_NAME
223
+ child_pid = read_proc_fork_child_pid(ev.payload)
224
+ @pid_comm[child_pid] ||= "?"
225
+ host.descendant_pids.add(child_pid)
226
+ end
227
+ end
228
+ end
229
+
230
+ def find_host_span(spans, ev)
231
+ candidates = spans.select { |s| event_in_span?(ev, s) }
232
+ return nil if candidates.empty?
233
+
234
+ direct = candidates.select { |s| s.tid == ev.tid }
235
+ return direct.last unless direct.empty?
236
+
237
+ desc = candidates.select { |s| s.descendant_pids.include?(ev.pid) }
238
+ return desc.last unless desc.empty?
239
+
240
+ candidates.find(&:synthetic) || candidates.last
241
+ end
242
+
243
+ def innermost_span_for_event(spans, ev)
244
+ candidates = spans.select { |s| event_in_span?(ev, s) && s.tid == ev.tid }
245
+ candidates.last
246
+ end
247
+
248
+ def event_in_span?(ev, span)
249
+ return false unless span.start_ktime
250
+ return false if span.stop_ktime && ev.ktime_ns > span.stop_ktime
251
+
252
+ ev.ktime_ns >= span.start_ktime
253
+ end
254
+
255
+ def print_header
256
+ duration_s = ((@session_stop_ktime || 0) - (@session_start_ktime || 0)) / 1_000_000_000.0
257
+ @dest.puts "# vivarium session"
258
+ @dest.puts "# started iso=#{@session_start_iso} ktime=#{@session_start_ktime}"
259
+ @dest.puts "# stopped iso=#{@session_stop_iso} ktime=#{@session_stop_ktime}"
260
+ @dest.puts "# duration #{format('%.3fs', duration_s)}"
261
+ end
262
+
263
+ def print_warnings
264
+ @unresolved_method_ids.uniq.each do |mid|
265
+ @dest.puts format("# warning method_id=0x%016X unresolved at render time", mid & 0xFFFF_FFFF_FFFF_FFFF)
266
+ end
267
+ end
268
+
269
+ def print_observer_proc(spans)
270
+ @dest.puts "[PROC pid=#{@observer_pid} comm=#{@pid_comm[@observer_pid] || 'ruby'}]"
271
+ children = spans.reject { |s| s.synthetic && s.events.empty? }
272
+ .reject { |s| @child_span_set.include?(s) }
273
+ .select { |s| span_visible?(s) }
274
+ children.each_with_index do |span, idx|
275
+ print_span(span, prefix: "", is_last: idx == children.size - 1)
276
+ end
277
+ end
278
+
279
+ def print_span(span, prefix:, is_last:)
280
+ marker = is_last ? "└─ " : "├─ "
281
+ @dest.puts "#{prefix}#{marker}#{render_span_header(span)}"
282
+ child_prefix = prefix + (is_last ? " " : "│ ")
283
+ nodes = build_span_children(span)
284
+ print_nodes(nodes, child_prefix)
285
+ end
286
+
287
+ def render_span_header(span)
288
+ name = span_display_name(span)
289
+ dur_text = format_duration(span.duration_ns)
290
+ file_info = span_file_info(span)
291
+ suffix = if span.exit_kind == :dangling
292
+ " (open)"
293
+ elsif span.raised
294
+ " (raise)"
295
+ else
296
+ ""
297
+ end
298
+ "[SPAN tid=#{span.tid} #{name}#{file_info} dur=#{dur_text}#{suffix}]"
299
+ end
300
+
301
+ def span_file_info(span)
302
+ return "" if span.synthetic
303
+ return "" unless span.file_id && span.file_id != -1
304
+
305
+ file_name = Vivarium::Usdt.get_file_name(span.file_id)
306
+ return "" unless file_name
307
+
308
+ lno = span.lineno && span.lineno > 0 ? ":#{span.lineno}" : ""
309
+ " at=#{File.basename(file_name)}#{lno}"
310
+ end
311
+
312
+ def span_display_name(span)
313
+ return SYNTHETIC_SPAN_NAME if span.synthetic
314
+ return SYNTHETIC_SPAN_NAME if span.method_id.nil?
315
+
316
+ name = @method_table[span.method_id]
317
+ name ||= Vivarium::Usdt.get_method_name(span.method_id)
318
+ return name if name
319
+
320
+ @unresolved_method_ids << span.method_id
321
+ format("#{UNRESOLVED_METHOD_PREFIX}0x%016X>", span.method_id & 0xFFFF_FFFF_FFFF_FFFF)
322
+ end
323
+
324
+ def build_span_children(span)
325
+ proc_node_by_pid = {}
326
+ root_children = []
327
+
328
+ span.events.each do |ev|
329
+ target_text = nil
330
+ if @display_filter.needs_payload?
331
+ target_text = render_target(ev)
332
+ next unless event_visible?(ev, span, target_text)
333
+ else
334
+ next unless event_visible?(ev, span)
335
+ end
336
+
337
+ if ev.event_name == FORK_EVENT_NAME
338
+ child_pid = read_proc_fork_child_pid(ev.payload)
339
+ child_node = ProcNode.new(
340
+ pid: child_pid,
341
+ comm: @pid_comm[child_pid] || "?",
342
+ parent_pid: ev.pid,
343
+ children: []
344
+ )
345
+ proc_node_by_pid[child_pid] = child_node
346
+
347
+ ev_node = EventNode.new(
348
+ kind: kind_for(ev),
349
+ name: ev.event_name,
350
+ target: target_text || render_target(ev),
351
+ offset_ns: ev.ktime_ns - span.start_ktime,
352
+ child_proc: child_node
353
+ )
354
+
355
+ parent_container = container_for_pid(ev.pid, span, proc_node_by_pid, root_children)
356
+ append_event(parent_container, ev_node)
357
+ else
358
+ ev_node = EventNode.new(
359
+ kind: kind_for(ev),
360
+ name: ev.event_name,
361
+ target: target_text || render_target(ev),
362
+ offset_ns: ev.ktime_ns - span.start_ktime,
363
+ child_proc: nil
364
+ )
365
+
366
+ if ev.pid == @observer_pid && ev.tid == span.tid
367
+ append_event(root_children, ev_node)
368
+ elsif (node = proc_node_by_pid[ev.pid])
369
+ append_event(node.children, ev_node)
370
+ else
371
+ # event from a descendant pid we haven't materialized — synthesize a stub PROC node
372
+ stub = ProcNode.new(
373
+ pid: ev.pid,
374
+ comm: @pid_comm[ev.pid] || "?",
375
+ parent_pid: @pid_parent[ev.pid] || span.tid,
376
+ children: []
377
+ )
378
+ proc_node_by_pid[ev.pid] = stub
379
+ append_event(stub.children, ev_node)
380
+ root_children << stub
381
+ end
382
+
383
+ if ev.event_name == EXEC_EVENT_NAME && (node = proc_node_by_pid[ev.pid])
384
+ node.comm = @pid_comm[ev.pid] || node.comm
385
+ end
386
+ end
387
+ end
388
+
389
+ # Interleave child spans by start time among the event/proc nodes
390
+ child_spans = (@children_map[span] || []).sort_by(&:start_ktime)
391
+ child_spans.each do |child_span|
392
+ child_offset = child_span.start_ktime - span.start_ktime
393
+ insert_pos = root_children.size
394
+ root_children.each_with_index do |node, i|
395
+ if node.is_a?(EventNode) && node.offset_ns >= child_offset
396
+ insert_pos = i
397
+ break
398
+ end
399
+ end
400
+ root_children.insert(insert_pos, child_span)
401
+ end
402
+
403
+ root_children
404
+ end
405
+
406
+ def container_for_pid(pid, span, proc_node_by_pid, root_children)
407
+ return root_children if pid == @observer_pid
408
+
409
+ node = proc_node_by_pid[pid]
410
+ return node.children if node
411
+
412
+ # Walk up parent chain to find a known node
413
+ cur = pid
414
+ while (parent = @pid_parent[cur])
415
+ return root_children if parent == @observer_pid
416
+ if (parent_node = proc_node_by_pid[parent])
417
+ stub = ProcNode.new(pid: pid, comm: @pid_comm[pid] || "?", parent_pid: parent, children: [])
418
+ proc_node_by_pid[pid] = stub
419
+ parent_node.children << stub
420
+ return stub.children
421
+ end
422
+ cur = parent
423
+ end
424
+
425
+ root_children
426
+ end
427
+
428
+ def append_event(container, ev_node)
429
+ container << ev_node
430
+ end
431
+
432
+ def print_nodes(nodes, prefix)
433
+ visible_nodes = nodes.select do |node|
434
+ !node.is_a?(Span) || span_visible?(node)
435
+ end
436
+
437
+ visible_nodes.each_with_index do |node, idx|
438
+ is_last = idx == visible_nodes.size - 1
439
+ case node
440
+ when EventNode
441
+ print_event_node(node, prefix: prefix, is_last: is_last)
442
+ when ProcNode
443
+ print_proc_node(node, prefix: prefix, is_last: is_last)
444
+ when Span
445
+ print_span(node, prefix: prefix, is_last: is_last)
446
+ end
447
+ end
448
+ end
449
+
450
+ def span_visible?(span)
451
+ @display_filter.allow_span_name?(span_display_name(span))
452
+ end
453
+
454
+ def event_visible?(ev, span, target_text = nil)
455
+ @display_filter.allow_event?(
456
+ event_name: ev.event_name,
457
+ severity: Vivarium.event_severity(ev.event_name),
458
+ span_name: span_display_name(span),
459
+ payload: target_text,
460
+ pid: ev.pid,
461
+ tid: ev.tid
462
+ )
463
+ end
464
+
465
+ def print_event_node(node, prefix:, is_last:)
466
+ marker = is_last && node.child_proc.nil? ? "└─ " : "├─ "
467
+ marker = "└─ " if is_last && node.child_proc.nil?
468
+ marker = "├─ " unless is_last
469
+ marker = "└─ " if is_last
470
+ offset_text = format_offset(node.offset_ns)
471
+ line = format("%-4s %-15s → %-30s @+%s", node.kind, node.name, node.target, offset_text)
472
+ @dest.puts "#{prefix}#{marker}#{line}"
473
+
474
+ if node.child_proc
475
+ child_prefix = prefix + (is_last ? " " : "│ ")
476
+ print_proc_node(node.child_proc, prefix: child_prefix, is_last: true)
477
+ end
478
+ end
479
+
480
+ def print_proc_node(node, prefix:, is_last:)
481
+ marker = is_last ? "└─ " : "├─ "
482
+ header = "[PROC pid=#{node.pid} comm=#{node.comm}"
483
+ header += " parent=#{node.parent_pid}" if node.parent_pid
484
+ header += "]"
485
+ @dest.puts "#{prefix}#{marker}#{header}"
486
+ child_prefix = prefix + (is_last ? " " : "│ ")
487
+ print_nodes(node.children, child_prefix)
488
+ end
489
+
490
+ def kind_for(ev)
491
+ return "EXCP" if ev.event_name == "span_raise"
492
+ return "USDT" if SPAN_EVENT_NAMES.include?(ev.event_name)
493
+ return "SSL" if ev.event_name == SSL_WRITE_EVENT_NAME
494
+ return "LSM" if LSM_EVENT_NAMES.include?(ev.event_name)
495
+ return "TP" if TP_EVENT_NAMES.include?(ev.event_name)
496
+
497
+ "EVT"
498
+ end
499
+
500
+ def render_target(ev)
501
+ return render_raise_target(ev) if ev.event_name == "span_raise"
502
+ return render_ssl_write_target(ev) if ev.event_name == SSL_WRITE_EVENT_NAME
503
+
504
+ text = Vivarium.render_event_payload(ev).to_s
505
+ text = text.gsub(/\s+/, " ").strip
506
+ text.empty? ? "-" : text
507
+ end
508
+
509
+ def render_ssl_write_target(ev)
510
+ decoded = Vivarium.decode_ssl_write_payload(ev.payload)
511
+ http_decoder.render(
512
+ pid: ev.pid,
513
+ data: decoded[:data],
514
+ data_len: decoded[:data_len]
515
+ )
516
+ rescue StandardError => e
517
+ "ssl_write <decode-error: #{e.class}: #{e.message}>"
518
+ end
519
+
520
+ def http_decoder
521
+ @http_decoder ||= Vivarium::HttpDecoder.new
522
+ end
523
+
524
+ def render_raise_target(ev)
525
+ bytes = ev.payload.to_s.b
526
+ return "-" if bytes.bytesize < 8
527
+
528
+ error_id = bytes[0, 8].unpack1("q<")
529
+ message_id = bytes.bytesize >= 16 ? bytes[8, 8].unpack1("q<") : -1
530
+ file_id = bytes.bytesize >= 24 ? bytes[16, 8].unpack1("q<") : -1
531
+ lineno = bytes.bytesize >= 32 ? bytes[24, 8].unpack1("q<") : -1
532
+
533
+ error_name = Vivarium::Usdt.get_error_name(error_id) ||
534
+ format("0x%016X", error_id & 0xFFFF_FFFF_FFFF_FFFF)
535
+
536
+ parts = ["error=#{error_name}"]
537
+
538
+ if message_id != -1
539
+ msg = Vivarium::Usdt.get_message_name(message_id)
540
+ parts << "message=#{msg.inspect}" if msg
541
+ end
542
+
543
+ if file_id != -1 && (file_name = Vivarium::Usdt.get_file_name(file_id))
544
+ lno = lineno && lineno > 0 ? ":#{lineno}" : ""
545
+ parts << "at=#{File.basename(file_name)}#{lno}"
546
+ end
547
+
548
+ parts.join(" ")
549
+ end
550
+
551
+ def format_duration(ns)
552
+ return "?" unless ns
553
+
554
+ ms = ns / 1_000_000.0
555
+ ms < 1.0 ? format("%.1fus", ns / 1_000.0) : format("%.1fms", ms)
556
+ end
557
+
558
+ def format_offset(ns)
559
+ return "?" unless ns
560
+
561
+ ms = ns / 1_000_000.0
562
+ ms.abs < 1.0 ? format("%.1fus", ns / 1_000.0) : format("%.1fms", ms)
563
+ end
564
+
565
+ def read_span_payload(payload)
566
+ bytes = payload.to_s.b
567
+ return [0, -1, -1] if bytes.bytesize < 8
568
+
569
+ method_id = bytes[0, 8].unpack1("q<")
570
+ file_id = bytes.bytesize >= 16 ? bytes[8, 8].unpack1("q<") : -1
571
+ lineno = bytes.bytesize >= 24 ? bytes[16, 8].unpack1("q<") : -1
572
+ [method_id, file_id, lineno]
573
+ end
574
+
575
+ def read_proc_fork_child_pid(payload)
576
+ bytes = payload.to_s.b
577
+ return 0 if bytes.bytesize < 4
578
+
579
+ bytes[0, 4].unpack1("L<")
580
+ end
581
+
582
+ def exec_basename(payload)
583
+ slot_size = Vivarium::PROC_EXEC_SLOT_SIZE
584
+ bytes = payload.to_s.b
585
+ return nil if bytes.empty?
586
+
587
+ filename = Vivarium.c_string(bytes[0, slot_size])
588
+ return nil if filename.empty?
589
+
590
+ File.basename(filename)
591
+ end
592
+ end
593
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vivarium
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end