vivarium 0.5.2 → 0.6.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 +4 -4
- data/examples/dlopen_demo_otel.rb +45 -0
- data/examples/env_access_external_demo_otel.rb +54 -0
- data/examples/env_access_ruby_demo_otel.rb +52 -0
- data/examples/execve_demo_otel.rb +55 -0
- data/examples/file_operation_demo_otel.rb +82 -0
- data/examples/network_client_demo_otel.rb +83 -0
- data/examples/network_client_demo_otel_out.rb +84 -0
- data/examples/privilege_event_demo_otel.rb +45 -0
- data/examples/raise_demo_otel.rb +47 -0
- data/examples/save_raw_demo_otel.rb +35 -0
- data/examples/signal_kill_demo_otel.rb +45 -0
- data/examples/ssl_write_demo_otel.rb +36 -0
- data/lib/vivarium/api_server.rb +26 -1
- data/lib/vivarium/cli.rb +101 -6
- data/lib/vivarium/correlator.rb +39 -1
- data/lib/vivarium/display_filter.rb +13 -2
- data/lib/vivarium/otel_exporter.rb +231 -0
- data/lib/vivarium/otel_stream.rb +277 -0
- data/lib/vivarium/raw_store.rb +27 -9
- data/lib/vivarium/tree_renderer.rb +19 -0
- data/lib/vivarium/version.rb +1 -1
- data/lib/vivarium.rb +130 -14
- metadata +15 -1
data/lib/vivarium/api_server.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "fiddle"
|
|
5
|
+
require "securerandom"
|
|
4
6
|
require "socket"
|
|
5
7
|
|
|
6
8
|
module Vivarium
|
|
@@ -43,21 +45,44 @@ module Vivarium
|
|
|
43
45
|
|
|
44
46
|
# Wraps the daemon's live BPF target maps so the API can (un)register PIDs.
|
|
45
47
|
class Registry
|
|
46
|
-
|
|
48
|
+
# struct otel_ctx_t { trace_id{u64 hi; u64 lo}; u64 span_id; u64 parent_span_id; }
|
|
49
|
+
OTEL_CTX_PACK_FMT = "Q<Q<Q<Q<"
|
|
50
|
+
|
|
51
|
+
def initialize(config_root_targets, config_spawned_targets, otel_ctx = nil)
|
|
47
52
|
@config_root_targets = config_root_targets
|
|
48
53
|
@config_spawned_targets = config_spawned_targets
|
|
54
|
+
@otel_ctx = otel_ctx
|
|
49
55
|
end
|
|
50
56
|
|
|
57
|
+
# Registering a root target marks the start of a trace: issue a fresh
|
|
58
|
+
# 128-bit trace_id and a root span_id (no parent) into the otel_ctx map,
|
|
59
|
+
# keyed by the root pid (== the main thread's tid).
|
|
51
60
|
def register(pid)
|
|
52
61
|
@config_root_targets[pid] = 1
|
|
62
|
+
return unless @otel_ctx
|
|
63
|
+
|
|
64
|
+
hi = SecureRandom.random_number(1 << 64)
|
|
65
|
+
lo = SecureRandom.random_number(1 << 64)
|
|
66
|
+
span = SecureRandom.random_number(1 << 64)
|
|
67
|
+
write_otel_ctx(pid, hi, lo, span, 0)
|
|
53
68
|
end
|
|
54
69
|
|
|
55
70
|
def unregister(pid)
|
|
56
71
|
@config_root_targets.delete(pid)
|
|
57
72
|
@config_spawned_targets.clear
|
|
73
|
+
@otel_ctx&.clear
|
|
58
74
|
rescue KeyError
|
|
59
75
|
nil
|
|
60
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def write_otel_ctx(tid, trace_hi, trace_lo, span_id, parent_span_id)
|
|
81
|
+
size = @otel_ctx.leafsize
|
|
82
|
+
ptr = Fiddle::Pointer.malloc(size)
|
|
83
|
+
ptr[0, size] = [trace_hi, trace_lo, span_id, parent_span_id].pack(OTEL_CTX_PACK_FMT)
|
|
84
|
+
@otel_ctx[tid] = ptr
|
|
85
|
+
end
|
|
61
86
|
end
|
|
62
87
|
|
|
63
88
|
# Minimal HTTP/1.1 server over a Unix domain socket exposing the daemon control API.
|
data/lib/vivarium/cli.rb
CHANGED
|
@@ -18,18 +18,36 @@ module Vivarium
|
|
|
18
18
|
opts.on("--socket PATH", "vivariumd Unix domain socket path") { |v| options[:socket_path] = v }
|
|
19
19
|
opts.on("-o", "--output PATH", "Log output file (default: stdout)") { |v| options[:dest] = File.open(v, "a") }
|
|
20
20
|
opts.on("--save-raw PATH", "load: save raw events to PATH instead of rendering") { |v| options[:save_raw] = v }
|
|
21
|
-
opts.on("--
|
|
21
|
+
opts.on("--otel-out PATH", "load: write OTLP/JSON spans to PATH instead of rendering") { |v| options[:otel_out] = v }
|
|
22
|
+
opts.on("--otel-endpoint URL", "load: stream OTLP/JSON spans to an OTLP/HTTP collector (or OTEL_EXPORTER_OTLP_ENDPOINT)") do |v|
|
|
23
|
+
options[:otel_endpoint] = v
|
|
24
|
+
end
|
|
25
|
+
opts.on("-a", "--all", "report: show all events (ignore default filter)") { options[:show_all] = true }
|
|
22
26
|
opts.on("--filter JSON", "report: filter as a JSON object (overrides --event/default)") { |v| options[:filter_json] = v }
|
|
23
|
-
opts.on("--event NAMES", "report: comma-separated event names to include") do |v|
|
|
27
|
+
opts.on("-e", "--event NAMES", "report: comma-separated event names to include") do |v|
|
|
24
28
|
options[:event_names] = v.split(",").map(&:strip).reject(&:empty?)
|
|
25
29
|
end
|
|
26
|
-
opts.on("--max-span-depth N", Integer, "report: collapse method spans deeper than N (events kept)") do |v|
|
|
30
|
+
opts.on("-d", "--max-span-depth N", Integer, "report: collapse method spans deeper than N (events kept)") do |v|
|
|
27
31
|
options[:max_span_depth] = v
|
|
28
32
|
end
|
|
33
|
+
opts.on("-u", "--dedup-values", "load/report: show repeated path_open/mmap_exec/dlopen/env_caccess values only once") do
|
|
34
|
+
options[:dedup_values] = true
|
|
35
|
+
end
|
|
36
|
+
opts.on("--dump-otel", "report: dump per-event otel fields (trace/span/uid/gid/comm) instead of the tree") do
|
|
37
|
+
options[:dump_otel] = true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
# order! stops at the first non-option (the subcommand), so parse once to
|
|
41
|
+
# collect options before the command, then again to collect options placed
|
|
42
|
+
# after it (e.g. `vivarium report --dedup-values FILE`).
|
|
43
|
+
begin
|
|
44
|
+
parser.order!(argv)
|
|
45
|
+
command = argv.shift
|
|
46
|
+
parser.order!(argv) if command
|
|
47
|
+
rescue OptionParser::ParseError => e
|
|
48
|
+
abort "#{e.message}\n\n#{parser.help}"
|
|
29
49
|
end
|
|
30
|
-
parser.order!(argv)
|
|
31
50
|
|
|
32
|
-
command = argv.shift
|
|
33
51
|
case command
|
|
34
52
|
when "load"
|
|
35
53
|
run_load!(argv, options)
|
|
@@ -45,8 +63,19 @@ module Vivarium
|
|
|
45
63
|
abort "Usage: vivarium load <script>" unless script
|
|
46
64
|
abort "File not found: #{script}" unless File.exist?(script)
|
|
47
65
|
|
|
66
|
+
filter = Vivarium::DEFAULT_FILTER
|
|
67
|
+
filter = filter.merge(dedup_values: true) if options[:dedup_values]
|
|
68
|
+
|
|
69
|
+
endpoint = options[:otel_endpoint] || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"]
|
|
70
|
+
otel_out = options[:otel_out]
|
|
71
|
+
if endpoint && otel_out
|
|
72
|
+
warn "[vivarium] --otel-endpoint takes precedence; ignoring --otel-out"
|
|
73
|
+
otel_out = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
48
76
|
Vivarium.observe(socket_path: options[:socket_path], dest: options[:dest],
|
|
49
|
-
filter:
|
|
77
|
+
filter: filter, save_raw: options[:save_raw],
|
|
78
|
+
otel_out: otel_out, otel_endpoint: endpoint) do
|
|
50
79
|
Kernel.load(File.expand_path(script))
|
|
51
80
|
end
|
|
52
81
|
end
|
|
@@ -63,10 +92,19 @@ module Vivarium
|
|
|
63
92
|
abort "Invalid vivarium-raw file #{raw}: #{e.message}"
|
|
64
93
|
end
|
|
65
94
|
meta = data[:meta]
|
|
95
|
+
|
|
96
|
+
if options[:dump_otel]
|
|
97
|
+
dump_otel(data[:events], options[:dest])
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
66
101
|
filter = resolve_report_filter(options)
|
|
67
102
|
if options[:max_span_depth]
|
|
68
103
|
filter = (filter || {}).merge(max_span_depth: options[:max_span_depth])
|
|
69
104
|
end
|
|
105
|
+
if options[:dedup_values]
|
|
106
|
+
filter = (filter || {}).merge(dedup_values: true)
|
|
107
|
+
end
|
|
70
108
|
|
|
71
109
|
Vivarium::TreeRenderer.new(
|
|
72
110
|
events: data[:events],
|
|
@@ -81,6 +119,63 @@ module Vivarium
|
|
|
81
119
|
).render
|
|
82
120
|
end
|
|
83
121
|
|
|
122
|
+
# Flat per-event dump of the OTel-oriented fields, sorted by ktime, with
|
|
123
|
+
# method-call span nesting resolved by compute_otel_rows. An alternative to
|
|
124
|
+
# the tree view for inspecting trace_id/span_id propagation.
|
|
125
|
+
def self.dump_otel(events, dest)
|
|
126
|
+
header = format("%-18s %-7s %-7s %-6s %-6s %-16s %-32s %-16s %-16s %-5s %s",
|
|
127
|
+
"ktime_ns", "pid", "tid", "uid", "gid", "comm",
|
|
128
|
+
"trace_id", "span_id", "parent_span", "depth", "event")
|
|
129
|
+
dest.puts header
|
|
130
|
+
compute_otel_rows(events).each do |e, span, parent, depth|
|
|
131
|
+
trace = format("%016x%016x", e.trace_hi.to_i, e.trace_lo.to_i)
|
|
132
|
+
dest.puts format("%-18d %-7d %-7d %-6d %-6d %-16s %-32s %016x %016x %-5d %s",
|
|
133
|
+
e.ktime_ns, e.pid, e.tid, e.uid.to_i, e.gid.to_i, e.comm.to_s,
|
|
134
|
+
trace, span, parent, depth, e.event_name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Resolve each event's effective OTel span by replaying span_start/span_stop
|
|
139
|
+
# into a per-tid stack of method-call spans on top of the BPF-provided
|
|
140
|
+
# thread/process span. Returns [event, span_id, parent_span_id, depth] per
|
|
141
|
+
# event. A method span's id is hashed from (trace_id, tid, span-start ktime):
|
|
142
|
+
# non-zero, unique within the trace, and stable across re-runs. When no method
|
|
143
|
+
# span is active, the thread span and its parent_span_id form the base frame.
|
|
144
|
+
# The stack is per-tid so spawned children nest independently.
|
|
145
|
+
def self.compute_otel_rows(events)
|
|
146
|
+
sorted = events.sort_by { |e| [e.ktime_ns, e.pid, e.tid] }
|
|
147
|
+
stacks = Hash.new { |h, k| h[k] = [] } # tid => [method_span_id, ...]
|
|
148
|
+
parent_of = {} # method_span_id => parent span_id
|
|
149
|
+
|
|
150
|
+
sorted.map do |e|
|
|
151
|
+
thread_span = e.span_id.to_i
|
|
152
|
+
stack = stacks[e.tid]
|
|
153
|
+
|
|
154
|
+
case e.event_name
|
|
155
|
+
when "span_start"
|
|
156
|
+
parent = stack.empty? ? thread_span : stack.last
|
|
157
|
+
span = Vivarium.synth_span_id(e.trace_hi.to_i, e.trace_lo.to_i, e.tid, e.ktime_ns)
|
|
158
|
+
parent_of[span] = parent
|
|
159
|
+
stack.push(span)
|
|
160
|
+
[e, span, parent, stack.size]
|
|
161
|
+
when "span_stop"
|
|
162
|
+
if stack.empty?
|
|
163
|
+
[e, thread_span, e.parent_span_id.to_i, 0]
|
|
164
|
+
else
|
|
165
|
+
span = stack.pop
|
|
166
|
+
[e, span, parent_of[span] || thread_span, stack.size + 1]
|
|
167
|
+
end
|
|
168
|
+
else
|
|
169
|
+
if stack.empty?
|
|
170
|
+
[e, thread_span, e.parent_span_id.to_i, 0]
|
|
171
|
+
else
|
|
172
|
+
span = stack.last
|
|
173
|
+
[e, span, parent_of[span] || thread_span, stack.size]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
84
179
|
# Resolve the report display filter by precedence:
|
|
85
180
|
# --all > --filter JSON > --event NAMES > DEFAULT_FILTER
|
|
86
181
|
def self.resolve_report_filter(options)
|
data/lib/vivarium/correlator.rb
CHANGED
|
@@ -14,13 +14,15 @@ module Vivarium
|
|
|
14
14
|
SAVE_RAW_PROGRESS_INTERVAL = 1000
|
|
15
15
|
|
|
16
16
|
def initialize(socket_path: Vivarium.socket_path, observer_pid:, main_tid:,
|
|
17
|
-
filter: nil, dest: $stdout, save_raw: nil)
|
|
17
|
+
filter: nil, dest: $stdout, save_raw: nil, otel_out: nil, otel_endpoint: nil)
|
|
18
18
|
@socket_path = socket_path
|
|
19
19
|
@observer_pid = observer_pid
|
|
20
20
|
@main_tid = main_tid
|
|
21
21
|
@filter = filter
|
|
22
22
|
@dest = dest
|
|
23
23
|
@save_raw = save_raw
|
|
24
|
+
@otel_out = otel_out
|
|
25
|
+
@otel_endpoint = otel_endpoint
|
|
24
26
|
|
|
25
27
|
@client = DaemonClient.new(socket_path: socket_path)
|
|
26
28
|
@events = []
|
|
@@ -35,6 +37,7 @@ module Vivarium
|
|
|
35
37
|
|
|
36
38
|
@session_start_iso = Time.now.utc.iso8601(3)
|
|
37
39
|
@session_start_ktime = Vivarium.monotonic_ktime_ns
|
|
40
|
+
start_stream_exporter if @otel_endpoint
|
|
38
41
|
@sock = @client.open_event_stream
|
|
39
42
|
@thread = Thread.new { run }
|
|
40
43
|
@started = true
|
|
@@ -51,6 +54,14 @@ module Vivarium
|
|
|
51
54
|
@session_stop_iso = Time.now.utc.iso8601(3)
|
|
52
55
|
@session_stop_ktime = Vivarium.monotonic_ktime_ns
|
|
53
56
|
|
|
57
|
+
if @stream
|
|
58
|
+
@stream.finalize(stop_ktime: @session_stop_ktime)
|
|
59
|
+
@stream_exporter.shutdown
|
|
60
|
+
warn "[vivarium] otel: streamed spans -> #{@otel_endpoint}"
|
|
61
|
+
@stopped = true
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
events_snapshot = @events_mutex.synchronize { @events.dup }
|
|
55
66
|
@stopped = true
|
|
56
67
|
|
|
@@ -71,6 +82,14 @@ module Vivarium
|
|
|
71
82
|
return
|
|
72
83
|
end
|
|
73
84
|
|
|
85
|
+
if @otel_out
|
|
86
|
+
File.open(@otel_out, "w") do |io|
|
|
87
|
+
Vivarium::OtelExporter.dump(io, events: events_snapshot, meta: meta)
|
|
88
|
+
end
|
|
89
|
+
warn "[vivarium] otel_out: wrote OTLP spans -> #{@otel_out}"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
74
93
|
TreeRenderer.new(events: events_snapshot, **meta, filter: @filter, dest: @dest).render
|
|
75
94
|
end
|
|
76
95
|
|
|
@@ -113,8 +132,27 @@ module Vivarium
|
|
|
113
132
|
buffer
|
|
114
133
|
end
|
|
115
134
|
|
|
135
|
+
def start_stream_exporter
|
|
136
|
+
@stream_exporter = Vivarium::OtelHttpExporter.new(endpoint: @otel_endpoint).start
|
|
137
|
+
@stream = Vivarium::OtelSpanStreamer.new(
|
|
138
|
+
exporter: @stream_exporter,
|
|
139
|
+
session_start_iso: @session_start_iso,
|
|
140
|
+
session_start_ktime: @session_start_ktime,
|
|
141
|
+
observer_pid: @observer_pid,
|
|
142
|
+
main_tid: @main_tid
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
116
146
|
def capture_event(bytes)
|
|
117
147
|
ev = Vivarium::RawStore.unpack_record(bytes)
|
|
148
|
+
|
|
149
|
+
# Streaming mode converts events to spans live and does not buffer them,
|
|
150
|
+
# so a resident observation does not grow memory without bound.
|
|
151
|
+
if @stream
|
|
152
|
+
@stream.on_event(ev)
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
118
156
|
count = @events_mutex.synchronize { @events << ev; @events.size }
|
|
119
157
|
report_save_progress(count)
|
|
120
158
|
rescue StandardError => e
|
|
@@ -5,7 +5,7 @@ require "set"
|
|
|
5
5
|
module Vivarium
|
|
6
6
|
class DisplayFilter
|
|
7
7
|
attr_reader :include_events, :exclude_events, :include_severities, :include_pids, :include_tids,
|
|
8
|
-
:max_span_depth
|
|
8
|
+
:max_span_depth, :dedup_values
|
|
9
9
|
|
|
10
10
|
def self.compile(raw)
|
|
11
11
|
return new if raw.nil?
|
|
@@ -30,6 +30,7 @@ module Vivarium
|
|
|
30
30
|
@include_span_names = normalize_string_set(fetch_key(:include_span_names, :span_names))
|
|
31
31
|
@span_pattern = normalize_pattern(fetch_key(:span, :span_pattern))
|
|
32
32
|
@max_span_depth = normalize_integer(fetch_key(:max_span_depth, :max_depth))
|
|
33
|
+
@dedup_values = normalize_bool(fetch_key(:dedup_values, :dedup))
|
|
33
34
|
|
|
34
35
|
payload_value = fetch_key(:payload)
|
|
35
36
|
@payload_pattern = normalize_pattern(fetch_key(:payload_pattern))
|
|
@@ -46,7 +47,7 @@ module Vivarium
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def needs_payload?
|
|
49
|
-
!@payload_pattern.nil? || !@payload_patterns_by_event.empty?
|
|
50
|
+
!@payload_pattern.nil? || !@payload_patterns_by_event.empty? || @dedup_values
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def allow_span_name?(span_name)
|
|
@@ -139,6 +140,16 @@ module Vivarium
|
|
|
139
140
|
nil
|
|
140
141
|
end
|
|
141
142
|
|
|
143
|
+
def normalize_bool(value)
|
|
144
|
+
case value
|
|
145
|
+
when true then true
|
|
146
|
+
when false, nil then false
|
|
147
|
+
when String then %w[1 true yes on].include?(value.strip.downcase)
|
|
148
|
+
when Numeric then !value.zero?
|
|
149
|
+
else true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
142
153
|
def normalize_pattern(value)
|
|
143
154
|
case value
|
|
144
155
|
when nil
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Vivarium
|
|
7
|
+
# Converts a captured event stream into an OTLP/JSON ResourceSpans document.
|
|
8
|
+
#
|
|
9
|
+
# Two span layers are emitted (see plan): a base thread/process span per tid
|
|
10
|
+
# (identified by the BPF-issued span_id) and method-call spans reconstructed
|
|
11
|
+
# from span_start/span_stop. Every other event becomes an OTel span event on
|
|
12
|
+
# the innermost active span. Method span ids come from Vivarium.synth_span_id
|
|
13
|
+
# so they match the report --dump-otel view.
|
|
14
|
+
module OtelExporter
|
|
15
|
+
SPAN_KIND_INTERNAL = 1
|
|
16
|
+
SERVICE_NAME = "vivarium"
|
|
17
|
+
INTERNAL_COMM_MATCH = [/otel_stream\.rb/, /correlator\.rb/].freeze
|
|
18
|
+
CONTROL_EVENT_NAMES = %w[proc_exit].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# io: a writable IO. Writes a single-line OTLP/JSON document.
|
|
23
|
+
def dump(io, events:, meta:)
|
|
24
|
+
io.write(JSON.generate(build_document(events: events, meta: meta)))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_document(events:, meta:)
|
|
28
|
+
wrap_document(build_spans(events: events, meta: meta))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Wraps a list of OTLP span hashes in the ResourceSpans envelope. Shared by
|
|
32
|
+
# the batch file exporter and the streaming HTTP exporter so both emit the
|
|
33
|
+
# same resource/scope.
|
|
34
|
+
def wrap_document(spans)
|
|
35
|
+
{
|
|
36
|
+
resourceSpans: [
|
|
37
|
+
{
|
|
38
|
+
resource: { attributes: [str_attr("service.name", SERVICE_NAME)] },
|
|
39
|
+
scopeSpans: [
|
|
40
|
+
{
|
|
41
|
+
scope: { name: SERVICE_NAME, version: Vivarium::VERSION },
|
|
42
|
+
spans: spans
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns an array of OTLP span hashes.
|
|
51
|
+
def build_spans(events:, meta:)
|
|
52
|
+
sorted = events.sort_by { |e| [e.ktime_ns, e.pid, e.tid] }
|
|
53
|
+
start_ktime = meta[:session_start_ktime].to_i
|
|
54
|
+
stop_ktime = meta[:session_stop_ktime].to_i
|
|
55
|
+
start_unix = iso_to_unix_ns(meta[:session_start_iso])
|
|
56
|
+
main_tid = meta[:main_tid]
|
|
57
|
+
to_unix = ->(k) { (start_unix + (k.to_i - start_ktime)).to_s }
|
|
58
|
+
|
|
59
|
+
thread_spans = {} # tid => mutable span record
|
|
60
|
+
method_spans = [] # all method span records
|
|
61
|
+
stacks = Hash.new { |h, k| h[k] = [] } # tid => [method span record, ...]
|
|
62
|
+
|
|
63
|
+
sorted.each do |ev|
|
|
64
|
+
next if internal_comm?(ev.comm)
|
|
65
|
+
next if control_event?(ev.event_name)
|
|
66
|
+
|
|
67
|
+
ts = (thread_spans[ev.tid] ||= new_thread_span(ev, main_tid))
|
|
68
|
+
ts[:comm] = ev.comm.to_s unless ev.comm.to_s.empty?
|
|
69
|
+
ts[:min_k] = ev.ktime_ns if ev.ktime_ns < ts[:min_k]
|
|
70
|
+
ts[:max_k] = ev.ktime_ns if ev.ktime_ns > ts[:max_k]
|
|
71
|
+
stack = stacks[ev.tid]
|
|
72
|
+
|
|
73
|
+
case ev.event_name
|
|
74
|
+
when "span_start"
|
|
75
|
+
name, file, lineno = read_span_payload(ev.payload)
|
|
76
|
+
parent = stack.empty? ? ts[:span_id] : stack.last[:span_id]
|
|
77
|
+
rec = {
|
|
78
|
+
tid: ev.tid, pid: ev.pid,
|
|
79
|
+
span_id: Vivarium.synth_span_id(ev.trace_hi.to_i, ev.trace_lo.to_i, ev.tid, ev.ktime_ns),
|
|
80
|
+
trace_hi: ev.trace_hi.to_i, trace_lo: ev.trace_lo.to_i,
|
|
81
|
+
parent: parent, name: (name.nil? || name.empty? ? "<anonymous>" : name),
|
|
82
|
+
file: file, lineno: lineno, start_k: ev.ktime_ns, stop_k: nil, events: []
|
|
83
|
+
}
|
|
84
|
+
method_spans << rec
|
|
85
|
+
stack.push(rec)
|
|
86
|
+
when "span_stop"
|
|
87
|
+
rec = stack.pop
|
|
88
|
+
rec[:stop_k] = ev.ktime_ns if rec
|
|
89
|
+
else
|
|
90
|
+
host = stack.empty? ? ts : stack.last
|
|
91
|
+
host[:events] << build_span_event(ev, to_unix)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
method_spans.each { |rec| rec[:stop_k] ||= stop_ktime }
|
|
96
|
+
|
|
97
|
+
out = []
|
|
98
|
+
thread_spans.each_value { |ts| out << thread_span_hash(ts, start_ktime, stop_ktime, to_unix) }
|
|
99
|
+
method_spans.each { |rec| out << method_span_hash(rec, to_unix) }
|
|
100
|
+
out
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- span record construction -------------------------------------------
|
|
104
|
+
|
|
105
|
+
def new_thread_span(ev, main_tid)
|
|
106
|
+
span_id = ev.span_id.to_i
|
|
107
|
+
span_id = Vivarium.synth_span_id(ev.trace_hi.to_i, ev.trace_lo.to_i, ev.tid, ev.ktime_ns) if span_id.zero?
|
|
108
|
+
{
|
|
109
|
+
tid: ev.tid, pid: ev.pid, span_id: span_id,
|
|
110
|
+
trace_hi: ev.trace_hi.to_i, trace_lo: ev.trace_lo.to_i,
|
|
111
|
+
parent: ev.parent_span_id.to_i, comm: ev.comm.to_s,
|
|
112
|
+
root: ev.tid == main_tid,
|
|
113
|
+
min_k: ev.ktime_ns, max_k: ev.ktime_ns, events: []
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def thread_span_hash(ts, start_ktime, stop_ktime, to_unix)
|
|
118
|
+
start_k = ts[:root] ? start_ktime : ts[:min_k]
|
|
119
|
+
stop_k = ts[:root] ? stop_ktime : ts[:max_k]
|
|
120
|
+
name = ts[:comm].empty? ? "tid=#{ts[:tid]}" : ts[:comm]
|
|
121
|
+
span_hash(
|
|
122
|
+
trace_hi: ts[:trace_hi], trace_lo: ts[:trace_lo],
|
|
123
|
+
span_id: ts[:span_id], parent: ts[:parent], name: name,
|
|
124
|
+
start_k: start_k, stop_k: stop_k, to_unix: to_unix,
|
|
125
|
+
attributes: [
|
|
126
|
+
int_attr("thread.id", ts[:tid]),
|
|
127
|
+
int_attr("process.pid", ts[:pid]),
|
|
128
|
+
str_attr("process.command", ts[:comm])
|
|
129
|
+
],
|
|
130
|
+
events: ts[:events] || []
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def method_span_hash(rec, to_unix)
|
|
135
|
+
attrs = [int_attr("thread.id", rec[:tid]), int_attr("process.pid", rec[:pid])]
|
|
136
|
+
attrs << str_attr("code.filepath", rec[:file]) if rec[:file] && !rec[:file].empty?
|
|
137
|
+
attrs << int_attr("code.lineno", rec[:lineno]) if rec[:lineno] && rec[:lineno] > 0
|
|
138
|
+
span_hash(
|
|
139
|
+
trace_hi: rec[:trace_hi], trace_lo: rec[:trace_lo],
|
|
140
|
+
span_id: rec[:span_id], parent: rec[:parent], name: rec[:name],
|
|
141
|
+
start_k: rec[:start_k], stop_k: rec[:stop_k], to_unix: to_unix,
|
|
142
|
+
attributes: attrs, events: rec[:events]
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def span_hash(trace_hi:, trace_lo:, span_id:, parent:, name:, start_k:, stop_k:, to_unix:, attributes:, events:)
|
|
147
|
+
hash = {
|
|
148
|
+
traceId: hex32(trace_hi, trace_lo),
|
|
149
|
+
spanId: hex16(span_id),
|
|
150
|
+
name: name,
|
|
151
|
+
kind: SPAN_KIND_INTERNAL,
|
|
152
|
+
startTimeUnixNano: to_unix.call(start_k),
|
|
153
|
+
endTimeUnixNano: to_unix.call(stop_k),
|
|
154
|
+
attributes: attributes
|
|
155
|
+
}
|
|
156
|
+
hash[:parentSpanId] = hex16(parent) unless parent.to_i.zero?
|
|
157
|
+
hash[:events] = events unless events.empty?
|
|
158
|
+
hash
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_span_event(ev, to_unix)
|
|
162
|
+
{ timeUnixNano: to_unix.call(ev.ktime_ns), name: ev.event_name, attributes: event_attributes(ev) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# OTLP attribute list describing an event (target/severity/uid/gid/comm/...).
|
|
166
|
+
# Reused for both span events and standalone single-event spans (streaming).
|
|
167
|
+
def event_attributes(ev)
|
|
168
|
+
target =
|
|
169
|
+
begin
|
|
170
|
+
Vivarium.render_event_payload(ev).to_s.gsub(/\s+/, " ").strip
|
|
171
|
+
rescue StandardError
|
|
172
|
+
""
|
|
173
|
+
end
|
|
174
|
+
attrs = [
|
|
175
|
+
int_attr("thread.id", ev.tid),
|
|
176
|
+
int_attr("process.pid", ev.pid),
|
|
177
|
+
int_attr("user.id", ev.uid),
|
|
178
|
+
int_attr("group.id", ev.gid),
|
|
179
|
+
str_attr("severity", Vivarium.event_severity(ev.event_name))
|
|
180
|
+
]
|
|
181
|
+
attrs << str_attr("process.command", ev.comm.to_s) unless ev.comm.to_s.empty?
|
|
182
|
+
attrs << str_attr("target", target) unless target.empty?
|
|
183
|
+
attrs
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- helpers -------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def internal_comm?(comm)
|
|
189
|
+
value = comm.to_s
|
|
190
|
+
INTERNAL_COMM_MATCH.any? { |regex| value.match?(regex) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def control_event?(event_name)
|
|
194
|
+
CONTROL_EVENT_NAMES.include?(event_name.to_s)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def read_span_payload(payload)
|
|
198
|
+
bytes = payload.to_s.b
|
|
199
|
+
return [nil, nil, -1] if bytes.empty?
|
|
200
|
+
|
|
201
|
+
name = Vivarium.c_string(bytes[0, Vivarium::SPAN_METHOD_SIZE])
|
|
202
|
+
file = Vivarium.c_string(bytes[Vivarium::SPAN_METHOD_SIZE, Vivarium::SPAN_FILE_SIZE])
|
|
203
|
+
lineno = bytes.bytesize > Vivarium::SPAN_LINENO_OFFSET ? bytes[Vivarium::SPAN_LINENO_OFFSET, 8].unpack1("q<") : -1
|
|
204
|
+
[name, file, lineno]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def iso_to_unix_ns(iso)
|
|
208
|
+
return 0 if iso.nil? || iso.to_s.empty?
|
|
209
|
+
|
|
210
|
+
(Time.iso8601(iso).to_r * 1_000_000_000).to_i
|
|
211
|
+
rescue ArgumentError
|
|
212
|
+
0
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def hex32(hi, lo)
|
|
216
|
+
format("%016x%016x", hi.to_i & Vivarium::U64_MASK, lo.to_i & Vivarium::U64_MASK)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def hex16(value)
|
|
220
|
+
format("%016x", value.to_i & Vivarium::U64_MASK)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def str_attr(key, value)
|
|
224
|
+
{ key: key, value: { stringValue: value.to_s } }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def int_attr(key, value)
|
|
228
|
+
{ key: key, value: { intValue: value.to_i.to_s } }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|