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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +262 -0
  4. data/docs/design-decisions.md +142 -0
  5. data/docs/evidence-schema.md +388 -0
  6. data/docs/maintenance.md +92 -0
  7. data/docs/trace-schema.md +118 -0
  8. data/lib/bulldogger/capture.rb +98 -0
  9. data/lib/bulldogger/config.rb +57 -0
  10. data/lib/bulldogger/evidence.rb +106 -0
  11. data/lib/bulldogger/formatter.rb +103 -0
  12. data/lib/bulldogger/frame_source.rb +147 -0
  13. data/lib/bulldogger/integrations/minitest.rb +87 -0
  14. data/lib/bulldogger/integrations/rspec.rb +56 -0
  15. data/lib/bulldogger/minitest.rb +7 -0
  16. data/lib/bulldogger/pending.rb +58 -0
  17. data/lib/bulldogger/probe/bucket.rb +90 -0
  18. data/lib/bulldogger/probe/comparator.rb +95 -0
  19. data/lib/bulldogger/probe/method_stats.rb +215 -0
  20. data/lib/bulldogger/probe/raise_tracker.rb +141 -0
  21. data/lib/bulldogger/probe/registry.rb +32 -0
  22. data/lib/bulldogger/probe/session.rb +159 -0
  23. data/lib/bulldogger/probe/target.rb +13 -0
  24. data/lib/bulldogger/probe/target_resolver.rb +86 -0
  25. data/lib/bulldogger/probe/writer.rb +61 -0
  26. data/lib/bulldogger/probe.rb +36 -0
  27. data/lib/bulldogger/record/session.rb +334 -0
  28. data/lib/bulldogger/record/sqlite_converter.rb +86 -0
  29. data/lib/bulldogger/record/writer.rb +67 -0
  30. data/lib/bulldogger/record.rb +51 -0
  31. data/lib/bulldogger/redactor.rb +30 -0
  32. data/lib/bulldogger/rspec.rb +7 -0
  33. data/lib/bulldogger/run.rb +113 -0
  34. data/lib/bulldogger/version.rb +5 -0
  35. data/lib/bulldogger.rb +133 -0
  36. data/skills/bulldogger/SKILL.md +37 -0
  37. data/skills/bulldogger/references/failure-evidence.md +56 -0
  38. data/skills/bulldogger/references/probe.md +36 -0
  39. data/skills/bulldogger/references/record.md +28 -0
  40. 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