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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ module Probe
5
+ # One params[name]/returns aggregate: a running tally of observed
6
+ # classes and nil-ness (updated on every call), plus a bounded,
7
+ # fully-serialized sample of the first max_samples values.
8
+ #
9
+ # Tally and sample are deliberately split: contract-verbs.md
10
+ # measured full serialization at ~24us/event, which would blow the
11
+ # probe overhead budget on a call-dense target if it ran on every
12
+ # call. Class name and nil? never touch #inspect, so the tally
13
+ # stays cheap regardless of how many calls a session sees.
14
+ class Bucket
15
+ def initialize(formatter:, max_samples:, redacted_name: false)
16
+ @formatter = formatter
17
+ @max_samples = max_samples
18
+ @redacted_name = redacted_name
19
+ @classes = Hash.new(0)
20
+ @nil_count = 0
21
+ @samples = []
22
+ @count = 0
23
+ end
24
+
25
+ def record(value)
26
+ @count += 1
27
+ @classes[safe_class_name(value)] += 1
28
+ @nil_count += 1 if value.nil?
29
+ @samples << sample_for(value) if @samples.size < @max_samples
30
+ end
31
+
32
+ def to_h
33
+ h = { "classes" => @classes, "nil_count" => @nil_count, "samples" => @samples }
34
+ omitted = @count - @samples.size
35
+ # Present only when something was actually cut, so a reader
36
+ # can trust its absence -- the same rule contract.md applies
37
+ # to frames_omitted/locals_omitted.
38
+ h["samples_omitted"] = omitted if omitted.positive?
39
+ h
40
+ end
41
+
42
+ # Folds another Bucket's tally into this one. This is the
43
+ # finish-time merge step for thread-local aggregation: each
44
+ # thread records into its own Bucket with no lock, and totals
45
+ # are combined once, in one place, instead of every call taking
46
+ # a Mutex to update a single shared Bucket.
47
+ #
48
+ # Samples are re-capped at @max_samples here too: each
49
+ # thread-local Bucket already capped its own samples
50
+ # independently, so without this cap a target hit by N threads
51
+ # could publish up to N * max_samples samples, silently
52
+ # widening the documented limit just because more than one
53
+ # thread happened to record some of the calls.
54
+ def merge!(other)
55
+ @count += other.count
56
+ other.classes.each { |klass, n| @classes[klass] += n }
57
+ @nil_count += other.nil_count
58
+ other.samples.each do |sample|
59
+ break if @samples.size >= @max_samples
60
+
61
+ @samples << sample
62
+ end
63
+ end
64
+
65
+ protected
66
+
67
+ attr_reader :count, :classes, :nil_count, :samples
68
+
69
+ private
70
+
71
+ # Redaction gates on the parameter *name*, decided once at
72
+ # Bucket construction, never on the value: this bucket's caller
73
+ # (MethodStats) already checked the name against redact_patterns
74
+ # before a single value was ever inspected, matching Redactor's
75
+ # own rule of checking the name before touching the value.
76
+ def sample_for(value)
77
+ return { "redacted" => true, "reason" => "name" } if @redacted_name
78
+
79
+ @formatter.format(value)
80
+ end
81
+
82
+ def safe_class_name(value)
83
+ klass = value.class
84
+ klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
85
+ rescue Exception # rubocop:disable Lint/RescueException
86
+ "Object"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bulldogger
6
+ module Probe
7
+ # Compares two probe evidence files for behavior-preservation:
8
+ # "did this refactor change what the probed methods actually do."
9
+ #
10
+ # Compares *shape* (classes seen, nil_count, raised_exits, calls,
11
+ # the set of callers, parameters), not raw sample values, because
12
+ # the default `inspect` embeds an object's memory address (e.g.
13
+ # `#<Order:0x000000012a>`), so two probes of *identical*,
14
+ # unchanged code would otherwise report differences on every run.
15
+ # Samples are still diffed as a secondary signal, but only after
16
+ # normalizing `0x[0-9a-f]+` out of them for exactly this reason.
17
+ module Comparator
18
+ HEX_ADDRESS = /0x[0-9a-f]+/i
19
+
20
+ module_function
21
+
22
+ def compare(path_a, path_b)
23
+ a = JSON.parse(File.read(path_a))
24
+ b = JSON.parse(File.read(path_b))
25
+ differences = []
26
+ compare_methods(a["methods"] || {}, b["methods"] || {}, differences)
27
+ { "identical" => differences.empty?, "differences" => differences }
28
+ end
29
+
30
+ def compare_methods(methods_a, methods_b, differences)
31
+ (methods_a.keys | methods_b.keys).each do |label|
32
+ ma = methods_a[label]
33
+ mb = methods_b[label]
34
+ if ma.nil? || mb.nil?
35
+ differences << "#{label}: only present in #{ma.nil? ? 'b' : 'a'}"
36
+ next
37
+ end
38
+
39
+ compare_method(label, ma, mb, differences)
40
+ end
41
+ end
42
+
43
+ def compare_method(label, ma, mb, differences)
44
+ add_diff(differences, label, "calls", ma["calls"], mb["calls"])
45
+ add_diff(differences, label, "raised_exits", ma["raised_exits"], mb["raised_exits"])
46
+ add_diff(differences, label, "parameters", ma["parameters"], mb["parameters"])
47
+ add_diff(differences, label, "raised", ma["raised"], mb["raised"])
48
+ # Set only, not counts: contract-verbs.md compares "the set of
49
+ # callers", not how many times each one fired.
50
+ add_diff(differences, label, "callers", (ma["callers"] || {}).keys.sort, (mb["callers"] || {}).keys.sort)
51
+ compare_bucket("#{label}.returns", ma["returns"], mb["returns"], differences)
52
+ compare_params(label, ma["params"] || {}, mb["params"] || {}, differences)
53
+ end
54
+
55
+ def compare_params(label, params_a, params_b, differences)
56
+ (params_a.keys | params_b.keys).each do |name|
57
+ compare_bucket("#{label} param #{name}", params_a[name], params_b[name], differences)
58
+ end
59
+ end
60
+
61
+ def compare_bucket(prefix, bucket_a, bucket_b, differences)
62
+ bucket_a ||= {}
63
+ bucket_b ||= {}
64
+ add_diff(differences, prefix, "classes", bucket_a["classes"], bucket_b["classes"])
65
+ add_diff(differences, prefix, "nil_count", bucket_a["nil_count"], bucket_b["nil_count"])
66
+ compare_samples(prefix, bucket_a["samples"] || [], bucket_b["samples"] || [], differences)
67
+ end
68
+
69
+ def compare_samples(prefix, samples_a, samples_b, differences)
70
+ normalized_a = normalize_samples(samples_a)
71
+ normalized_b = normalize_samples(samples_b)
72
+ return if normalized_a == normalized_b
73
+
74
+ differences << "#{prefix}.samples changed (after normalizing object addresses): " \
75
+ "a=#{normalized_a.inspect} b=#{normalized_b.inspect}"
76
+ end
77
+
78
+ def normalize_samples(samples)
79
+ samples.map do |sample|
80
+ next sample unless sample.is_a?(Hash) && sample["value"].is_a?(String)
81
+
82
+ sample.merge("value" => sample["value"].gsub(HEX_ADDRESS, "0x…"))
83
+ end
84
+ end
85
+
86
+ def add_diff(differences, label, field, value_a, value_b)
87
+ return if value_a == value_b
88
+
89
+ differences << "#{label}.#{field} changed: a=#{value_a.inspect} b=#{value_b.inspect}"
90
+ end
91
+ private_class_method :compare_methods, :compare_method, :compare_params, :compare_bucket,
92
+ :compare_samples, :normalize_samples, :add_diff
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bucket"
4
+
5
+ module Bulldogger
6
+ module Probe
7
+ # Aggregates every :call/:return pair observed for one probe
8
+ # target into the "methods"[label] shape the evidence JSON
9
+ # publishes: call count, per-parameter and return-value shape, the
10
+ # raise-exit count, and the set of call sites.
11
+ #
12
+ # record_call/record_return take no lock: each caller writes only
13
+ # into its own ThreadLocal (below), reached through Thread.current[]
14
+ # -- fiber-local, like RaiseTracker's own storage, so two fibers on
15
+ # the same OS thread still get separate slots -- keyed uniquely to
16
+ # this MethodStats instance, so two threads calling the same probed
17
+ # method concurrently never touch the same mutable state. A per-call
18
+ # Mutex#synchronize was measured to be on the hot path for every
19
+ # single call and return; a fiber only pays a lock once -- the first
20
+ # time it is ever seen by this target, to register its ThreadLocal
21
+ # in @locals -- and #to_h folds every one of them into the published
22
+ # totals exactly once, at finish time.
23
+ class MethodStats
24
+ # One fiber's own, lock-free view of this target. Merged into
25
+ # the instance's published totals by #merge_local, once, in
26
+ # #to_h.
27
+ ThreadLocal = Struct.new(:calls, :raised_exits, :param_buckets, :returns, :raised, :callers)
28
+
29
+ def initialize(target:, formatter:, redactor:, max_samples:)
30
+ @formatter = formatter
31
+ @redactor = redactor
32
+ @max_samples = max_samples
33
+ @parameters = declared_parameters(target.unbound_method)
34
+
35
+ @calls = 0
36
+ @raised_exits = 0
37
+ @param_buckets = build_param_buckets
38
+ @returns = new_returns_bucket
39
+ @raised = Hash.new(0)
40
+ # key => [count, one representative Thread::Backtrace::Location].
41
+ # One Hash, not two: a second hash keyed the same way would be
42
+ # a second lookup on every single call for no benefit -- see
43
+ # #record_caller.
44
+ @callers = {}
45
+
46
+ # Unique per instance (not a fixed name): two targets probed
47
+ # in the same session must not share one thread-local slot.
48
+ @thread_key = :"bulldogger_probe_method_stats_#{object_id}"
49
+ @locals = []
50
+ @locals_mutex = Mutex.new
51
+ @merge_mutex = Mutex.new
52
+ @merged = false
53
+ end
54
+
55
+ def record_call(tp, caller_location)
56
+ local = thread_local
57
+ local.calls += 1
58
+ record_param_samples(local, tp.binding)
59
+ record_caller(local, caller_location)
60
+ end
61
+
62
+ # raised: true means contract-verbs.md's raise-exit discriminator
63
+ # fired for this return -- this call must not be counted as a
64
+ # nil return (that would fabricate a "returned nil" the method
65
+ # never actually did), so it updates the thread-local @raised
66
+ # instead of @returns and never touches the returns bucket at
67
+ # all.
68
+ def record_return(tp, raised:)
69
+ local = thread_local
70
+ if raised
71
+ local.raised_exits += 1
72
+ klass = RaiseTracker.instance.current_exception_class_name || "Object"
73
+ local.raised[klass] += 1
74
+ else
75
+ local.returns.record(tp.return_value)
76
+ end
77
+ end
78
+
79
+ def to_h
80
+ merge!
81
+ {
82
+ "calls" => @calls,
83
+ "raised_exits" => @raised_exits,
84
+ "parameters" => @parameters,
85
+ "params" => params_to_h,
86
+ "returns" => @returns.to_h,
87
+ "raised" => @raised,
88
+ "callers" => callers_to_h
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ # UnboundMethod#parameters is declared, static shape -- computed
95
+ # once here, not derived from tp.parameters per call, since it
96
+ # cannot change between calls and re-deriving it every time would
97
+ # only add cost with no new information.
98
+ def declared_parameters(unbound_method)
99
+ unbound_method.parameters.map { |kind, name| [kind.to_s, name&.to_s] }
100
+ end
101
+
102
+ def build_param_buckets
103
+ @parameters.each_with_object({}) do |(_kind, name), buckets|
104
+ next if name.nil? # anonymous *, **, or & has no bindable local to sample
105
+
106
+ buckets[name] = Bucket.new(formatter: @formatter, max_samples: @max_samples,
107
+ redacted_name: @redactor.redact_name?(name))
108
+ end
109
+ end
110
+
111
+ def new_returns_bucket
112
+ Bucket.new(formatter: @formatter, max_samples: @max_samples)
113
+ end
114
+
115
+ # Lazily creates and registers this fiber's ThreadLocal on
116
+ # first touch. @locals_mutex is taken only here -- once per
117
+ # fiber per target, not once per call -- to append to the
118
+ # shared registry that #merge! later reads.
119
+ def thread_local
120
+ Thread.current[@thread_key] ||= begin
121
+ local = ThreadLocal.new(0, 0, build_param_buckets, new_returns_bucket, Hash.new(0), {})
122
+ @locals_mutex.synchronize { @locals << local }
123
+ local
124
+ end
125
+ end
126
+
127
+ def record_param_samples(local, binding)
128
+ local.param_buckets.each do |name, bucket|
129
+ bucket.record(binding.local_variable_get(name.to_sym))
130
+ end
131
+ end
132
+
133
+ # Tallies by a "path:lineno" String built from two cheap
134
+ # attribute reads, not by Location#to_s (which additionally
135
+ # resolves and formats a method label on every single call).
136
+ # Measured directly: this key plus one Hash lookup costs about
137
+ # half of the old to_s-keyed lookup. An Array key ([path,
138
+ # lineno]) was tried first and measured *slower* than the
139
+ # to_s baseline it was meant to beat -- Array#hash and #eql?
140
+ # walk and hash every element on every lookup, so a compound
141
+ # object is not automatically a cheap Hash key. A String is.
142
+ #
143
+ # One Hash lookup, not two: the value is a mutable [count,
144
+ # location] pair, so an existing entry is updated in place
145
+ # (cheap, no second Hash op) and only a first-time key pays for
146
+ # a Hash write. The full "path:line:in 'label'" String -- what
147
+ # the evidence actually publishes -- is built from the stored
148
+ # Location at most once per distinct call site, in
149
+ # #callers_to_h, not once per call.
150
+ def record_caller(local, caller_location)
151
+ return unless caller_location
152
+
153
+ key = "#{caller_location.path}:#{caller_location.lineno}"
154
+ entry = local.callers[key]
155
+ if entry
156
+ entry[0] += 1
157
+ else
158
+ local.callers[key] = [1, caller_location]
159
+ end
160
+ end
161
+
162
+ # Runs once, the first time #to_h is called: folds every
163
+ # fiber's ThreadLocal into the published totals under
164
+ # @merge_mutex, so #to_h itself can be called more than once
165
+ # (Writer calls it exactly once per target today) without
166
+ # double-counting.
167
+ def merge!
168
+ return if @merged
169
+
170
+ @merge_mutex.synchronize do
171
+ return if @merged
172
+
173
+ # Snapshot under @locals_mutex, then merge outside it: a
174
+ # fiber could still be registering its own ThreadLocal
175
+ # (the append in #thread_local) concurrently with finish, so
176
+ # the read of @locals itself needs the same lock the writer
177
+ # uses, even though the per-call recording above never does.
178
+ locals = @locals_mutex.synchronize { @locals.dup }
179
+ locals.each { |local| merge_local(local) }
180
+ @merged = true
181
+ end
182
+ end
183
+
184
+ def merge_local(local)
185
+ @calls += local.calls
186
+ @raised_exits += local.raised_exits
187
+ local.param_buckets.each { |name, bucket| @param_buckets[name].merge!(bucket) }
188
+ @returns.merge!(local.returns)
189
+ local.raised.each { |klass, count| @raised[klass] += count }
190
+ local.callers.each do |key, (count, location)|
191
+ entry = @callers[key]
192
+ if entry
193
+ entry[0] += count
194
+ else
195
+ @callers[key] = [count, location]
196
+ end
197
+ end
198
+ end
199
+
200
+ def params_to_h
201
+ @param_buckets.transform_values(&:to_h)
202
+ end
203
+
204
+ # Location#to_s (the "path:line:in 'label'" format the evidence
205
+ # publishes) runs here, once per distinct call site total across
206
+ # every fiber -- not once per call, which is what made this the
207
+ # single most expensive part of the hook before this task.
208
+ def callers_to_h
209
+ @callers.each_with_object({}) do |(_key, (count, location)), result|
210
+ result[location.to_s] = count
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ module Probe
5
+ # Tells a probed method's normal return apart from a raise-exit.
6
+ # `:call`/`:return` can be targeted to one method, but `:raise`
7
+ # cannot (measured in contract-verbs.md): it fires globally, for
8
+ # every exception in the process. Distinguishing "returned nil"
9
+ # from "exited by raising" needs this second, global signal,
10
+ # correlated with each targeted call through the checkpoint below.
11
+ #
12
+ # Mechanism (contract-verbs.md, measured on ruby 4.0.6): a global
13
+ # counter that a `:raise` TracePoint increments and a `:rescue`
14
+ # TracePoint decrements. `:call` records the counter's value; at
15
+ # `:return`, a *positive delta* from that recorded value means an
16
+ # exception passed through this frame's unwind between the two
17
+ # events, because the caller's own `:rescue` fires only after
18
+ # `:return`, so the counter is still unbalanced at `:return` time.
19
+ #
20
+ # Delta, not the absolute counter: an absolute counter left
21
+ # unbalanced by a raise from *before* this call started (e.g. one
22
+ # whose `:rescue` fires after this session already released the
23
+ # tracker) would poison every later call on the same fiber. A
24
+ # delta only asks "did the counter move during *this* call", so a
25
+ # pre-existing imbalance cannot produce a false positive.
26
+ #
27
+ # Counter and checkpoint stack are fiber-local (Thread.current[]),
28
+ # not a single shared integer: a fiber's call stack is private to
29
+ # that fiber, and only one exception can be unwinding through a
30
+ # given fiber's stack at a time, so a fiber-local counter can
31
+ # never be perturbed by an unrelated raise on another fiber or
32
+ # thread. A single shared counter would not have that guarantee.
33
+ #
34
+ # `:rescue` requires ruby >= 3.3.0 (Feature #19572); this gem's
35
+ # required_ruby_version is >= 4.0, so no degrade path is needed
36
+ # here. `:rescue` only fires for Ruby-level `rescue` (ruby's own
37
+ # NEWS-3.3.0.md); a raise fully handled inside C code (measured:
38
+ # `Integer(s, exception: false)`) never fires `:raise` either, so
39
+ # it cannot unbalance the counter in the first place.
40
+ class RaiseTracker
41
+ COUNTER_KEY = :bulldogger_probe_raise_counter
42
+ STACK_KEY = :bulldogger_probe_raise_checkpoints
43
+ CLASS_KEY = :bulldogger_probe_raise_class
44
+
45
+ def self.instance
46
+ @instance ||= new
47
+ end
48
+
49
+ def initialize
50
+ @mutex = Mutex.new
51
+ @refcount = 0
52
+ @raise_tp = nil
53
+ @rescue_tp = nil
54
+ end
55
+
56
+ # Ref-counted: multiple probe sessions can be active at once
57
+ # (different targets, or nested probe calls) and all of them
58
+ # need this same pair of TracePoints running, but each pays only
59
+ # its own targeted :call/:return cost -- the untargeted :raise/
60
+ # :rescue subscription is shared, not duplicated per session.
61
+ def acquire
62
+ @mutex.synchronize do
63
+ @refcount += 1
64
+ next unless @refcount == 1
65
+
66
+ @raise_tp = TracePoint.new(:raise) { |tp| on_raise(tp) }
67
+ @rescue_tp = TracePoint.new(:rescue) { on_rescue }
68
+ @raise_tp.enable
69
+ @rescue_tp.enable
70
+ end
71
+ end
72
+
73
+ def release
74
+ @mutex.synchronize do
75
+ @refcount -= 1
76
+ next if @refcount.positive?
77
+
78
+ @raise_tp&.disable
79
+ @rescue_tp&.disable
80
+ @raise_tp = nil
81
+ @rescue_tp = nil
82
+ end
83
+ end
84
+
85
+ # Call at :call time, before any work that could raise inside
86
+ # the hook itself -- a cheap Array#push that must not be skipped,
87
+ # or the matching pop_and_raised_exit? at :return would read the
88
+ # wrong checkpoint and desynchronize every later call on this
89
+ # fiber.
90
+ def push_checkpoint
91
+ (Thread.current[STACK_KEY] ||= []) << counter
92
+ end
93
+
94
+ # Call at :return time. Pops the checkpoint pushed by the
95
+ # matching push_checkpoint (calls on one fiber nest properly, so
96
+ # a plain stack -- not a per-target slot -- keeps recursive and
97
+ # interleaved targets correctly paired) and reports whether the
98
+ # counter moved since that call started.
99
+ def pop_and_raised_exit?
100
+ stack = Thread.current[STACK_KEY]
101
+ checkpoint = stack && !stack.empty? ? stack.pop : 0
102
+ counter > checkpoint
103
+ end
104
+
105
+ # Only meaningful right after pop_and_raised_exit? returned
106
+ # true: the class of the most recent :raise seen on this fiber,
107
+ # which is the exception currently unwinding through the
108
+ # caller's frame. Stale otherwise; callers must gate on
109
+ # pop_and_raised_exit?, not read this unconditionally.
110
+ def current_exception_class_name
111
+ Thread.current[CLASS_KEY]
112
+ end
113
+
114
+ private
115
+
116
+ def counter
117
+ Thread.current[COUNTER_KEY] || 0
118
+ end
119
+
120
+ def on_raise(tp)
121
+ Thread.current[COUNTER_KEY] = counter + 1
122
+ Thread.current[CLASS_KEY] = exception_class_name(tp.raised_exception)
123
+ rescue Exception # rubocop:disable Lint/RescueException
124
+ nil
125
+ end
126
+
127
+ def on_rescue
128
+ Thread.current[COUNTER_KEY] = counter - 1
129
+ rescue Exception # rubocop:disable Lint/RescueException
130
+ nil
131
+ end
132
+
133
+ def exception_class_name(exception)
134
+ klass = exception.class
135
+ klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
136
+ rescue Exception # rubocop:disable Lint/RescueException
137
+ "Object"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ module Probe
5
+ # Tracks which target labels currently have a live probe session,
6
+ # process-wide. Two concurrent sessions on the same method would
7
+ # each build their own TracePoint and double-count every call;
8
+ # the contract requires that be caught at start, by name, not
9
+ # discovered later as doubled statistics.
10
+ module Registry
11
+ @mutex = Mutex.new
12
+ @active = {}
13
+
14
+ class << self
15
+ def reserve!(labels)
16
+ @mutex.synchronize do
17
+ conflict = labels.find { |label| @active[label] }
18
+ if conflict
19
+ raise ArgumentError, "bulldogger: probe target #{conflict.inspect} is already being probed"
20
+ end
21
+
22
+ labels.each { |label| @active[label] = true }
23
+ end
24
+ end
25
+
26
+ def release(labels)
27
+ @mutex.synchronize { labels.each { |label| @active.delete(label) } }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end