bulldogger 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/docs/design-decisions.md +142 -0
- data/docs/evidence-schema.md +388 -0
- data/docs/maintenance.md +92 -0
- data/docs/trace-schema.md +118 -0
- data/lib/bulldogger/capture.rb +98 -0
- data/lib/bulldogger/config.rb +57 -0
- data/lib/bulldogger/evidence.rb +106 -0
- data/lib/bulldogger/formatter.rb +103 -0
- data/lib/bulldogger/frame_source.rb +147 -0
- data/lib/bulldogger/integrations/minitest.rb +87 -0
- data/lib/bulldogger/integrations/rspec.rb +56 -0
- data/lib/bulldogger/minitest.rb +7 -0
- data/lib/bulldogger/pending.rb +58 -0
- data/lib/bulldogger/probe/bucket.rb +90 -0
- data/lib/bulldogger/probe/comparator.rb +95 -0
- data/lib/bulldogger/probe/method_stats.rb +215 -0
- data/lib/bulldogger/probe/raise_tracker.rb +141 -0
- data/lib/bulldogger/probe/registry.rb +32 -0
- data/lib/bulldogger/probe/session.rb +159 -0
- data/lib/bulldogger/probe/target.rb +13 -0
- data/lib/bulldogger/probe/target_resolver.rb +86 -0
- data/lib/bulldogger/probe/writer.rb +61 -0
- data/lib/bulldogger/probe.rb +36 -0
- data/lib/bulldogger/record/session.rb +334 -0
- data/lib/bulldogger/record/sqlite_converter.rb +86 -0
- data/lib/bulldogger/record/writer.rb +67 -0
- data/lib/bulldogger/record.rb +51 -0
- data/lib/bulldogger/redactor.rb +30 -0
- data/lib/bulldogger/rspec.rb +7 -0
- data/lib/bulldogger/run.rb +113 -0
- data/lib/bulldogger/version.rb +5 -0
- data/lib/bulldogger.rb +133 -0
- data/skills/bulldogger/SKILL.md +37 -0
- data/skills/bulldogger/references/failure-evidence.md +56 -0
- data/skills/bulldogger/references/probe.md +36 -0
- data/skills/bulldogger/references/record.md +28 -0
- metadata +153 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "target_resolver"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
require_relative "raise_tracker"
|
|
6
|
+
require_relative "method_stats"
|
|
7
|
+
require_relative "writer"
|
|
8
|
+
|
|
9
|
+
module Bulldogger
|
|
10
|
+
module Probe
|
|
11
|
+
# One probe run: resolves and reserves its targets, builds one
|
|
12
|
+
# TracePoint per target (contract-verbs.md: "1 TracePoint can only
|
|
13
|
+
# enable one target -- can't nest-enable a targeting TracePoint"),
|
|
14
|
+
# aggregates every observed call/return into MethodStats, and
|
|
15
|
+
# writes the evidence file on finish.
|
|
16
|
+
class Session
|
|
17
|
+
def self.start(target_strings, config:, run:)
|
|
18
|
+
return nil unless config.enabled
|
|
19
|
+
|
|
20
|
+
targets = TargetResolver.resolve!(target_strings)
|
|
21
|
+
labels = targets.map(&:label)
|
|
22
|
+
Registry.reserve!(labels)
|
|
23
|
+
begin
|
|
24
|
+
new(targets: targets, config: config, run: run).enable!
|
|
25
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
26
|
+
# A target that fails mid-enable (all targets already passed
|
|
27
|
+
# TargetResolver's own check, so this is the unlikely case --
|
|
28
|
+
# a race with the app redefining a method, say) must not
|
|
29
|
+
# leave its label stuck in Registry: that would refuse every
|
|
30
|
+
# later probe of the same target for the rest of the process.
|
|
31
|
+
Registry.release(labels)
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.run(target_strings, config:, run:)
|
|
37
|
+
unless config.enabled
|
|
38
|
+
# The switch being off must not stop the caller's own code
|
|
39
|
+
# from running -- only from being observed and written. See
|
|
40
|
+
# AGENTS.md: the tool must not change what the app does.
|
|
41
|
+
yield if block_given?
|
|
42
|
+
return nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
session = start(target_strings, config: config, run: run)
|
|
46
|
+
# The written path comes from session.finish, not from
|
|
47
|
+
# `begin...ensure...end`'s own value (that would be the
|
|
48
|
+
# block's return value): an ensure clause's result is
|
|
49
|
+
# discarded unless captured explicitly, and finish must still
|
|
50
|
+
# run -- and its path still be returned -- when the block
|
|
51
|
+
# raises, since a probed run that hit an exception is exactly
|
|
52
|
+
# the case where the evidence is most worth having.
|
|
53
|
+
result = nil
|
|
54
|
+
begin
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
result = session.finish
|
|
58
|
+
end
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(targets:, config:, run:)
|
|
63
|
+
@targets = targets
|
|
64
|
+
@config = config
|
|
65
|
+
@run = run
|
|
66
|
+
@redactor = Redactor.new(config.redact_patterns)
|
|
67
|
+
@formatter = Formatter.new(config: config, redactor: @redactor)
|
|
68
|
+
@stats = targets.each_with_object({}) do |target, hash|
|
|
69
|
+
hash[target.label] = MethodStats.new(target: target, formatter: @formatter, redactor: @redactor,
|
|
70
|
+
max_samples: config.max_samples)
|
|
71
|
+
end
|
|
72
|
+
@trace_points = []
|
|
73
|
+
@started_at = Time.now.utc
|
|
74
|
+
@finished = false
|
|
75
|
+
@finish_mutex = Mutex.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enable!
|
|
79
|
+
RaiseTracker.instance.acquire
|
|
80
|
+
@targets.each do |target|
|
|
81
|
+
tp = build_trace_point(@stats[target.label])
|
|
82
|
+
tp.enable(target: target.unbound_method)
|
|
83
|
+
@trace_points << tp
|
|
84
|
+
end
|
|
85
|
+
self
|
|
86
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
87
|
+
@trace_points.each(&:disable)
|
|
88
|
+
RaiseTracker.instance.release
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def finish
|
|
93
|
+
@finish_mutex.synchronize do
|
|
94
|
+
return @result_path if @finished
|
|
95
|
+
|
|
96
|
+
@finished = true
|
|
97
|
+
@trace_points.each(&:disable)
|
|
98
|
+
RaiseTracker.instance.release
|
|
99
|
+
Registry.release(@targets.map(&:label))
|
|
100
|
+
@result_path = Writer.write(run: @run, config: @config, targets: @targets, stats: @stats,
|
|
101
|
+
started_at: @started_at)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# A one-line delegation, matching every other hook in this
|
|
108
|
+
# codebase (Capture, RaiseTracker, Record::Session): the block
|
|
109
|
+
# given to TracePoint.new does no work itself, it only calls a
|
|
110
|
+
# named method. That method is what a direct unit test calls
|
|
111
|
+
# too, with a double standing in for tp, so this hook's own
|
|
112
|
+
# routing logic is covered by an ordinary test and not just by
|
|
113
|
+
# the hook actually firing (which stdlib Coverage cannot see
|
|
114
|
+
# inside).
|
|
115
|
+
def build_trace_point(stats)
|
|
116
|
+
TracePoint.new(:call, :return) { |tp| dispatch(tp, stats) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Delegating the block body to this named method adds one real
|
|
120
|
+
# call frame between the app's own call site and where
|
|
121
|
+
# caller_locations runs -- measured directly (the block calling
|
|
122
|
+
# a method that calls caller_locations puts one extra frame
|
|
123
|
+
# under caller_locations, versus caller_locations running
|
|
124
|
+
# straight inside the block) -- so the offset here is 3, not the
|
|
125
|
+
# 2 a bare inline block needed before this method existed.
|
|
126
|
+
# probe_caller_offset_test.rb pins this number against this
|
|
127
|
+
# exact shape.
|
|
128
|
+
#
|
|
129
|
+
# Only the first frame is ever kept (`.first` was called on a
|
|
130
|
+
# 3-frame array before this hook existed), so only 1 frame is
|
|
131
|
+
# requested here. Measured at ~300ns/call, caller_locations is
|
|
132
|
+
# the single most expensive thing this hook does -- 4-5x the
|
|
133
|
+
# cost of TracePoint's own dispatch -- and building frames this
|
|
134
|
+
# code never reads was pure waste.
|
|
135
|
+
def dispatch(tp, stats)
|
|
136
|
+
case tp.event
|
|
137
|
+
when :call
|
|
138
|
+
RaiseTracker.instance.push_checkpoint
|
|
139
|
+
caller_loc = caller_locations(3, 1)&.first
|
|
140
|
+
stats.record_call(tp, caller_loc)
|
|
141
|
+
when :return
|
|
142
|
+
# Popped before record_return, not after: even if
|
|
143
|
+
# record_return itself raises (caught below), the
|
|
144
|
+
# checkpoint stack for this fiber is already balanced, so a
|
|
145
|
+
# later call on the same fiber is never thrown off by an
|
|
146
|
+
# earlier one's formatting failure.
|
|
147
|
+
raised = RaiseTracker.instance.pop_and_raised_exit?
|
|
148
|
+
stats.record_return(tp, raised: raised)
|
|
149
|
+
end
|
|
150
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
151
|
+
# The hook must never let an exception escape into the probed
|
|
152
|
+
# method's own call/return path -- doing so would replace the
|
|
153
|
+
# app's real control flow with one from inside this library.
|
|
154
|
+
# Same rule as Capture's :raise hook.
|
|
155
|
+
warn("bulldogger: probe hook failed: #{e.class}: #{e.message}") if ENV["BULLDOGGER_DEBUG"] == "1"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
module Probe
|
|
5
|
+
# One resolved probe target: the label the caller wrote
|
|
6
|
+
# ("Klass#method" / "Klass.method") paired with the UnboundMethod
|
|
7
|
+
# TracePoint#enable(target:) actually needs. Kept as a plain value
|
|
8
|
+
# object so Session and Writer never re-derive the label from the
|
|
9
|
+
# method (an UnboundMethod alone can't tell instance and singleton
|
|
10
|
+
# spelling apart) or re-run constant/method lookup after start.
|
|
11
|
+
Target = Struct.new(:label, :unbound_method)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "target"
|
|
4
|
+
|
|
5
|
+
module Bulldogger
|
|
6
|
+
module Probe
|
|
7
|
+
# Turns "Klass#method" / "Klass.method" strings into Targets,
|
|
8
|
+
# failing before any TracePoint is enabled. Resolving every target
|
|
9
|
+
# up front (not lazily, on first call) is the contract's own
|
|
10
|
+
# requirement: a probe session must not let bad instrumentation
|
|
11
|
+
# surface only after the caller's code has already started
|
|
12
|
+
# running.
|
|
13
|
+
module TargetResolver
|
|
14
|
+
INSTANCE_PATTERN = /\A(.+)#(.+)\z/
|
|
15
|
+
SINGLETON_PATTERN = /\A(.+)\.(.+)\z/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def resolve!(target_strings)
|
|
20
|
+
target_strings.map { |s| resolve_one!(s) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolve_one!(target_string)
|
|
24
|
+
owner_name, method_name, kind = split(target_string)
|
|
25
|
+
owner = resolve_constant!(owner_name, target_string)
|
|
26
|
+
unbound_method = resolve_unbound_method!(owner, method_name, kind, target_string)
|
|
27
|
+
assert_targetable!(unbound_method, target_string)
|
|
28
|
+
Target.new(target_string, unbound_method)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def split(target_string)
|
|
32
|
+
if (m = INSTANCE_PATTERN.match(target_string))
|
|
33
|
+
[m[1], m[2], :instance]
|
|
34
|
+
elsif (m = SINGLETON_PATTERN.match(target_string))
|
|
35
|
+
[m[1], m[2], :singleton]
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"bulldogger: invalid probe target #{target_string.inspect} " \
|
|
39
|
+
'(expected "Klass#method" or "Klass.method")'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# const_get(name, false) at each nesting level (not a single
|
|
44
|
+
# Object.const_get("A::B")) so a wrong nested name fails with
|
|
45
|
+
# the segment that is actually missing, and so this never
|
|
46
|
+
# accidentally resolves a same-named top-level constant that
|
|
47
|
+
# const_get's own inherit-search could reach from a deeper
|
|
48
|
+
# module (the `false` argument).
|
|
49
|
+
def resolve_constant!(owner_name, target_string)
|
|
50
|
+
owner_name.split("::").reject(&:empty?).reduce(Object) do |mod, name|
|
|
51
|
+
mod.const_get(name, false)
|
|
52
|
+
end
|
|
53
|
+
rescue NameError
|
|
54
|
+
raise NameError,
|
|
55
|
+
"bulldogger: no constant #{owner_name.inspect} for probe target #{target_string.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_unbound_method!(owner, method_name, kind, target_string)
|
|
59
|
+
if kind == :instance
|
|
60
|
+
owner.instance_method(method_name.to_sym)
|
|
61
|
+
else
|
|
62
|
+
owner.method(method_name.to_sym).unbind
|
|
63
|
+
end
|
|
64
|
+
rescue NameError
|
|
65
|
+
raise NameError, "bulldogger: no method for probe target #{target_string.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The only way to learn that a method is C-implemented is to
|
|
69
|
+
# actually try targeting it: UnboundMethod itself resolves fine
|
|
70
|
+
# for a C method (Array.instance_method(:push) succeeds); it is
|
|
71
|
+
# TracePoint#enable(target:) that raises ArgumentError (measured:
|
|
72
|
+
# "specified target is not supported", Array#push). The probe
|
|
73
|
+
# TracePoint built here is disabled again immediately -- its only
|
|
74
|
+
# purpose is to surface that error before any of the caller's
|
|
75
|
+
# targets go live.
|
|
76
|
+
def assert_targetable!(unbound_method, target_string)
|
|
77
|
+
probe_tp = TracePoint.new(:call) {}
|
|
78
|
+
probe_tp.enable(target: unbound_method)
|
|
79
|
+
probe_tp.disable
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"bulldogger: probe target #{target_string.inspect} is a C-implemented method and can't be traced"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bulldogger
|
|
6
|
+
module Probe
|
|
7
|
+
# Writes one probe session's aggregated MethodStats as
|
|
8
|
+
# tmp/bulldogger/run-.../probe-NNN-<slug>.json.
|
|
9
|
+
#
|
|
10
|
+
# Filenames use their own "probe-NNN-" sequence, not Run#next_path
|
|
11
|
+
# (which produces plain "NNN-<slug>.json" for failure evidence):
|
|
12
|
+
# Run is owned by the base-snapshot task and not touched here, and
|
|
13
|
+
# its sequence and this one are independent counters that happen
|
|
14
|
+
# to share a directory, not a single numbering space. Run#dir is
|
|
15
|
+
# still used for the lazy, on-first-write mkdir: probe is an
|
|
16
|
+
# explicit verb (AGENTS.md), so unlike a green test suite, a
|
|
17
|
+
# session that finished is itself the request to write, and does
|
|
18
|
+
# so even if it observed zero calls.
|
|
19
|
+
module Writer
|
|
20
|
+
@sequence = 0
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_accessor :sequence
|
|
25
|
+
attr_reader :mutex
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.write(run:, config:, targets:, stats:, started_at:)
|
|
29
|
+
dir = run.dir
|
|
30
|
+
return nil unless dir
|
|
31
|
+
|
|
32
|
+
path = File.join(dir, format("probe-%03d-%s.json", next_sequence, slug_for(targets)))
|
|
33
|
+
File.write(path, "#{JSON.pretty_generate(payload_for(config: config, targets: targets, stats: stats,
|
|
34
|
+
started_at: started_at))}\n")
|
|
35
|
+
path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.next_sequence
|
|
39
|
+
mutex.synchronize { self.sequence += 1 }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.slug_for(targets)
|
|
43
|
+
raw = targets.map(&:label).join("_")
|
|
44
|
+
raw.gsub(/[^A-Za-z0-9_-]/, "-")[0, 80]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.payload_for(config:, targets:, stats:, started_at:)
|
|
48
|
+
{
|
|
49
|
+
"schema_version" => 1,
|
|
50
|
+
"kind" => "probe",
|
|
51
|
+
"tool" => { "name" => "bulldogger", "version" => Bulldogger::VERSION },
|
|
52
|
+
"started_at" => started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
53
|
+
"targets" => targets.map(&:label),
|
|
54
|
+
"methods" => targets.each_with_object({}) { |t, h| h[t.label] = stats[t.label].to_h },
|
|
55
|
+
"limits" => { "max_samples" => config.max_samples, "max_value_length" => config.max_value_length }
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
private_class_method :next_sequence, :slug_for, :payload_for
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "redactor"
|
|
4
|
+
require_relative "formatter"
|
|
5
|
+
require_relative "probe/session"
|
|
6
|
+
require_relative "probe/comparator"
|
|
7
|
+
|
|
8
|
+
module Bulldogger
|
|
9
|
+
# Targeted capture: instead of a snapshot at the moment a test fails
|
|
10
|
+
# (Capture) or a full trace of every call (Record), a probe watches
|
|
11
|
+
# one or a few named methods across many calls and reports the
|
|
12
|
+
# *shape* of what it saw -- argument and return classes, nil counts,
|
|
13
|
+
# raise-exit counts, callers -- so a coding agent can answer
|
|
14
|
+
# "what does this method actually receive and return" without
|
|
15
|
+
# reading every call site by hand.
|
|
16
|
+
#
|
|
17
|
+
# See lib/bulldogger/probe/session.rb for the mechanism: one
|
|
18
|
+
# TracePoint per target method (:call/:return, targeted -- cheap and
|
|
19
|
+
# strictly filtered) plus one shared, ref-counted pair of untargeted
|
|
20
|
+
# TracePoints (:raise/:rescue, per contract-verbs.md the only way to
|
|
21
|
+
# tell a raise-exit apart from a method that legitimately returns
|
|
22
|
+
# nil).
|
|
23
|
+
module Probe
|
|
24
|
+
def self.start(target_strings, config:, run:)
|
|
25
|
+
Session.start(target_strings, config: config, run: run)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.call(target_strings, config:, run:, &block)
|
|
29
|
+
Session.run(target_strings, config: config, run: run, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.compare(path_a, path_b)
|
|
33
|
+
Comparator.compare(path_a, path_b)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../redactor"
|
|
4
|
+
require_relative "../formatter"
|
|
5
|
+
require_relative "../frame_source"
|
|
6
|
+
require_relative "writer"
|
|
7
|
+
|
|
8
|
+
module Bulldogger
|
|
9
|
+
module Record
|
|
10
|
+
# One recording session: a single TracePoint subscribed to
|
|
11
|
+
# :call/:return/:raise/:rescue, writing :call/:return/:raise as
|
|
12
|
+
# JSONL lines to one trace-NNN.jsonl file.
|
|
13
|
+
#
|
|
14
|
+
# :rescue is subscribed to but never written to the file -- it
|
|
15
|
+
# exists only to feed the raise-exit discriminator (see
|
|
16
|
+
# #on_return); WRITTEN_EVENTS is the actual shipped default the
|
|
17
|
+
# task report measures and docs describe.
|
|
18
|
+
class Session
|
|
19
|
+
WRITTEN_EVENTS = %w[call return raise].freeze
|
|
20
|
+
|
|
21
|
+
# A global (non-target) TracePoint is live for the entire
|
|
22
|
+
# process the instant #enable runs -- including the rest of
|
|
23
|
+
# this constructor after that line, and the entry into #stop
|
|
24
|
+
# before it disables anything. Without this filter, every
|
|
25
|
+
# session's trace opened with its own "Session#initialize
|
|
26
|
+
# returned" and "Record.start returned" lines (the latter
|
|
27
|
+
# dumping this session's own Config#inspect -- a value formatter
|
|
28
|
+
# never meant to be part of what a caller asked to observe), and
|
|
29
|
+
# closed with a "Session#stop" call line. Reusing FrameSource's
|
|
30
|
+
# already-measured skip_path_prefix (this library's own lib/
|
|
31
|
+
# directory) filters out bulldogger's own frames the same way
|
|
32
|
+
# Capture already does for :raise.
|
|
33
|
+
SKIP_PATH_PREFIX = Bulldogger::FrameSource.default_skip_path_prefix
|
|
34
|
+
|
|
35
|
+
# Where Ruby reports TracePoint's own methods as defined.
|
|
36
|
+
INTERNAL_TRACE_POINT_PATH = "<internal:trace_point>"
|
|
37
|
+
|
|
38
|
+
# sink: is an internal seam, not part of the public API a caller
|
|
39
|
+
# is meant to use. Bulldogger::Record.start never passes it; it
|
|
40
|
+
# exists only so the overhead benchmark (test/fixtures/record/
|
|
41
|
+
# bench.rb) can isolate the cost of value capture (TracePoint
|
|
42
|
+
# dispatch, Formatter, Redactor) from the JSONL write that
|
|
43
|
+
# always follows it on the real path -- there is no production
|
|
44
|
+
# code path that captures without writing, so measuring that
|
|
45
|
+
# split needs a substitute writer to exist at all. When sink is
|
|
46
|
+
# given, run_dir is never touched.
|
|
47
|
+
def initialize(config:, run_dir:, sink: nil)
|
|
48
|
+
@config = config
|
|
49
|
+
@enabled = config.enabled && !(run_dir.nil? && sink.nil?)
|
|
50
|
+
return unless @enabled
|
|
51
|
+
|
|
52
|
+
@redactor = Redactor.new(config.redact_patterns)
|
|
53
|
+
@formatter = Formatter.new(config: config, redactor: @redactor)
|
|
54
|
+
@writer = sink || Writer.new(run_dir: run_dir, header: header)
|
|
55
|
+
@mutex = Mutex.new
|
|
56
|
+
@sequence = 0
|
|
57
|
+
# Process-wide, not per-thread: it only ever needs to answer
|
|
58
|
+
# "did a raise happen between this frame's :call and its
|
|
59
|
+
# :return", and comparing the DELTA recorded at those two
|
|
60
|
+
# points (not the counter's absolute value at :return alone)
|
|
61
|
+
# makes that answer immune to an imbalance left over from
|
|
62
|
+
# before this particular frame's call started. See the
|
|
63
|
+
# contract's "raise で抜けたときの :return" note: an absolute
|
|
64
|
+
# counter would misclassify a frame if some unrelated raise
|
|
65
|
+
# elsewhere had already left the counter positive when this
|
|
66
|
+
# frame's own :call fired.
|
|
67
|
+
@raise_rescue_counter = 0
|
|
68
|
+
# One call stack per Thread. :call/:return for a given frame
|
|
69
|
+
# always fire on the same thread that made the call, so keying
|
|
70
|
+
# by Thread.current is what lets the delta comparison (and
|
|
71
|
+
# "depth") match each :return to its own :call instead of some
|
|
72
|
+
# other thread's -- a single shared stack would interleave
|
|
73
|
+
# unrelated frames from concurrent threads and pop the wrong
|
|
74
|
+
# entry.
|
|
75
|
+
@call_stacks = Hash.new { |h, k| h[k] = [] }
|
|
76
|
+
# Set just before the real TracePoint#disable call in #stop,
|
|
77
|
+
# and checked first in #handle. Without it: TracePoint#disable
|
|
78
|
+
# is itself a Ruby-level method (its own backtrace names
|
|
79
|
+
# <internal:trace_point>), so calling it while this session's
|
|
80
|
+
# TracePoint is still enabled fires one last :call/:return
|
|
81
|
+
# through this same handler before the disable takes effect --
|
|
82
|
+
# a phantom "TracePoint#disable" line at the end of every
|
|
83
|
+
# trace file (reproduced and confirmed while building this).
|
|
84
|
+
@stopping = false
|
|
85
|
+
# A Thread-local (not tp.disable) reentrancy guard. tp.disable
|
|
86
|
+
# was tried first and rejected: TracePoint's enabled flag is
|
|
87
|
+
# process-wide, so disabling it for the duration of one
|
|
88
|
+
# thread's handler silently drops every other thread's events
|
|
89
|
+
# that happen to fire in that same window -- reproduced with 4
|
|
90
|
+
# threads calling a traced method concurrently, which lost
|
|
91
|
+
# about three-quarters of the expected :call/:return events
|
|
92
|
+
# and corrupted #on_return's per-thread depth bookkeeping for
|
|
93
|
+
# the survivors. A key unique to this Session (not a fixed
|
|
94
|
+
# name) keeps two sessions -- e.g. Record.run nested inside
|
|
95
|
+
# another -- from suppressing each other's events on the same
|
|
96
|
+
# thread.
|
|
97
|
+
@reentrant_key = :"__bulldogger_record_session_#{object_id}__"
|
|
98
|
+
@trace_point = TracePoint.new(:call, :return, :raise, :rescue) { |tp| handle(tp) }
|
|
99
|
+
@trace_point.enable
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Idempotent, matching Bulldogger.start/stop and Run#finish
|
|
103
|
+
# elsewhere in this codebase: a second call must not re-close an
|
|
104
|
+
# already-closed IO (raises IOError) just because a caller
|
|
105
|
+
# defensively calls #stop from more than one place (an ensure
|
|
106
|
+
# block after an earlier explicit call, for example).
|
|
107
|
+
def stop
|
|
108
|
+
return nil unless @enabled
|
|
109
|
+
return @result_path if @stopping
|
|
110
|
+
|
|
111
|
+
@stopping = true
|
|
112
|
+
@trace_point.disable
|
|
113
|
+
@result_path = @writer.close
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def header
|
|
119
|
+
{
|
|
120
|
+
"schema_version" => 1,
|
|
121
|
+
"kind" => "record",
|
|
122
|
+
"started_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
123
|
+
"events" => WRITTEN_EVENTS,
|
|
124
|
+
"limits" => { "max_value_length" => @config.max_value_length }
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle(tp)
|
|
129
|
+
return if @stopping
|
|
130
|
+
# Filters out this library's own frames (Session, Writer,
|
|
131
|
+
# Formatter, Redactor all live under the same lib/ tree), so
|
|
132
|
+
# the recursion guard below only has to catch the case that
|
|
133
|
+
# filter cannot: formatting a value calls #inspect on
|
|
134
|
+
# whatever the app passed in, and an app-defined #inspect
|
|
135
|
+
# override is Ruby code outside this prefix.
|
|
136
|
+
return if tp.path&.start_with?(SKIP_PATH_PREFIX)
|
|
137
|
+
# The path filter above cannot reach TracePoint#enable itself:
|
|
138
|
+
# it is defined in Ruby's own <internal:trace_point>, not under
|
|
139
|
+
# this library's lib/. Enabling the trace point is the last
|
|
140
|
+
# thing start does, and from the second session onward in a
|
|
141
|
+
# process the previous session has already armed the mechanism,
|
|
142
|
+
# so this session's own enable call returns while tracing is
|
|
143
|
+
# live and writes a phantom event the app never made. The event
|
|
144
|
+
# is bulldogger's own machinery, which is exactly what
|
|
145
|
+
# SKIP_PATH_PREFIX exists to keep out of a trace.
|
|
146
|
+
return if tp.path == INTERNAL_TRACE_POINT_PATH
|
|
147
|
+
# Thread-local, not tp.disable (see @reentrant_key above): this
|
|
148
|
+
# only needs to stop the current thread's own call chain from
|
|
149
|
+
# recursing into itself (Formatter calling an app object's
|
|
150
|
+
# #inspect, which itself fires a new :call this same handler
|
|
151
|
+
# would otherwise try to process); it must not touch any other
|
|
152
|
+
# thread's visibility into the trace point at all.
|
|
153
|
+
return if Thread.current[@reentrant_key]
|
|
154
|
+
|
|
155
|
+
Thread.current[@reentrant_key] = true
|
|
156
|
+
begin
|
|
157
|
+
dispatch(tp)
|
|
158
|
+
ensure
|
|
159
|
+
Thread.current[@reentrant_key] = false
|
|
160
|
+
end
|
|
161
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
162
|
+
# Same rule as Capture's :raise hook (contract.md): a recorder
|
|
163
|
+
# must never be the reason the traced code fails.
|
|
164
|
+
warn("bulldogger: record failed: #{e.class}: #{e.message}") if debug?
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def dispatch(tp)
|
|
169
|
+
case tp.event
|
|
170
|
+
when :call then on_call(tp)
|
|
171
|
+
when :return then on_return(tp)
|
|
172
|
+
when :raise then on_raise(tp)
|
|
173
|
+
when :rescue then on_rescue(tp)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def on_call(tp)
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
stack = @call_stacks[Thread.current]
|
|
180
|
+
stack.push(@raise_rescue_counter)
|
|
181
|
+
@sequence += 1
|
|
182
|
+
@writer.write_event(build_call_event(tp, @sequence, stack.size))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def on_return(tp)
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
stack = @call_stacks[Thread.current]
|
|
189
|
+
counter_at_call = stack.last
|
|
190
|
+
depth = stack.size
|
|
191
|
+
raised = !counter_at_call.nil? && (@raise_rescue_counter - counter_at_call).positive?
|
|
192
|
+
stack.pop
|
|
193
|
+
@call_stacks.delete(Thread.current) if stack.empty?
|
|
194
|
+
@sequence += 1
|
|
195
|
+
@writer.write_event(build_return_event(tp, @sequence, depth, raised))
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def on_raise(tp)
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
@raise_rescue_counter += 1
|
|
202
|
+
@sequence += 1
|
|
203
|
+
depth = @call_stacks[Thread.current].size
|
|
204
|
+
@writer.write_event(build_raise_event(tp, @sequence, depth))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Not written to the trace (see WRITTEN_EVENTS); this only feeds
|
|
209
|
+
# the -1 half of the raise/rescue delta #on_return reads. Covers
|
|
210
|
+
# Ruby-level rescue only (Feature #19572, landed in 3.3.0) -- a
|
|
211
|
+
# C-level exception path such as Integer(s, exception: false)
|
|
212
|
+
# never fires :raise in the first place, so it cannot leave this
|
|
213
|
+
# counter unbalanced.
|
|
214
|
+
def on_rescue(_tp)
|
|
215
|
+
@mutex.synchronize { @raise_rescue_counter -= 1 }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_call_event(tp, seq, depth)
|
|
219
|
+
{
|
|
220
|
+
"event" => "call",
|
|
221
|
+
"seq" => seq,
|
|
222
|
+
"depth" => depth,
|
|
223
|
+
"path" => tp.path,
|
|
224
|
+
"line" => tp.lineno,
|
|
225
|
+
"method" => method_label(tp),
|
|
226
|
+
"args" => build_args(tp)
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_return_event(tp, seq, depth, raised)
|
|
231
|
+
event = {
|
|
232
|
+
"event" => "return",
|
|
233
|
+
"seq" => seq,
|
|
234
|
+
"depth" => depth,
|
|
235
|
+
"path" => tp.path,
|
|
236
|
+
"line" => tp.lineno,
|
|
237
|
+
"method" => method_label(tp)
|
|
238
|
+
}
|
|
239
|
+
# tp.return_value raises RuntimeError on any event but :return
|
|
240
|
+
# (measured; contract-verbs.md), and reading it at all when the
|
|
241
|
+
# method actually exited via raise would report a nil the
|
|
242
|
+
# method never returned -- the exact trap this discriminator
|
|
243
|
+
# exists to avoid, so the branch below never touches it when
|
|
244
|
+
# raised is true.
|
|
245
|
+
if raised
|
|
246
|
+
event["raised"] = true
|
|
247
|
+
else
|
|
248
|
+
event["return"] = @formatter.format(tp.return_value)
|
|
249
|
+
end
|
|
250
|
+
event
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def build_raise_event(tp, seq, depth)
|
|
254
|
+
{
|
|
255
|
+
"event" => "raise",
|
|
256
|
+
"seq" => seq,
|
|
257
|
+
"depth" => depth,
|
|
258
|
+
"path" => tp.path,
|
|
259
|
+
"line" => tp.lineno,
|
|
260
|
+
"method" => method_label(tp),
|
|
261
|
+
"exception" => exception_section(tp.raised_exception)
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def exception_section(exception)
|
|
266
|
+
message = exception.message.to_s
|
|
267
|
+
limit = @config.max_value_length * 5
|
|
268
|
+
section = {
|
|
269
|
+
"class" => exception_class_name(exception),
|
|
270
|
+
"message" => message.length > limit ? "#{message[0, limit]}…" : message
|
|
271
|
+
}
|
|
272
|
+
if message.length > limit
|
|
273
|
+
section["message_truncated"] = true
|
|
274
|
+
section["message_original_length"] = message.length
|
|
275
|
+
end
|
|
276
|
+
section
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def exception_class_name(exception)
|
|
280
|
+
exception.class.name || exception.class.to_s
|
|
281
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
282
|
+
"Object"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# At :call, tp.binding.local_variables holds exactly the
|
|
286
|
+
# parameters -- including omitted optional/keyword ones already
|
|
287
|
+
# filled with their default (measured: an omitted `discount:`
|
|
288
|
+
# showed as nil, not absent) -- so this is the actual-arguments
|
|
289
|
+
# read the contract calls for, not a later snapshot of whatever
|
|
290
|
+
# the method body has reassigned by :return time.
|
|
291
|
+
def build_args(tp)
|
|
292
|
+
binding = tp.binding
|
|
293
|
+
return {} unless binding
|
|
294
|
+
|
|
295
|
+
tp.parameters.each_with_object({}) do |(_kind, name), args|
|
|
296
|
+
next unless name # anonymous *, **, & params carry no name to look up
|
|
297
|
+
|
|
298
|
+
args[name.to_s] = build_value_entry(name, binding)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def build_value_entry(name, binding)
|
|
303
|
+
return { "redacted" => true, "reason" => "name" } if @redactor.redact_name?(name)
|
|
304
|
+
|
|
305
|
+
@formatter.format(binding.local_variable_get(name))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# "Klass#method" for an instance method, "Klass.method" for a
|
|
309
|
+
# class/singleton method. attached_object recovers the real
|
|
310
|
+
# owner name from tp.defined_class's singleton class; without it
|
|
311
|
+
# a class method would render as "#<Class:Klass>", which a
|
|
312
|
+
# reader has no use for. No respond_to? guard: singleton_class?
|
|
313
|
+
# and attached_object are both guaranteed on every Ruby this gem
|
|
314
|
+
# accepts (>= 4.0; attached_object landed in 3.2). The rescue
|
|
315
|
+
# below covers the real runtime edge case instead --
|
|
316
|
+
# attached_object raises TypeError for a singleton class with no
|
|
317
|
+
# attached object, e.g. nil/true/false's.
|
|
318
|
+
def method_label(tp)
|
|
319
|
+
klass = tp.defined_class
|
|
320
|
+
if klass.singleton_class?
|
|
321
|
+
"#{klass.attached_object}.#{tp.method_id}"
|
|
322
|
+
else
|
|
323
|
+
"#{klass}##{tp.method_id}"
|
|
324
|
+
end
|
|
325
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
326
|
+
tp.method_id.to_s
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def debug?
|
|
330
|
+
ENV["BULLDOGGER_DEBUG"] == "1"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|