necropsy 0.1.0 → 0.3.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/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1234 -62
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- metadata +106 -2
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
require_relative 'redis_nonblocking_io'
|
|
8
|
+
|
|
9
|
+
module Necropsy
|
|
10
|
+
module Analyzers
|
|
11
|
+
module Dynamic
|
|
12
|
+
class RedisCommandError < StandardError; end
|
|
13
|
+
|
|
14
|
+
class RedisTransport
|
|
15
|
+
include RedisNonblockingIO
|
|
16
|
+
|
|
17
|
+
CRLF = "\r\n"
|
|
18
|
+
|
|
19
|
+
def initialize(uri:, limits:, deadline:, redactor:)
|
|
20
|
+
@uri = uri
|
|
21
|
+
@limits = limits
|
|
22
|
+
@deadline = deadline
|
|
23
|
+
@redactor = redactor
|
|
24
|
+
@response_bytes = 0
|
|
25
|
+
@read_buffer = +''
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def connect
|
|
29
|
+
@tcp_socket = connect_tcp
|
|
30
|
+
@socket = uri.scheme == 'rediss' ? connect_tls : tcp_socket
|
|
31
|
+
deadline.check!
|
|
32
|
+
self
|
|
33
|
+
rescue Error
|
|
34
|
+
close
|
|
35
|
+
raise
|
|
36
|
+
rescue SystemCallError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError => e
|
|
37
|
+
close
|
|
38
|
+
domain_error("Could not connect securely to Redis at #{target}: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close
|
|
42
|
+
socket&.close
|
|
43
|
+
tcp_socket&.close unless socket.equal?(tcp_socket)
|
|
44
|
+
rescue IOError, SystemCallError
|
|
45
|
+
nil
|
|
46
|
+
ensure
|
|
47
|
+
@socket = nil
|
|
48
|
+
@tcp_socket = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def command(*parts)
|
|
52
|
+
deadline.check!
|
|
53
|
+
write_all(redis_command(parts))
|
|
54
|
+
response = read_response
|
|
55
|
+
deadline.check!
|
|
56
|
+
response
|
|
57
|
+
rescue RedisCommandError
|
|
58
|
+
raise
|
|
59
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
60
|
+
domain_error("Redis connection failed: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :uri, :limits, :deadline, :redactor, :socket, :tcp_socket
|
|
66
|
+
|
|
67
|
+
def connect_tcp
|
|
68
|
+
expires_at = monotonic_time + limits.connect_timeout
|
|
69
|
+
last_error = nil
|
|
70
|
+
addresses(expires_at).each do |address|
|
|
71
|
+
candidate = Socket.new(address.afamily, address.socktype, address.protocol)
|
|
72
|
+
begin
|
|
73
|
+
finish_tcp_connect(candidate, address, expires_at)
|
|
74
|
+
return candidate
|
|
75
|
+
rescue SystemCallError, SocketError => e
|
|
76
|
+
candidate.close
|
|
77
|
+
last_error = e
|
|
78
|
+
rescue Error
|
|
79
|
+
candidate.close
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
raise(last_error || SocketError.new("No Redis address found for #{uri.host}"))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def addresses(expires_at)
|
|
87
|
+
timeout = [expires_at - monotonic_time, deadline.remaining].min
|
|
88
|
+
domain_error('Redis address resolution timeout exceeded') unless timeout.positive?
|
|
89
|
+
|
|
90
|
+
# Limit asynchronous interruption to pure resolution, before any socket resource exists.
|
|
91
|
+
resolved = Timeout.timeout(timeout) do
|
|
92
|
+
Addrinfo.getaddrinfo(uri.host, uri.port || 6379, nil, :STREAM)
|
|
93
|
+
end
|
|
94
|
+
deadline.check!
|
|
95
|
+
domain_error('Redis address resolution timeout exceeded') unless (expires_at - monotonic_time).positive?
|
|
96
|
+
resolved
|
|
97
|
+
rescue Timeout::Error
|
|
98
|
+
deadline.check!
|
|
99
|
+
domain_error('Redis address resolution timeout exceeded')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def finish_tcp_connect(candidate, address, expires_at)
|
|
103
|
+
status = candidate.connect_nonblock(address, exception: false)
|
|
104
|
+
return unless wait_status?(status)
|
|
105
|
+
|
|
106
|
+
wait_for_io(status, io: candidate, expires_at: expires_at, label: 'Redis connect timeout exceeded')
|
|
107
|
+
error = candidate.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
|
|
108
|
+
raise SystemCallError.new("connect(2) for #{target}", error) unless error.zero?
|
|
109
|
+
rescue Errno::EISCONN
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def connect_tls
|
|
114
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
115
|
+
store = OpenSSL::X509::Store.new
|
|
116
|
+
store.set_default_paths
|
|
117
|
+
context.cert_store = store
|
|
118
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
119
|
+
context.verify_hostname = true if context.respond_to?(:verify_hostname=)
|
|
120
|
+
|
|
121
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
|
|
122
|
+
ssl_socket.sync_close = true
|
|
123
|
+
ssl_socket.hostname = uri.host
|
|
124
|
+
finish_tls_connect(ssl_socket)
|
|
125
|
+
ssl_socket.post_connection_check(uri.host)
|
|
126
|
+
ssl_socket
|
|
127
|
+
rescue StandardError
|
|
128
|
+
ssl_socket&.close
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def target
|
|
133
|
+
"#{uri.host}:#{uri.port || 6379}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def redis_command(parts)
|
|
137
|
+
encoded = parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }
|
|
138
|
+
(["*#{parts.length}"] + encoded).join(CRLF) + CRLF
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_response(depth = 0)
|
|
142
|
+
prefix = read_exact(1)
|
|
143
|
+
case prefix
|
|
144
|
+
when '+' then read_line
|
|
145
|
+
when '-' then raise RedisCommandError, redactor.redact(read_line)
|
|
146
|
+
when ':' then read_integer('integer')
|
|
147
|
+
when '$' then read_bulk_string
|
|
148
|
+
when '*' then read_array(depth)
|
|
149
|
+
else domain_error("Malformed Redis response prefix #{prefix.inspect}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read_bulk_string
|
|
154
|
+
length = read_integer('bulk length', nullable: true)
|
|
155
|
+
return nil if length == -1
|
|
156
|
+
|
|
157
|
+
domain_error('Invalid negative Redis bulk length') if length.negative?
|
|
158
|
+
domain_error('Redis bulk response exceeds configured limit') if length > limits.max_bulk_bytes
|
|
159
|
+
|
|
160
|
+
value = read_exact(length)
|
|
161
|
+
domain_error('Malformed Redis bulk response terminator') unless read_exact(2) == CRLF
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def read_array(depth)
|
|
166
|
+
length = read_integer('array length', nullable: true)
|
|
167
|
+
return nil if length == -1
|
|
168
|
+
|
|
169
|
+
domain_error('Invalid negative Redis array length') if length.negative?
|
|
170
|
+
domain_error('Redis array response exceeds configured element limit') if length > limits.max_array_elements
|
|
171
|
+
|
|
172
|
+
child_depth = depth + 1
|
|
173
|
+
domain_error('Redis response exceeds configured nesting depth') if child_depth > limits.max_resp_depth
|
|
174
|
+
Array.new(length) { read_response(child_depth) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read_integer(label, nullable: false)
|
|
178
|
+
text = read_line
|
|
179
|
+
domain_error("Malformed Redis #{label}") unless text.match?(/\A-?\d+\z/)
|
|
180
|
+
|
|
181
|
+
value = Integer(text, 10)
|
|
182
|
+
domain_error("Invalid negative Redis #{label}") if value.negative? && (!nullable || value < -1)
|
|
183
|
+
value
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read_line
|
|
187
|
+
loop do
|
|
188
|
+
if (terminator = @read_buffer.index(CRLF))
|
|
189
|
+
return @read_buffer.slice!(0, terminator + CRLF.bytesize).delete_suffix(CRLF)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
fill_buffer
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_exact(length)
|
|
197
|
+
fill_buffer while @read_buffer.bytesize < length
|
|
198
|
+
@read_buffer.slice!(0, length)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fill_buffer
|
|
202
|
+
deadline.check!
|
|
203
|
+
remaining = limits.max_response_bytes - @response_bytes
|
|
204
|
+
domain_error('Redis response exceeds configured byte limit') unless remaining.positive?
|
|
205
|
+
|
|
206
|
+
chunk = read_nonblock([4096, remaining].min)
|
|
207
|
+
@response_bytes += chunk.bytesize
|
|
208
|
+
@read_buffer << chunk
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def domain_error(message)
|
|
212
|
+
raise Error, redactor.redact(message), cause: nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Dynamic
|
|
6
|
+
module RuntimeReference
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build(symbol_id:, file: nil, line: nil, definition_id: nil)
|
|
10
|
+
{
|
|
11
|
+
'definition_id' => definition_id,
|
|
12
|
+
'symbol_id' => symbol_id,
|
|
13
|
+
'file' => file,
|
|
14
|
+
'line' => line
|
|
15
|
+
}.compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalize(value)
|
|
19
|
+
return value if value.is_a?(String)
|
|
20
|
+
return unless value.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
data = value.to_h { |key, item| [key.to_s, item] }
|
|
23
|
+
symbol_id = data['symbol_id']
|
|
24
|
+
return unless symbol_id.is_a?(String) && !symbol_id.empty?
|
|
25
|
+
return unless valid_optional_string?(data, 'definition_id') && valid_optional_string?(data, 'file')
|
|
26
|
+
|
|
27
|
+
line = normalize_line(data['line'])
|
|
28
|
+
return if data.key?('line') && !data['line'].nil? && line.nil?
|
|
29
|
+
|
|
30
|
+
build(
|
|
31
|
+
definition_id: optional_string(data['definition_id']),
|
|
32
|
+
symbol_id: symbol_id,
|
|
33
|
+
file: optional_string(data['file']),
|
|
34
|
+
line: line
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sort_key(reference)
|
|
39
|
+
normalized = normalize(reference)
|
|
40
|
+
return ['', reference.to_s, '', 0] unless normalized.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
[
|
|
43
|
+
normalized.fetch('definition_id', ''),
|
|
44
|
+
normalized.fetch('symbol_id'),
|
|
45
|
+
normalized.fetch('file', ''),
|
|
46
|
+
normalized.fetch('line', 0)
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def key(reference)
|
|
51
|
+
sort_key(reference)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def preferred(structured:, legacy:)
|
|
55
|
+
return Array(legacy) if structured.nil?
|
|
56
|
+
|
|
57
|
+
structured_values = Array(structured)
|
|
58
|
+
structured_symbols = structured_values.filter_map do |reference|
|
|
59
|
+
normalize(reference).then { |normalized| normalized['symbol_id'] if normalized.is_a?(Hash) }
|
|
60
|
+
end.to_set
|
|
61
|
+
structured_values + Array(legacy).reject { |symbol_id| structured_symbols.include?(symbol_id) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def malformed?(reference)
|
|
65
|
+
!reference.is_a?(String) && normalize(reference).nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def relative_file(root, path)
|
|
69
|
+
expanded = File.expand_path(path)
|
|
70
|
+
prefix = "#{File.expand_path(root)}/"
|
|
71
|
+
return unless expanded.start_with?(prefix)
|
|
72
|
+
|
|
73
|
+
expanded.delete_prefix(prefix)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def optional_string(value)
|
|
77
|
+
value if value.is_a?(String) && !value.empty?
|
|
78
|
+
end
|
|
79
|
+
private_class_method :optional_string
|
|
80
|
+
|
|
81
|
+
def valid_optional_string?(data, key)
|
|
82
|
+
!data.key?(key) || data[key].nil? || (data[key].is_a?(String) && !data[key].empty?)
|
|
83
|
+
end
|
|
84
|
+
private_class_method :valid_optional_string?
|
|
85
|
+
|
|
86
|
+
def normalize_line(value)
|
|
87
|
+
return if value.nil?
|
|
88
|
+
|
|
89
|
+
line = Integer(value, exception: false)
|
|
90
|
+
line if line&.positive?
|
|
91
|
+
end
|
|
92
|
+
private_class_method :normalize_line
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -1,57 +1,124 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'fileutils'
|
|
4
5
|
require 'time'
|
|
5
6
|
require 'yaml'
|
|
7
|
+
require_relative '../../clock'
|
|
8
|
+
require_relative 'runtime_reference'
|
|
9
|
+
require_relative 'observation_policy'
|
|
6
10
|
|
|
7
11
|
module Necropsy
|
|
8
12
|
module Analyzers
|
|
9
13
|
module Dynamic
|
|
10
14
|
class TracePointCollector
|
|
11
|
-
def self.record(root:, output:, sample_rate: 1.0, &)
|
|
12
|
-
new(root: root, output: output, sample_rate: sample_rate).record(&)
|
|
15
|
+
def self.record(root:, output:, sample_rate: 1.0, clock: nil, random: nil, &)
|
|
16
|
+
new(root: root, output: output, sample_rate: sample_rate, clock: clock, random: random).record(&)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
def
|
|
19
|
+
def self.install_at_exit(root:, output:, sample_rate: 1.0, merge: false, run_id: nil, clock: nil, random: nil)
|
|
20
|
+
new(
|
|
21
|
+
root: root, output: output, sample_rate: sample_rate, merge: merge, run_id: run_id,
|
|
22
|
+
clock: clock, random: random
|
|
23
|
+
).install_at_exit
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(root:, output:, sample_rate: 1.0, merge: false, run_id: nil, clock: nil, random: nil)
|
|
16
27
|
@root = File.expand_path(root)
|
|
17
28
|
@output = output
|
|
18
29
|
@sample_rate = sample_rate.to_f
|
|
30
|
+
raise Error, 'sample_rate must be between 0.0 and 1.0' unless @sample_rate.between?(0.0, 1.0)
|
|
31
|
+
|
|
19
32
|
@nodes = {}
|
|
33
|
+
@node_references = {}
|
|
20
34
|
@edges = {}
|
|
21
|
-
@
|
|
35
|
+
@edge_references = {}
|
|
36
|
+
@stacks = {}.compare_by_identity
|
|
37
|
+
@lock = Mutex.new
|
|
38
|
+
@merge = merge
|
|
39
|
+
@run_id = run_id
|
|
40
|
+
@clock = clock || -> { Clock.new.time }
|
|
41
|
+
@random = random || default_random
|
|
22
42
|
end
|
|
23
43
|
|
|
24
44
|
def record
|
|
25
|
-
started_at =
|
|
45
|
+
started_at = current_time
|
|
26
46
|
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
27
47
|
tracer.enable
|
|
28
48
|
yield
|
|
29
49
|
ensure
|
|
30
50
|
tracer&.disable
|
|
31
|
-
write_payload(started_at: started_at, finished_at:
|
|
51
|
+
write_payload(started_at: started_at, finished_at: current_time)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def install_at_exit
|
|
55
|
+
started_at = current_time
|
|
56
|
+
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
57
|
+
tracer.enable
|
|
58
|
+
at_exit do
|
|
59
|
+
tracer.disable
|
|
60
|
+
write_payload(started_at: started_at, finished_at: current_time)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
warn "Necropsy TracePoint collector failed: #{e.message}"
|
|
63
|
+
end
|
|
32
64
|
end
|
|
33
65
|
|
|
34
66
|
private
|
|
35
67
|
|
|
36
|
-
attr_reader :root, :output, :sample_rate, :nodes, :edges, :
|
|
68
|
+
attr_reader :root, :output, :sample_rate, :nodes, :node_references, :edges, :edge_references, :stacks, :lock,
|
|
69
|
+
:run_id, :clock, :random
|
|
70
|
+
|
|
71
|
+
def merge?
|
|
72
|
+
@merge
|
|
73
|
+
end
|
|
37
74
|
|
|
38
75
|
def capture(event)
|
|
39
76
|
return unless project_path?(event.path)
|
|
40
|
-
return if sample_rate < 1.0 && SecureRandom.random_number > sample_rate
|
|
41
77
|
|
|
42
|
-
|
|
43
|
-
return unless
|
|
78
|
+
reference = node_reference_for(event)
|
|
79
|
+
return unless reference
|
|
80
|
+
|
|
81
|
+
lock.synchronize do
|
|
82
|
+
stack = stacks[Thread.current] ||= []
|
|
83
|
+
if event.event == :return
|
|
84
|
+
unwind_stack(stack, reference)
|
|
85
|
+
stacks.delete(Thread.current) if stack.empty?
|
|
86
|
+
return
|
|
87
|
+
end
|
|
44
88
|
|
|
45
|
-
|
|
46
|
-
stack.
|
|
47
|
-
|
|
89
|
+
sampled = sample_rate >= 1.0 || (sample_rate.positive? && random.call < sample_rate)
|
|
90
|
+
caller_reference = stack.last&.first if stack.last&.last
|
|
91
|
+
if sampled
|
|
92
|
+
record_node(reference)
|
|
93
|
+
record_edge(caller_reference, reference) if caller_reference
|
|
94
|
+
end
|
|
95
|
+
stack << [reference, sampled]
|
|
48
96
|
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def current_time
|
|
100
|
+
clock.call.utc
|
|
101
|
+
end
|
|
49
102
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
def default_random
|
|
104
|
+
epoch = ENV.fetch(Clock::SOURCE_DATE_EPOCH, nil)
|
|
105
|
+
return SecureRandom.method(:random_number) if epoch.nil? || epoch.empty?
|
|
53
106
|
|
|
54
|
-
|
|
107
|
+
Random.new(Integer(epoch)).method(:rand)
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
SecureRandom.method(:random_number)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unwind_stack(stack, reference)
|
|
113
|
+
reference_key = invocation_key(reference)
|
|
114
|
+
index = stack.rindex { |frame| invocation_key(frame.first) == reference_key }
|
|
115
|
+
return unless index
|
|
116
|
+
|
|
117
|
+
stack.slice!(index..)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def invocation_key(reference)
|
|
121
|
+
reference.values_at('definition_id', 'symbol_id', 'file')
|
|
55
122
|
end
|
|
56
123
|
|
|
57
124
|
def project_path?(path)
|
|
@@ -67,6 +134,28 @@ module Necropsy
|
|
|
67
134
|
"#{owner}#{separator}#{event.method_id}"
|
|
68
135
|
end
|
|
69
136
|
|
|
137
|
+
def node_reference_for(event)
|
|
138
|
+
symbol_id = node_id_for(event)
|
|
139
|
+
return unless symbol_id
|
|
140
|
+
|
|
141
|
+
RuntimeReference.build(
|
|
142
|
+
symbol_id: symbol_id,
|
|
143
|
+
file: RuntimeReference.relative_file(root, event.path),
|
|
144
|
+
line: event.respond_to?(:lineno) ? event.lineno : nil
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def record_node(reference)
|
|
149
|
+
nodes[reference.fetch('symbol_id')] = true
|
|
150
|
+
node_references[RuntimeReference.key(reference)] = reference
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def record_edge(caller, callee)
|
|
154
|
+
edges[[caller.fetch('symbol_id'), callee.fetch('symbol_id')]] = true
|
|
155
|
+
key = [RuntimeReference.key(caller), RuntimeReference.key(callee)]
|
|
156
|
+
edge_references[key] = { 'caller_id' => caller.dup, 'callee_id' => callee.dup }
|
|
157
|
+
end
|
|
158
|
+
|
|
70
159
|
def owner_and_separator(defined_class)
|
|
71
160
|
name = defined_class.name
|
|
72
161
|
return [name, '#'] if name
|
|
@@ -79,16 +168,132 @@ module Necropsy
|
|
|
79
168
|
|
|
80
169
|
def write_payload(started_at:, finished_at:)
|
|
81
170
|
payload = {
|
|
171
|
+
'schema_version' => 2,
|
|
172
|
+
'collector' => { 'name' => 'necropsy-trace-point', 'version' => Necropsy::VERSION },
|
|
173
|
+
'scope' => { 'sample_unit' => 'event', 'sample_rate' => sample_rate },
|
|
174
|
+
'quality' => { 'dropped_events' => 0, 'overflowed' => false },
|
|
82
175
|
'nodes' => nodes.keys.sort,
|
|
83
|
-
'
|
|
176
|
+
'node_references' => node_references.values.sort_by { |reference| RuntimeReference.sort_key(reference) },
|
|
177
|
+
'edges' => legacy_edges,
|
|
178
|
+
'edge_references' => structured_edges,
|
|
84
179
|
'observation' => {
|
|
85
180
|
'started_at' => started_at.iso8601,
|
|
86
181
|
'finished_at' => finished_at.iso8601,
|
|
87
182
|
'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
|
|
88
|
-
'sample_rate' => sample_rate
|
|
183
|
+
'sample_rate' => sample_rate,
|
|
184
|
+
'collector_overhead' => {
|
|
185
|
+
'wall_time_seconds' => [finished_at - started_at, 0].max.round(6),
|
|
186
|
+
'observed_nodes' => nodes.length,
|
|
187
|
+
'observed_edges' => edges.length
|
|
188
|
+
}
|
|
89
189
|
}
|
|
90
190
|
}
|
|
91
|
-
|
|
191
|
+
payload['observation']['run_id'] = run_id if run_id
|
|
192
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
193
|
+
merge? ? write_merged_payload(payload) : File.write(output, payload.to_yaml)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def write_merged_payload(payload)
|
|
197
|
+
File.open(output, File::RDWR | File::CREAT, 0o644) do |file|
|
|
198
|
+
file.flock(File::LOCK_EX)
|
|
199
|
+
existing = trace_payload_for_current_run(read_payload(file), payload)
|
|
200
|
+
merged = merge_payload(existing, payload)
|
|
201
|
+
file.rewind
|
|
202
|
+
file.truncate(0)
|
|
203
|
+
file.write(merged.to_yaml)
|
|
204
|
+
file.flush
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def read_payload(file)
|
|
209
|
+
file.rewind
|
|
210
|
+
content = file.read
|
|
211
|
+
return {} if content.empty?
|
|
212
|
+
|
|
213
|
+
YAML.safe_load(content, aliases: false) || {}
|
|
214
|
+
rescue Psych::Exception
|
|
215
|
+
{}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def trace_payload_for_current_run(existing, payload)
|
|
219
|
+
current_run = payload.dig('observation', 'run_id')
|
|
220
|
+
return existing unless current_run
|
|
221
|
+
return existing if existing.empty? || existing.dig('observation', 'run_id') == current_run
|
|
222
|
+
|
|
223
|
+
{}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def merge_payload(left, right)
|
|
227
|
+
edges = (Array(left['edges']) + Array(right['edges'])).uniq do |edge|
|
|
228
|
+
[edge['caller_id'], edge['callee_id']]
|
|
229
|
+
end
|
|
230
|
+
left_observation = left.fetch('observation', {})
|
|
231
|
+
right_observation = right.fetch('observation', {})
|
|
232
|
+
observation = ObservationPolicy.compatible_merge(left_observation, right_observation)
|
|
233
|
+
observation['started_at'] = [left_observation['started_at'], right_observation['started_at']].compact.min
|
|
234
|
+
observation['finished_at'] = [left_observation['finished_at'], right_observation['finished_at']].compact.max
|
|
235
|
+
observation['days'] = [left_observation['days'].to_i, right_observation['days'].to_i, 1].max
|
|
236
|
+
observation['collector_overhead'] = merge_overhead(left_observation, right_observation)
|
|
237
|
+
observation['processes'] = process_count(left_observation) + process_count(right_observation)
|
|
238
|
+
{
|
|
239
|
+
'schema_version' => [left['schema_version'], right['schema_version'], 2].compact.max,
|
|
240
|
+
'collector' => right['collector'] || left['collector'],
|
|
241
|
+
'scope' => right['scope'] || left['scope'],
|
|
242
|
+
'quality' => right['quality'] || left['quality'],
|
|
243
|
+
'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
|
|
244
|
+
'node_references' => merge_node_references(left['node_references'], right['node_references']),
|
|
245
|
+
'edges' => edges,
|
|
246
|
+
'edge_references' => merge_edge_references(left['edge_references'], right['edge_references']),
|
|
247
|
+
'observation' => observation
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def legacy_edges
|
|
252
|
+
edges.keys.sort.map { |caller_id, callee_id| { 'caller_id' => caller_id, 'callee_id' => callee_id } }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def structured_edges
|
|
256
|
+
edge_references.values.sort_by do |edge|
|
|
257
|
+
[RuntimeReference.sort_key(edge.fetch('caller_id')), RuntimeReference.sort_key(edge.fetch('callee_id'))]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def merge_node_references(left, right)
|
|
262
|
+
(Array(left) + Array(right))
|
|
263
|
+
.filter_map { |reference| RuntimeReference.normalize(reference) }
|
|
264
|
+
.uniq { |reference| RuntimeReference.key(reference) }
|
|
265
|
+
.sort_by { |reference| RuntimeReference.sort_key(reference) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def merge_edge_references(left, right)
|
|
269
|
+
(Array(left) + Array(right)).filter_map do |edge|
|
|
270
|
+
next unless edge.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
caller = RuntimeReference.normalize(edge['caller_id'] || edge[:caller_id])
|
|
273
|
+
callee = RuntimeReference.normalize(edge['callee_id'] || edge[:callee_id])
|
|
274
|
+
{ 'caller_id' => caller, 'callee_id' => callee } if caller && callee
|
|
275
|
+
end.uniq do |edge|
|
|
276
|
+
[RuntimeReference.key(edge.fetch('caller_id')), RuntimeReference.key(edge.fetch('callee_id'))]
|
|
277
|
+
end.sort_by do |edge|
|
|
278
|
+
[RuntimeReference.sort_key(edge.fetch('caller_id')), RuntimeReference.sort_key(edge.fetch('callee_id'))]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def process_count(observation)
|
|
283
|
+
return observation['processes'].to_i if observation['processes']
|
|
284
|
+
return 0 if observation.empty?
|
|
285
|
+
|
|
286
|
+
1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def merge_overhead(left, right)
|
|
290
|
+
left_overhead = left.fetch('collector_overhead', {})
|
|
291
|
+
right_overhead = right.fetch('collector_overhead', {})
|
|
292
|
+
{
|
|
293
|
+
'wall_time_seconds' => left_overhead['wall_time_seconds'].to_f + right_overhead['wall_time_seconds'].to_f,
|
|
294
|
+
'observed_nodes' => left_overhead['observed_nodes'].to_i + right_overhead['observed_nodes'].to_i,
|
|
295
|
+
'observed_edges' => left_overhead['observed_edges'].to_i + right_overhead['observed_edges'].to_i
|
|
296
|
+
}
|
|
92
297
|
end
|
|
93
298
|
end
|
|
94
299
|
end
|
|
@@ -9,7 +9,9 @@ module Necropsy
|
|
|
9
9
|
name: :trace_point,
|
|
10
10
|
kind: :dynamic,
|
|
11
11
|
soundness: :observational,
|
|
12
|
-
description: 'Imports method execution and caller-callee edges captured by Necropsy TracePoint recording.'
|
|
12
|
+
description: 'Imports method execution and caller-callee edges captured by Necropsy TracePoint recording.',
|
|
13
|
+
version: Necropsy::VERSION,
|
|
14
|
+
assumptions: %w[positive_observations_only tracepoint_call_events]
|
|
13
15
|
)
|
|
14
16
|
end
|
|
15
17
|
end
|