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,388 @@
1
+ # Evidence schema
2
+
3
+ bulldogger writes one JSON evidence file for each failed test.
4
+ The run directory also contains `index.json`, which lists the failure files.
5
+
6
+ ## Top-level fields
7
+
8
+ | Field | Type | Presence | Meaning |
9
+ |---|---|---|---|
10
+ | `schema_version` | Integer | always | Schema version. Version 0.1.0 writes `1`. |
11
+ | `tool` | Object | always | Writer name and version. |
12
+ | `captured_at` | String | always | UTC write time in `YYYY-MM-DDTHH:MM:SSZ` format. |
13
+ | `capture_mode` | String | always | `capture_frames`, `degraded`, or `missed`. |
14
+ | `test` | Object | always | Framework test data. |
15
+ | `exception` | Object | always | Exception data. |
16
+ | `frames` | Array | always | Frames from the raise site outward. |
17
+ | `frames_omitted` | Integer | when positive | Frames removed by `max_frames`. |
18
+ | `frames_unavailable_reason` | String | in missed mode | Reason for an empty frame list. |
19
+ | `limits` | Object | always | Limits used for this capture. |
20
+
21
+ ## Test fields
22
+
23
+ The framework integration supplies all four fields.
24
+ Each value can be `null` when the framework has no value.
25
+
26
+ | Field | Type |
27
+ |---|---|
28
+ | `framework` | String or null |
29
+ | `id` | String or null |
30
+ | `file` | String or null |
31
+ | `line` | Integer or null |
32
+
33
+ ## Exception fields
34
+
35
+ | Field | Type | Presence | Meaning |
36
+ |---|---|---|---|
37
+ | `class` | String | always | Exception class name. |
38
+ | `message` | String | always | Message with a limit of five times `max_value_length`. |
39
+ | `message_truncated` | Boolean | when true | The message exceeded its limit. |
40
+ | `message_original_length` | Integer | with `message_truncated` | Length before truncation. |
41
+ | `backtrace` | Array of String | always | First `max_frames` backtrace lines. |
42
+
43
+ ## Frame fields
44
+
45
+ | Field | Type | Presence | Meaning |
46
+ |---|---|---|---|
47
+ | `index` | Integer | always | Position from the raise site. |
48
+ | `path` | String or null | always | Ruby source path. |
49
+ | `line` | Integer or null | always | Ruby source line. |
50
+ | `label` | String or null | always | Method or block label. |
51
+ | `self` | String | when captured | Rendered frame receiver. |
52
+ | `locals` | Object | when captured | Map from local names to entries. |
53
+ | `locals_omitted` | Integer | when positive | Locals removed by `max_locals`. |
54
+ | `locals_unavailable` | Boolean | degraded frames after frame 0 | The capture source could not read these locals. |
55
+
56
+ Frame 0 is the innermost frame.
57
+ An assertion library can raise before control returns to the test method.
58
+ Search frame labels and paths to identify the frame that owns the needed locals.
59
+
60
+ ## Local entries
61
+
62
+ A captured local has a String value:
63
+
64
+ ```json
65
+ {"qty": {"value": "3"}}
66
+ ```
67
+
68
+ A value longer than `max_value_length` also has truncation fields:
69
+
70
+ ```json
71
+ {"value": "a long rendered value…", "truncated": true, "original_length": 814}
72
+ ```
73
+
74
+ A local name that matches a redaction pattern has this shape:
75
+
76
+ ```json
77
+ {"api_token": {"redacted": true, "reason": "name"}}
78
+ ```
79
+
80
+ The redacted entry has no `value` field.
81
+ Version 0.1.0 emits only `"name"` as the `reason` value.
82
+
83
+ Hash-key redaction appears inside the parent value String:
84
+
85
+ ```json
86
+ {"settings": {"value": "{\"token\" => \"[REDACTED]\", \"level\" => \"debug\"}"}}
87
+ ```
88
+
89
+ ## Value rendering
90
+
91
+ bulldogger stores each rendered value as a JSON String.
92
+ It uses Ruby `inspect` for scalar values and Strings.
93
+
94
+ Arrays and Hashes expand one level.
95
+ A nested Array becomes `[…]`, and a nested Hash becomes `{…}`.
96
+ The formatter keeps 10 elements and adds `…` when more elements exist.
97
+
98
+ For other objects, the formatter calls `inspect` and catches any exception.
99
+ An object with a failing `inspect` produces `#<ClassName (inspect raised ExceptionClass)>`.
100
+
101
+ ## Capture modes
102
+
103
+ ### `capture_frames`
104
+
105
+ The `debug` frame API supplies locals and `self` for each retained frame.
106
+ This projection came from a generated Minitest failure file:
107
+
108
+ ```json
109
+ {
110
+ "schema_version": 1,
111
+ "tool": {"name": "bulldogger", "version": "0.1.0"},
112
+ "capture_mode": "capture_frames",
113
+ "test": {
114
+ "framework": "minitest",
115
+ "id": "RedTest#test_deep_raise",
116
+ "file": "test/fixtures/minitest_red/red_test.rb",
117
+ "line": 19
118
+ },
119
+ "exception": {
120
+ "class": "ArgumentError",
121
+ "message": "expected 3 to equal the sum of [1, 2, 3]"
122
+ },
123
+ "frames": [
124
+ {
125
+ "index": 0,
126
+ "line": 9,
127
+ "label": "Order.total",
128
+ "self": "Order",
129
+ "locals": {
130
+ "qty": {"value": "3"},
131
+ "rows": {"value": "[1, 2, 3]"},
132
+ "api_token": {"redacted": true, "reason": "name"}
133
+ }
134
+ }
135
+ ],
136
+ "limits": {"max_frames": 20, "max_locals": 50, "max_value_length": 200}
137
+ }
138
+ ```
139
+
140
+ ### `degraded`
141
+
142
+ Frame 0 uses the raising `TracePoint` binding.
143
+ Later frames contain positions and `locals_unavailable: true`.
144
+ This projection came from the same fixture with `BULLDOGGER_FRAME_SOURCE=degraded`:
145
+
146
+ ```json
147
+ {
148
+ "schema_version": 1,
149
+ "capture_mode": "degraded",
150
+ "frames": [
151
+ {
152
+ "index": 0,
153
+ "line": 9,
154
+ "label": "Order.total",
155
+ "locals": {
156
+ "qty": {"value": "3"},
157
+ "rows": {"value": "[1, 2, 3]"},
158
+ "api_token": {"redacted": true, "reason": "name"}
159
+ },
160
+ "self": "Order"
161
+ },
162
+ {
163
+ "index": 1,
164
+ "path": "test/fixtures/minitest_red/red_test.rb",
165
+ "line": 20,
166
+ "label": "RedTest#test_deep_raise",
167
+ "locals_unavailable": true
168
+ }
169
+ ]
170
+ }
171
+ ```
172
+
173
+ A later frame can have Ruby locals even when the evidence has no `locals` field.
174
+ Add `gem "debug", group: :test` and repeat the failed test to capture those locals.
175
+
176
+ ### `missed`
177
+
178
+ Missed mode has an empty frame list.
179
+ The following complete file came from recording an exception before capture started:
180
+
181
+ ```json
182
+ {
183
+ "schema_version": 1,
184
+ "tool": {"name": "bulldogger", "version": "0.1.0"},
185
+ "captured_at": "2026-08-28T07:58:25Z",
186
+ "capture_mode": "missed",
187
+ "test": {
188
+ "framework": "minitest",
189
+ "id": "MissedTest#test_example",
190
+ "file": "test/missed_test.rb",
191
+ "line": 7
192
+ },
193
+ "exception": {
194
+ "class": "ArgumentError",
195
+ "message": "missed example",
196
+ "backtrace": ["-e:1:in '<main>'"]
197
+ },
198
+ "frames": [],
199
+ "frames_unavailable_reason": "capture_disabled",
200
+ "limits": {"max_frames": 20, "max_locals": 50, "max_value_length": 200}
201
+ }
202
+ ```
203
+
204
+ The reason has one of these values:
205
+
206
+ | Value | Meaning |
207
+ |---|---|
208
+ | `capture_disabled` | Capture was not running when Ruby raised the exception. |
209
+ | `not_captured` | Capture was running, but the pending ring had no matching exception. |
210
+ | `evicted` | Later exceptions removed the matching snapshot from the bounded ring. |
211
+
212
+ Missed evidence retains the test data, exception message, and exception backtrace.
213
+
214
+ ## Limits and omission markers
215
+
216
+ The default limits are 20 frames, 50 locals per frame, and 200 characters per value.
217
+ The pending ring keeps 32 exception snapshots.
218
+
219
+ The file records positive omission counts:
220
+
221
+ - `frames_omitted` counts frames after `max_frames`.
222
+ - `locals_omitted` counts locals after `max_locals`.
223
+ - `original_length` records the rendered length before value truncation.
224
+ - `message_original_length` records the message length before message truncation.
225
+
226
+ An Array or Hash uses a trailing `…` to mark elements after the first 10.
227
+
228
+ ## Run index
229
+
230
+ `index.json` has this shape:
231
+
232
+ ```json
233
+ {
234
+ "schema_version": 1,
235
+ "run_dir": "/absolute/path/tmp/bulldogger/run-20260828-165825-28806",
236
+ "failures": [
237
+ {
238
+ "path": "001-RedTest-test_assertion_failure.json",
239
+ "test": {
240
+ "framework": "minitest",
241
+ "id": "RedTest#test_assertion_failure",
242
+ "file": "test/fixtures/minitest_red/red_test.rb",
243
+ "line": 12
244
+ },
245
+ "exception": {
246
+ "class": "Minitest::Assertion",
247
+ "message": "seeded api_token: 9 chars.\nExpected: 4\n Actual: 9"
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ ```
253
+
254
+ Each failure path is relative to the run directory.
255
+ The `latest` symlink points to the last finished run when the filesystem supports symlinks.
256
+
257
+ ## Probe evidence
258
+
259
+ `Bulldogger.probe` writes one JSON file after the observed block finishes.
260
+ The file has `kind: "probe"` and a `methods` entry for each target.
261
+
262
+ This excerpt came from a generated probe of `ProseSample#amount`:
263
+
264
+ ```json
265
+ {
266
+ "schema_version": 1,
267
+ "kind": "probe",
268
+ "tool": {"name": "bulldogger", "version": "0.1.0"},
269
+ "targets": ["ProseSample#amount"],
270
+ "methods": {
271
+ "ProseSample#amount": {
272
+ "calls": 3,
273
+ "raised_exits": 1,
274
+ "parameters": [["req", "mult"], ["key", "discount"], ["key", "api_token"]],
275
+ "params": {
276
+ "discount": {
277
+ "classes": {"NilClass": 2, "TrueClass": 1},
278
+ "nil_count": 2,
279
+ "samples": [{"value": "nil"}, {"value": "true"}, {"value": "nil"}]
280
+ },
281
+ "api_token": {
282
+ "classes": {"NilClass": 2, "String": 1},
283
+ "nil_count": 2,
284
+ "samples": [
285
+ {"redacted": true, "reason": "name"},
286
+ {"redacted": true, "reason": "name"},
287
+ {"redacted": true, "reason": "name"}
288
+ ]
289
+ }
290
+ },
291
+ "returns": {
292
+ "classes": {"Integer": 1, "NilClass": 1},
293
+ "nil_count": 1,
294
+ "samples": [{"value": "21"}, {"value": "nil"}]
295
+ },
296
+ "raised": {"ArgumentError": 1},
297
+ "callers": {"-e:1:in 'block in <main>'": 3}
298
+ }
299
+ },
300
+ "limits": {"max_samples": 10, "max_value_length": 200}
301
+ }
302
+ ```
303
+
304
+ ### Probe method fields
305
+
306
+ | Field | Type | Meaning |
307
+ |---|---|---|
308
+ | `calls` | Integer | Calls observed for this target. |
309
+ | `raised_exits` | Integer | Calls that left the method through an exception. |
310
+ | `parameters` | Array | Declared parameter kinds and names. |
311
+ | `params` | Object | Class counts, `nil` counts, and samples for each named argument. |
312
+ | `returns` | Object | Class counts, `nil` count, and samples for normal returns. |
313
+ | `raised` | Object | Exception class counts for raised exits. |
314
+ | `callers` | Object | Call-site strings and their counts. |
315
+
316
+ A raised exit does not increase the return count.
317
+ This rule distinguishes an exception exit from a normal `nil` return.
318
+
319
+ Each parameter and return bucket counts every observed value.
320
+ The `samples` array contains the first `max_samples` rendered values.
321
+ When later values exist, `samples_omitted` gives their count.
322
+ Redaction and value limits use the failure evidence rules.
323
+
324
+ `Bulldogger.probe_compare` compares two probe files by their behavior shape.
325
+ Its result has `identical` and `differences` fields.
326
+
327
+ ## jq queries
328
+
329
+ These queries ran against generated evidence files.
330
+
331
+ List the capture mode and test identifier:
332
+
333
+ ```sh
334
+ jq '{capture_mode, test_id: .test.id}' evidence.json
335
+ ```
336
+
337
+ List frame positions:
338
+
339
+ ```sh
340
+ jq '[.frames[] | {index, label, path}]' evidence.json
341
+ ```
342
+
343
+ Find a local in any captured frame:
344
+
345
+ ```sh
346
+ jq '[.frames[] | select(.locals.qty) | {index, label, qty: .locals.qty}]' evidence.json
347
+ ```
348
+
349
+ List truncated locals:
350
+
351
+ ```sh
352
+ jq '[.frames[] | .index as $frame | (.locals // {}) | to_entries[]
353
+ | select(.value.truncated == true)
354
+ | {frame: $frame, name: .key, original_length: .value.original_length}]' evidence.json
355
+ ```
356
+
357
+ List redacted locals:
358
+
359
+ ```sh
360
+ jq '[.frames[] | .index as $frame | (.locals // {}) | to_entries[]
361
+ | select(.value.redacted == true)
362
+ | {frame: $frame, name: .key, reason: .value.reason}]' evidence.json
363
+ ```
364
+
365
+ List failed test identifiers from the run index:
366
+
367
+ ```sh
368
+ jq '[.failures[].test.id]' tmp/bulldogger/latest/index.json
369
+ ```
370
+
371
+ List the call count, raised exits, and callers from probe evidence:
372
+
373
+ ```sh
374
+ jq '.methods | to_entries[] | {
375
+ method: .key, calls: .value.calls,
376
+ raised_exits: .value.raised_exits, callers: .value.callers
377
+ }' probe.json
378
+ ```
379
+
380
+ List parameter and return `nil` counts:
381
+
382
+ ```sh
383
+ jq '.methods | to_entries[] | {
384
+ method: .key,
385
+ params: (.value.params | map_values(.nil_count)),
386
+ returns: .value.returns.nil_count
387
+ }' probe.json
388
+ ```
@@ -0,0 +1,92 @@
1
+ # Maintenance
2
+
3
+ ## How releases work
4
+
5
+ GitHub Actions publishes bulldogger through RubyGems.org Trusted
6
+ Publishing. The release workflow holds no RubyGems.org API key. It signs
7
+ in with a short-lived OIDC token that RubyGems.org accepts only from this
8
+ repository, this workflow file, and this environment.
9
+
10
+ `.github/workflows/release.yml` runs when a `v*` tag reaches GitHub. It
11
+ checks that the tag version matches `Bulldogger::VERSION`, runs the
12
+ default task and the coverage gate, builds the gem, pushes it, and
13
+ attaches the built gem to a GitHub release.
14
+
15
+ ## Before the first release
16
+
17
+ Two things live outside this repository, and a release fails without
18
+ either one.
19
+
20
+ ### Register the pending trusted publisher
21
+
22
+ RubyGems.org accepts a publisher for a gem name that does not exist yet.
23
+ Open the [pending trusted publishers
24
+ page](https://rubygems.org/profile/oidc/pending_trusted_publishers) and
25
+ create one with these values:
26
+
27
+ | Field | Value |
28
+ | --- | --- |
29
+ | Gem name | `bulldogger` |
30
+ | Repository owner | `meganemura` |
31
+ | Repository name | `bulldogger` |
32
+ | Workflow filename | `release.yml` |
33
+ | Environment | `release` |
34
+ | Workflow repository owner | Leave this field blank. |
35
+ | Workflow repository name | Leave this field blank. |
36
+
37
+ Leave both workflow repository fields blank. Those fields name a
38
+ reusable workflow in a different repository, and this workflow lives
39
+ beside the gem.
40
+
41
+ RubyGems.org converts the pending publisher after the first successful
42
+ push, and adds the profile account as an owner of the new gem.
43
+
44
+ For a gem that already exists, open the gem page, select **Trusted
45
+ publishers**, select **Create**, and enter the same values.
46
+
47
+ ### Create the GitHub environment
48
+
49
+ The workflow names `release` as its environment. Create that environment
50
+ in the repository settings and add a required reviewer.
51
+
52
+ RubyGems.org matches this name exactly. The workflow, the environment in
53
+ the repository settings, and the trusted publisher must all use the same
54
+ name, or the push step fails to authenticate.
55
+
56
+ Without a required reviewer the environment still satisfies
57
+ RubyGems.org, and the release proceeds with no human confirmation.
58
+ Publishing a version cannot be undone: yanking a version does not free
59
+ its number, and the number can never be reused. The reviewer is what
60
+ turns a pushed tag into a decision.
61
+
62
+ ## Make a release
63
+
64
+ 1. Update the version in `lib/bulldogger/version.rb`.
65
+ 2. Complete the normal review process for the version change.
66
+ 3. Create a `vVERSION` tag, using the exact version from
67
+ `version.rb`. Version `0.1.0` takes the tag `v0.1.0`.
68
+ 4. Push the tag.
69
+ 5. Approve the waiting release in the GitHub Actions run.
70
+ 6. Confirm that the workflow succeeds.
71
+ 7. Confirm that RubyGems.org shows the new version.
72
+
73
+ The workflow refuses a tag whose version disagrees with `version.rb`, so
74
+ a mistyped tag stops before anything is published.
75
+
76
+ Re-running a release that already pushed its gem succeeds. The push step
77
+ treats "Repushing of gem versions is not allowed" as success, so a
78
+ failure in a later step can be retried without the earlier step blocking
79
+ it.
80
+
81
+ ## Releasing a version whose tag already exists
82
+
83
+ A tag that is already on GitHub does not start a workflow by existing.
84
+ Deleting the tag and pushing it again does:
85
+
86
+ ```sh
87
+ git push origin :refs/tags/vVERSION
88
+ git push origin vVERSION
89
+ ```
90
+
91
+ Confirm the tag points at the commit intended for release before pushing
92
+ it back.
@@ -0,0 +1,118 @@
1
+ # Trace schema
2
+
3
+ `Bulldogger.record` writes a JSONL file named `trace-NNN.jsonl`.
4
+ The first line is a header, and each later line is one event.
5
+
6
+ ## Header
7
+
8
+ This header came from a generated trace:
9
+
10
+ ```json
11
+ {"schema_version":1,"kind":"record","started_at":"2026-08-28T12:15:18Z","events":["call","return","raise"],"limits":{"max_value_length":200}}
12
+ ```
13
+
14
+ | Field | Type | Meaning |
15
+ |---|---|---|
16
+ | `schema_version` | Integer | Schema version. Version 0.1.0 writes `1`. |
17
+ | `kind` | String | The value is `record`. |
18
+ | `started_at` | String | UTC start time. |
19
+ | `events` | Array | Event kinds written after the header. |
20
+ | `limits` | Object | Value limits used for this trace. |
21
+
22
+ ## Events
23
+
24
+ Each event has `event`, `seq`, `depth`, `path`, `line`, and `method`.
25
+ The sequence starts at 1 and follows write order.
26
+ The depth is the Ruby call depth observed by this recording session.
27
+
28
+ A call event has an `args` object with named arguments:
29
+
30
+ ```json
31
+ {"event":"call","seq":2,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","args":{"value":{"value":"3"}}}
32
+ ```
33
+
34
+ A normal return has a rendered `return` entry:
35
+
36
+ ```json
37
+ {"event":"return","seq":3,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","return":{"value":"6"}}
38
+ ```
39
+
40
+ A raise event has an exception class and message:
41
+
42
+ ```json
43
+ {"event":"raise","seq":7,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","exception":{"class":"ArgumentError","message":"negative"}}
44
+ ```
45
+
46
+ Ruby emits a `:return` event when an exception leaves a method.
47
+ That event has `raised: true` and has no `return` field:
48
+
49
+ ```json
50
+ {"event":"return","seq":8,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","raised":true}
51
+ ```
52
+
53
+ The discriminator uses Ruby-level `:rescue` events.
54
+ It distinguishes raised exits from normal `nil` returns.
55
+ The trace uses this event for internal state and does not write it.
56
+
57
+ ## Event selection
58
+
59
+ The default event set contains `:call`, `:return`, and `:raise`.
60
+ The header records this set for each file.
61
+
62
+ `:line` produces an event for each executed source line.
63
+ `:b_call` produces an event for each block invocation.
64
+ Their event counts follow executed lines and block invocations.
65
+ The record verb keeps method boundaries and raises within a usable explicit run.
66
+
67
+ ## Values and secrets
68
+
69
+ Argument and return entries use the bounded formatter from failure evidence.
70
+ Named arguments also use the configured redaction patterns.
71
+ A redacted argument has no `value` field.
72
+
73
+ Exception messages use five times `max_value_length`.
74
+ A long message has `message_truncated` and `message_original_length` fields.
75
+
76
+ ## jq queries
77
+
78
+ These queries ran against the generated trace used for the examples.
79
+
80
+ Read the header:
81
+
82
+ ```sh
83
+ head -n 1 trace.jsonl | jq '{schema_version, kind, events, limits}'
84
+ ```
85
+
86
+ List the event sequence:
87
+
88
+ ```sh
89
+ jq -c 'select(.event) | {seq, depth, event, method}' trace.jsonl
90
+ ```
91
+
92
+ List raised exits:
93
+
94
+ ```sh
95
+ jq -c 'select(.event == "return" and .raised == true)
96
+ | {seq, depth, method, raised}' trace.jsonl
97
+ ```
98
+
99
+ List raised exceptions:
100
+
101
+ ```sh
102
+ jq -c 'select(.event == "raise")
103
+ | {seq, method, class: .exception.class, message: .exception.message}' trace.jsonl
104
+ ```
105
+
106
+ Count calls by method:
107
+
108
+ ```sh
109
+ jq -s 'map(select(.event == "call"))
110
+ | group_by(.method)
111
+ | map({method: .[0].method, calls: length})' trace.jsonl
112
+ ```
113
+
114
+ ## SQLite adapter
115
+
116
+ `Bulldogger.trace_to_sqlite(jsonl_path, db_path)` converts a finished trace.
117
+ It returns `nil` with a warning when the `sqlite3` gem is unavailable.
118
+ The converter stores each event and its complete JSON payload.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redactor"
4
+ require_relative "formatter"
5
+ require_relative "frame_source"
6
+ require_relative "pending"
7
+
8
+ module Bulldogger
9
+ # Subscribes to :raise globally and turns every raised exception into
10
+ # a bounded, already-serialized snapshot before the hook returns.
11
+ #
12
+ # Serialization happens here, inside the hook, rather than later from
13
+ # whatever holds the frame data: a DEBUGGER__::FrameInfo keeps its
14
+ # frame's Binding alive, and a live Binding keeps every object
15
+ # reachable from that frame alive too (measured: the TracePoint
16
+ # block's own captured-array local was still reachable through a
17
+ # retained Binding). Rendering every value to a String and dropping
18
+ # the FrameInfo/Binding before this method returns is what makes the
19
+ # Pending ring's size an actual memory bound, not just a count of
20
+ # references to unbounded object graphs.
21
+ class Capture
22
+ def initialize(config:)
23
+ @config = config
24
+ @redactor = Redactor.new(config.redact_patterns)
25
+ @formatter = Formatter.new(config: config, redactor: @redactor)
26
+ @frame_source = FrameSource.new(config: config, formatter: @formatter, redactor: @redactor)
27
+ @pending = Pending.new(config.max_pending)
28
+ @trace_point = nil
29
+ @start_mutex = Mutex.new
30
+ end
31
+
32
+ def start
33
+ @start_mutex.synchronize do
34
+ return self if @trace_point
35
+
36
+ @frame_source.resolve!
37
+ trace_point = TracePoint.new(:raise) { |tp| handle_raise(tp) }
38
+ trace_point.enable
39
+ @trace_point = trace_point
40
+ end
41
+ self
42
+ end
43
+
44
+ def stop
45
+ @start_mutex.synchronize do
46
+ @trace_point&.disable
47
+ @trace_point = nil
48
+ end
49
+ self
50
+ end
51
+
52
+ def running?
53
+ !@trace_point.nil?
54
+ end
55
+
56
+ def snapshot_for(exception)
57
+ @pending.get(exception)
58
+ end
59
+
60
+ def reason_for_missing(exception)
61
+ return "capture_disabled" unless running?
62
+ return "evicted" if @pending.evicted?(exception)
63
+
64
+ "not_captured"
65
+ end
66
+
67
+ private
68
+
69
+ # :raise fires for every exception, including ones the app rescues
70
+ # and handles without incident. This hook must never let an
71
+ # exception escape: doing so would replace the app's real raise
72
+ # with one from inside our own hook, corrupting the very failure
73
+ # the app was raising. `rescue Exception`, not `StandardError`,
74
+ # because even a NoMemoryError or SystemStackError surfacing from
75
+ # our own code here must not propagate into the app's raise path.
76
+ def handle_raise(tp)
77
+ exception = tp.raised_exception
78
+ frames, frames_omitted = @frame_source.capture(tp)
79
+ snapshot = {
80
+ "capture_mode" => @frame_source.mode.to_s,
81
+ "frames" => frames
82
+ }
83
+ # frames_omitted only appears when something was actually cut, so
84
+ # its presence alone tells a reader "frames were dropped here" --
85
+ # the same rule build_frame already applies to locals_omitted.
86
+ snapshot["frames_omitted"] = frames_omitted if frames_omitted.positive?
87
+ @pending.put(exception, snapshot)
88
+ nil
89
+ rescue Exception => e # rubocop:disable Lint/RescueException
90
+ warn("bulldogger: capture failed: #{e.class}: #{e.message}") if debug?
91
+ nil
92
+ end
93
+
94
+ def debug?
95
+ ENV["BULLDOGGER_DEBUG"] == "1"
96
+ end
97
+ end
98
+ end