vivarium 0.3.0 → 0.3.2

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,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "http/2"
5
+ rescue LoadError
6
+ # http/2 gem is optional; without it we can still parse frame headers but not HPACK-decompress HEADERS.
7
+ end
8
+
9
+ module Vivarium
10
+ # Decodes payloads captured from OpenSSL `SSL_write` into a human-readable
11
+ # one-liner. Auto-detects HTTP/1.x request/response lines and HTTP/2 binary
12
+ # frames; HPACK-decompresses HEADERS / CONTINUATION when the `http-2` gem
13
+ # is available, otherwise reports frame types only.
14
+ #
15
+ # HPACK decompressor state is kept per pid. This is sufficient for the
16
+ # common "one HTTPS connection per process" case; with multiple concurrent
17
+ # TLS connections per pid the HPACK table can diverge and decoding may
18
+ # fail — in that case the decompressor for that pid is reset on the next
19
+ # decode error.
20
+ class HttpDecoder
21
+ HTTP1_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
22
+ HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".b
23
+ H2_FRAME_HEADER_SIZE = 9
24
+ H2_FLAG_END_HEADERS = 0x04
25
+ H2_FLAG_PADDED = 0x08
26
+ H2_FLAG_PRIORITY = 0x20
27
+ FRAME_TYPE_NAMES = {
28
+ 0x0 => "DATA",
29
+ 0x1 => "HEADERS",
30
+ 0x2 => "PRIORITY",
31
+ 0x3 => "RST_STREAM",
32
+ 0x4 => "SETTINGS",
33
+ 0x5 => "PUSH_PROMISE",
34
+ 0x6 => "PING",
35
+ 0x7 => "GOAWAY",
36
+ 0x8 => "WINDOW_UPDATE",
37
+ 0x9 => "CONTINUATION"
38
+ }.freeze
39
+
40
+ def initialize
41
+ @hpack_available = load_http2_gem
42
+ @decompressors = {}
43
+ @continuation = {}
44
+ end
45
+
46
+ def hpack_available?
47
+ @hpack_available
48
+ end
49
+
50
+ def render(pid:, data:, data_len:)
51
+ data = data.to_s.b
52
+ data_len = data_len.to_i
53
+
54
+ return "data_len=0" if data_len <= 0
55
+ return "len=#{data_len} <no-capture>" if data.empty?
56
+
57
+ if (summary = http1_summary(data))
58
+ kind, line = summary
59
+ return "http/1.x #{kind}: #{line}#{truncation_note(data, data_len)}"
60
+ end
61
+
62
+ rest = data
63
+ preface_note = ""
64
+ if rest.start_with?(HTTP2_PREFACE)
65
+ preface_note = "preface "
66
+ rest = rest.byteslice(HTTP2_PREFACE.bytesize..) || "".b
67
+ end
68
+
69
+ frames = parse_h2_frames(rest)
70
+ if frames.empty?
71
+ if !preface_note.empty?
72
+ return "h2 preface only#{truncation_note(data, data_len)}"
73
+ end
74
+ return "binary len=#{data_len}#{truncation_note(data, data_len)}"
75
+ end
76
+
77
+ rendered = frames.map { |f| render_h2_frame(pid, f) }.join(" | ")
78
+ "h2 #{preface_note}#{rendered}#{truncation_note(data, data_len)}"
79
+ end
80
+
81
+ private
82
+
83
+ def truncation_note(data, data_len)
84
+ return "" if data.bytesize >= data_len
85
+
86
+ " (captured #{data.bytesize}/#{data_len}B)"
87
+ end
88
+
89
+ def load_http2_gem
90
+ require "http/2"
91
+ true
92
+ rescue LoadError
93
+ false
94
+ end
95
+
96
+ def http1_summary(data)
97
+ head = data.byteslice(0, 512).to_s
98
+ first_line = head.split("\r\n", 2).first
99
+ return nil if first_line.nil? || first_line.empty?
100
+
101
+ first_line = first_line.dup.force_encoding(Encoding::UTF_8)
102
+ return nil unless first_line.valid_encoding?
103
+
104
+ if HTTP1_METHODS.any? { |m| first_line.start_with?("#{m} ") }
105
+ return ["request", first_line]
106
+ end
107
+
108
+ if first_line.start_with?("HTTP/1.1 ") || first_line.start_with?("HTTP/1.0 ")
109
+ return ["response", first_line]
110
+ end
111
+
112
+ nil
113
+ end
114
+
115
+ # @return [Array<Array(Integer, Integer, Integer, String, Boolean)>]
116
+ # each entry: [frame_type, flags, stream_id, frame_payload, truncated?]
117
+ def parse_h2_frames(payload)
118
+ frames = []
119
+ i = 0
120
+ total = payload.bytesize
121
+
122
+ while i + H2_FRAME_HEADER_SIZE <= total
123
+ length = (payload.getbyte(i) << 16) |
124
+ (payload.getbyte(i + 1) << 8) |
125
+ payload.getbyte(i + 2)
126
+ frame_type = payload.getbyte(i + 3)
127
+ flags = payload.getbyte(i + 4)
128
+ stream_id = payload.byteslice(i + 5, 4).unpack1("N") & 0x7fff_ffff
129
+ i += H2_FRAME_HEADER_SIZE
130
+
131
+ if i + length > total
132
+ remaining = payload.byteslice(i, total - i) || "".b
133
+ frames << [frame_type, flags, stream_id, remaining, true]
134
+ break
135
+ end
136
+
137
+ frame_payload = payload.byteslice(i, length) || "".b
138
+ i += length
139
+ frames << [frame_type, flags, stream_id, frame_payload, false]
140
+ end
141
+
142
+ # Heuristic: if the very first "frame" doesn't look like a valid HTTP/2
143
+ # frame, refuse the whole parse so we fall back to "binary".
144
+ first_type = frames.first && frames.first[0]
145
+ return [] if first_type && !FRAME_TYPE_NAMES.key?(first_type)
146
+
147
+ frames
148
+ end
149
+
150
+ def render_h2_frame(pid, frame)
151
+ frame_type, flags, stream_id, frame_payload, truncated = frame
152
+ frame_name = FRAME_TYPE_NAMES.fetch(frame_type, "TYPE0x#{format('%02x', frame_type)}")
153
+ header = "#{frame_name} stream=#{stream_id} flags=0x#{format('%02x', flags)} len=#{frame_payload.bytesize}#{truncated ? '*' : ''}"
154
+
155
+ case frame_type
156
+ when 0x1 # HEADERS
157
+ fragment = headers_fragment(flags, frame_payload)
158
+ return "#{header} <bad_payload>" if fragment.nil?
159
+
160
+ if (flags & H2_FLAG_END_HEADERS) != 0
161
+ pseudo = decode_hpack(pid, fragment)
162
+ "#{header}#{format_pseudo(pseudo)}"
163
+ else
164
+ @continuation[[pid, stream_id]] = fragment.dup
165
+ "#{header} <collecting>"
166
+ end
167
+ when 0x9 # CONTINUATION
168
+ key = [pid, stream_id]
169
+ unless @continuation.key?(key)
170
+ return "#{header} <orphan>"
171
+ end
172
+
173
+ @continuation[key] << frame_payload
174
+ if (flags & H2_FLAG_END_HEADERS) == 0
175
+ "#{header} <collecting>"
176
+ else
177
+ buf = @continuation.delete(key)
178
+ pseudo = decode_hpack(pid, buf)
179
+ "#{header}#{format_pseudo(pseudo)}"
180
+ end
181
+ else
182
+ header
183
+ end
184
+ end
185
+
186
+ def headers_fragment(flags, frame_payload)
187
+ start_idx = 0
188
+ end_idx = frame_payload.bytesize
189
+
190
+ if (flags & H2_FLAG_PADDED) != 0
191
+ return nil if end_idx.zero?
192
+
193
+ pad_len = frame_payload.getbyte(0)
194
+ start_idx += 1
195
+ end_idx = [start_idx, end_idx - pad_len].max
196
+ end
197
+
198
+ if (flags & H2_FLAG_PRIORITY) != 0
199
+ return nil if start_idx + 5 > end_idx
200
+
201
+ start_idx += 5
202
+ end
203
+
204
+ frame_payload.byteslice(start_idx, end_idx - start_idx) || "".b
205
+ end
206
+
207
+ def decompressor_for(pid)
208
+ return nil unless @hpack_available
209
+
210
+ @decompressors[pid] ||= HTTP2::Header::Decompressor.new
211
+ end
212
+
213
+ def decode_hpack(pid, header_block)
214
+ dec = decompressor_for(pid)
215
+ return { ":error" => "hpack-unavailable" } unless dec
216
+
217
+ pairs = dec.decode(header_block.b)
218
+ pairs.each_with_object({}) { |(k, v), h| h[k] = v }
219
+ rescue StandardError => e
220
+ @decompressors.delete(pid)
221
+ { ":error" => "#{e.class}: #{e.message}" }
222
+ end
223
+
224
+ def format_pseudo(pseudo)
225
+ return " <error: #{pseudo[':error']}>" if pseudo.key?(":error")
226
+
227
+ parts = []
228
+ parts << ":method=#{pseudo[':method']}" if pseudo[':method']
229
+ parts << ":path=#{pseudo[':path']}" if pseudo[':path']
230
+ parts << ":authority=#{pseudo[':authority']}" if pseudo[':authority']
231
+ parts << ":status=#{pseudo[':status']}" if pseudo[':status']
232
+ return "" if parts.empty?
233
+
234
+ " #{parts.join(' ')}"
235
+ end
236
+ end
237
+ end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require_relative "http_decoder"
4
5
 
5
6
  module Vivarium
6
7
  class TreeRenderer
7
8
  SPAN_EVENT_NAMES = %w[span_start span_stop].to_set.freeze
8
9
  FORK_EVENT_NAME = "proc_fork"
9
10
  EXEC_EVENT_NAME = "proc_exec"
11
+ SSL_WRITE_EVENT_NAME = "ssl_write"
10
12
 
11
13
  LSM_EVENT_NAMES = %w[
12
14
  path_open sock_connect odd_socket
@@ -19,6 +21,9 @@ module Vivarium
19
21
  dns_req proc_exec file_getdents proc_fork
20
22
  ].to_set.freeze
21
23
 
24
+ UPROBE_EVENT_NAMES = %w[ssl_write].to_set.freeze
25
+ DL_EVENT_NAMES = %w[dlopen mmap_exec].to_set.freeze
26
+
22
27
  SYNTHETIC_SPAN_NAME = "<no-span>"
23
28
  UNRESOLVED_METHOD_PREFIX = "<method_id="
24
29
 
@@ -49,7 +54,7 @@ module Vivarium
49
54
 
50
55
  def initialize(events:, method_table:, observer_pid:, main_tid:,
51
56
  session_start_iso:, session_start_ktime:,
52
- session_stop_iso:, session_stop_ktime:, dest:)
57
+ session_stop_iso:, session_stop_ktime:, filter: nil, dest:)
53
58
  @events = events
54
59
  @method_table = method_table
55
60
  @observer_pid = observer_pid
@@ -58,6 +63,7 @@ module Vivarium
58
63
  @session_start_ktime = session_start_ktime
59
64
  @session_stop_iso = session_stop_iso
60
65
  @session_stop_ktime = session_stop_ktime
66
+ @display_filter = Vivarium::DisplayFilter.compile(filter)
61
67
  @dest = dest
62
68
 
63
69
  @pid_comm = { observer_pid => "ruby" }
@@ -265,6 +271,7 @@ module Vivarium
265
271
  @dest.puts "[PROC pid=#{@observer_pid} comm=#{@pid_comm[@observer_pid] || 'ruby'}]"
266
272
  children = spans.reject { |s| s.synthetic && s.events.empty? }
267
273
  .reject { |s| @child_span_set.include?(s) }
274
+ .select { |s| span_visible?(s) }
268
275
  children.each_with_index do |span, idx|
269
276
  print_span(span, prefix: "", is_last: idx == children.size - 1)
270
277
  end
@@ -318,8 +325,17 @@ module Vivarium
318
325
  def build_span_children(span)
319
326
  proc_node_by_pid = {}
320
327
  root_children = []
328
+ prev_event_ktime = span.start_ktime
321
329
 
322
330
  span.events.each do |ev|
331
+ target_text = nil
332
+ if @display_filter.needs_payload?
333
+ target_text = render_target(ev)
334
+ next unless event_visible?(ev, span, target_text)
335
+ else
336
+ next unless event_visible?(ev, span)
337
+ end
338
+
323
339
  if ev.event_name == FORK_EVENT_NAME
324
340
  child_pid = read_proc_fork_child_pid(ev.payload)
325
341
  child_node = ProcNode.new(
@@ -333,28 +349,28 @@ module Vivarium
333
349
  ev_node = EventNode.new(
334
350
  kind: kind_for(ev),
335
351
  name: ev.event_name,
336
- target: render_target(ev),
352
+ target: target_text || render_target(ev),
337
353
  offset_ns: ev.ktime_ns - span.start_ktime,
338
354
  child_proc: child_node
339
355
  )
340
356
 
341
357
  parent_container = container_for_pid(ev.pid, span, proc_node_by_pid, root_children)
358
+ maybe_inject_drop_node(parent_container, ev, span, prev_event_ktime)
342
359
  append_event(parent_container, ev_node)
343
360
  else
344
361
  ev_node = EventNode.new(
345
362
  kind: kind_for(ev),
346
363
  name: ev.event_name,
347
- target: render_target(ev),
364
+ target: target_text || render_target(ev),
348
365
  offset_ns: ev.ktime_ns - span.start_ktime,
349
366
  child_proc: nil
350
367
  )
351
368
 
352
- if ev.pid == @observer_pid && ev.tid == span.tid
353
- append_event(root_children, ev_node)
369
+ container = if ev.pid == @observer_pid && ev.tid == span.tid
370
+ root_children
354
371
  elsif (node = proc_node_by_pid[ev.pid])
355
- append_event(node.children, ev_node)
372
+ node.children
356
373
  else
357
- # event from a descendant pid we haven't materialized — synthesize a stub PROC node
358
374
  stub = ProcNode.new(
359
375
  pid: ev.pid,
360
376
  comm: @pid_comm[ev.pid] || "?",
@@ -362,14 +378,19 @@ module Vivarium
362
378
  children: []
363
379
  )
364
380
  proc_node_by_pid[ev.pid] = stub
365
- append_event(stub.children, ev_node)
366
381
  root_children << stub
382
+ stub.children
367
383
  end
368
384
 
385
+ maybe_inject_drop_node(container, ev, span, prev_event_ktime)
386
+ append_event(container, ev_node)
387
+
369
388
  if ev.event_name == EXEC_EVENT_NAME && (node = proc_node_by_pid[ev.pid])
370
389
  node.comm = @pid_comm[ev.pid] || node.comm
371
390
  end
372
391
  end
392
+
393
+ prev_event_ktime = ev.ktime_ns
373
394
  end
374
395
 
375
396
  # Interleave child spans by start time among the event/proc nodes
@@ -415,9 +436,30 @@ module Vivarium
415
436
  container << ev_node
416
437
  end
417
438
 
439
+ def maybe_inject_drop_node(container, ev, span, prev_event_ktime = nil)
440
+ n = ev.dropped_since_last.to_i
441
+ return if n.zero?
442
+
443
+ # Show the start of the drop window (= time of last good event).
444
+ # The end is implicitly ev.ktime_ns (shown on the following event line).
445
+ drop_start_ns = prev_event_ktime ? (prev_event_ktime - span.start_ktime) : nil
446
+
447
+ container << EventNode.new(
448
+ kind: "DROP",
449
+ name: "dropped_events",
450
+ target: "#{n} event(s) lost (ringbuf overflow)",
451
+ offset_ns: drop_start_ns,
452
+ child_proc: nil
453
+ )
454
+ end
455
+
418
456
  def print_nodes(nodes, prefix)
419
- nodes.each_with_index do |node, idx|
420
- is_last = idx == nodes.size - 1
457
+ visible_nodes = nodes.select do |node|
458
+ !node.is_a?(Span) || span_visible?(node)
459
+ end
460
+
461
+ visible_nodes.each_with_index do |node, idx|
462
+ is_last = idx == visible_nodes.size - 1
421
463
  case node
422
464
  when EventNode
423
465
  print_event_node(node, prefix: prefix, is_last: is_last)
@@ -429,6 +471,21 @@ module Vivarium
429
471
  end
430
472
  end
431
473
 
474
+ def span_visible?(span)
475
+ @display_filter.allow_span_name?(span_display_name(span))
476
+ end
477
+
478
+ def event_visible?(ev, span, target_text = nil)
479
+ @display_filter.allow_event?(
480
+ event_name: ev.event_name,
481
+ severity: Vivarium.event_severity(ev.event_name),
482
+ span_name: span_display_name(span),
483
+ payload: target_text,
484
+ pid: ev.pid,
485
+ tid: ev.tid
486
+ )
487
+ end
488
+
432
489
  def print_event_node(node, prefix:, is_last:)
433
490
  marker = is_last && node.child_proc.nil? ? "└─ " : "├─ "
434
491
  marker = "└─ " if is_last && node.child_proc.nil?
@@ -457,6 +514,8 @@ module Vivarium
457
514
  def kind_for(ev)
458
515
  return "EXCP" if ev.event_name == "span_raise"
459
516
  return "USDT" if SPAN_EVENT_NAMES.include?(ev.event_name)
517
+ return "SSL" if ev.event_name == SSL_WRITE_EVENT_NAME
518
+ return "DL" if DL_EVENT_NAMES.include?(ev.event_name)
460
519
  return "LSM" if LSM_EVENT_NAMES.include?(ev.event_name)
461
520
  return "TP" if TP_EVENT_NAMES.include?(ev.event_name)
462
521
 
@@ -465,12 +524,28 @@ module Vivarium
465
524
 
466
525
  def render_target(ev)
467
526
  return render_raise_target(ev) if ev.event_name == "span_raise"
527
+ return render_ssl_write_target(ev) if ev.event_name == SSL_WRITE_EVENT_NAME
468
528
 
469
529
  text = Vivarium.render_event_payload(ev).to_s
470
530
  text = text.gsub(/\s+/, " ").strip
471
531
  text.empty? ? "-" : text
472
532
  end
473
533
 
534
+ def render_ssl_write_target(ev)
535
+ decoded = Vivarium.decode_ssl_write_payload(ev.payload)
536
+ http_decoder.render(
537
+ pid: ev.pid,
538
+ data: decoded[:data],
539
+ data_len: decoded[:data_len]
540
+ )
541
+ rescue StandardError => e
542
+ "ssl_write <decode-error: #{e.class}: #{e.message}>"
543
+ end
544
+
545
+ def http_decoder
546
+ @http_decoder ||= Vivarium::HttpDecoder.new
547
+ end
548
+
474
549
  def render_raise_target(ev)
475
550
  bytes = ev.payload.to_s.b
476
551
  return "-" if bytes.bytesize < 8
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vivarium
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end