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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. 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 initialize(root:, output:, sample_rate: 1.0)
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
- @stack = []
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 = Time.now.utc
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: Time.now.utc)
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, :stack
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
- node_id = node_id_for(event)
43
- return unless node_id
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
- if event.event == :return
46
- stack.pop if stack.last == node_id
47
- return
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
- caller_id = stack.last
51
- nodes[node_id] = true
52
- edges[[caller_id, node_id]] = true if caller_id
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
- stack << node_id
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
- 'edges' => edges.keys.map { |caller_id, callee_id| { 'caller_id' => caller_id, 'callee_id' => callee_id } },
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
- File.write(output, payload.to_yaml)
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