logbrew-sdk 0.1.4 → 0.1.6
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 +4 -4
- data/README.md +8 -3
- data/lib/logbrew/issue_diagnostics.rb +214 -3
- data/lib/logbrew/rails_integration.rb +11 -6
- data/lib/logbrew/version.rb +1 -1
- data/lib/logbrew.rb +7 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e89660190354962ae56323ce67dd3192d2b6ac7fc52f73fefe8cca3e4cffe31
|
|
4
|
+
data.tar.gz: d92f16257ab1911e72f025f0b1fa29a85138e522653a68fdd4466e8f5c0311c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83d428f770034749b2c6717eb7fae953a21671bd050406822f341e7f06494af15849d1ac58b259ab08c2910f161e7bf8321fa3b3b6867b8bb14b048883b0a684
|
|
7
|
+
data.tar.gz: b60cd4f0f853c45fd656d16605b23bafa4fd2396f56be9c2be7ba8da3aa358a33510082f0ca49a45222690caea01bc1b5c3997ab30819ada8a8bde311963a617
|
data/README.md
CHANGED
|
@@ -23,7 +23,7 @@ needed:
|
|
|
23
23
|
|
|
24
24
|
```ruby
|
|
25
25
|
# Gemfile
|
|
26
|
-
gem "logbrew-sdk", "~> 0.1.
|
|
26
|
+
gem "logbrew-sdk", "~> 0.1.6"
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
```bash
|
|
@@ -479,8 +479,11 @@ rescue RuntimeError => error
|
|
|
479
479
|
end
|
|
480
480
|
```
|
|
481
481
|
|
|
482
|
-
The typed payload exposes exception type, mechanism and handled state,
|
|
483
|
-
|
|
482
|
+
The typed payload exposes exception type, mechanism and handled state, a
|
|
483
|
+
bounded parent-first Ruby `cause` chain, up to 32 newest-first stack frames,
|
|
484
|
+
and up to 64 oldest-to-newest breadcrumbs. Automatic node messages are marked
|
|
485
|
+
redacted, per-node stack availability is explicit, and cycles, unsafe cause
|
|
486
|
+
access, or the eight-node cap mark truncation. Generated
|
|
484
487
|
frames contain only basename, positive coordinates, and bounded function
|
|
485
488
|
identity. Explicit frames can also carry module, `inApp`, and debug ID. A
|
|
486
489
|
breadcrumb accepts a stable category/type, normalized level, bounded message,
|
|
@@ -494,6 +497,8 @@ and manual structured frame projection never captures raw backtrace strings,
|
|
|
494
497
|
source code, locals, arguments, or absolute paths. The raw Rails/Rack backtrace
|
|
495
498
|
option is separate and remains off by default. Run
|
|
496
499
|
`make -C examples run-issue-diagnostics` for a complete inspectable payload.
|
|
500
|
+
Rails, Rack, and Sidekiq issue capture reuse this same graph. See the shared
|
|
501
|
+
[exception-chain contract](../../docs/exception-chain-evidence.md).
|
|
497
502
|
|
|
498
503
|
## Metrics
|
|
499
504
|
|
|
@@ -11,7 +11,10 @@ module LogBrew
|
|
|
11
11
|
module IssueDiagnostics
|
|
12
12
|
MAX_STACK_FRAMES = 32
|
|
13
13
|
MAX_BREADCRUMBS = 64
|
|
14
|
+
MAX_EXCEPTIONS = 8
|
|
14
15
|
MAX_EXCEPTION_TYPE = 256
|
|
16
|
+
MAX_EXCEPTION_MESSAGE = 1_024
|
|
17
|
+
MAX_EXCEPTION_MODULE = 512
|
|
15
18
|
MAX_MECHANISM_TYPE = 64
|
|
16
19
|
MAX_FRAME_FILENAME = 2_048
|
|
17
20
|
MAX_FRAME_FUNCTION = 256
|
|
@@ -71,10 +74,17 @@ module LogBrew
|
|
|
71
74
|
)
|
|
72
75
|
}
|
|
73
76
|
attributes["message"] = message unless message.nil?
|
|
77
|
+
root_stack = include_stack_frames ? stack_evidence_from_exception(error) : nil
|
|
74
78
|
if include_stack_frames
|
|
75
|
-
frames =
|
|
79
|
+
frames = root_stack.fetch(:frames)
|
|
76
80
|
attributes["stackFrames"] = frames unless frames.empty?
|
|
77
81
|
end
|
|
82
|
+
attributes["exceptionChain"] = exception_chain_from_exception(
|
|
83
|
+
error,
|
|
84
|
+
root_exception: attributes.fetch("exception"),
|
|
85
|
+
root_stack: root_stack,
|
|
86
|
+
include_stack_frames: include_stack_frames
|
|
87
|
+
)
|
|
78
88
|
attributes["breadcrumbs"] = breadcrumbs unless breadcrumbs.nil?
|
|
79
89
|
attributes["breadcrumbsTruncated"] = true if breadcrumbs_truncated
|
|
80
90
|
attributes["metadata"] = metadata unless metadata.nil?
|
|
@@ -180,6 +190,13 @@ module LogBrew
|
|
|
180
190
|
payload["message"] = message.dup
|
|
181
191
|
end
|
|
182
192
|
payload["exception"] = validate_exception(read_value(attributes, "exception")) if has_key?(attributes, "exception")
|
|
193
|
+
if has_key?(attributes, "exceptionChain")
|
|
194
|
+
payload["exceptionChain"] = validate_exception_chain(
|
|
195
|
+
read_value(attributes, "exceptionChain"),
|
|
196
|
+
payload["exception"],
|
|
197
|
+
has_key?(attributes, "stackFrames") ? validate_stack_frames(read_value(attributes, "stackFrames")) : nil
|
|
198
|
+
)
|
|
199
|
+
end
|
|
183
200
|
payload["stackFrames"] = validate_stack_frames(read_value(attributes, "stackFrames")) if has_key?(attributes, "stackFrames")
|
|
184
201
|
payload["breadcrumbs"] = validate_breadcrumbs(read_value(attributes, "breadcrumbs")) if has_key?(attributes, "breadcrumbs")
|
|
185
202
|
if has_key?(attributes, "breadcrumbsTruncated")
|
|
@@ -230,6 +247,110 @@ module LogBrew
|
|
|
230
247
|
value
|
|
231
248
|
end
|
|
232
249
|
|
|
250
|
+
def validate_exception_chain(input, legacy_exception, legacy_frames)
|
|
251
|
+
unless input.is_a?(Hash)
|
|
252
|
+
raise validation("issue exceptionChain must be an object")
|
|
253
|
+
end
|
|
254
|
+
reject_unknown_keys(input, %w[entries truncated], "issue exceptionChain")
|
|
255
|
+
entries_input = read_required(input, "entries", "issue exceptionChain entries")
|
|
256
|
+
unless entries_input.is_a?(Array) && entries_input.length.between?(1, MAX_EXCEPTIONS)
|
|
257
|
+
raise validation("issue exceptionChain entries must contain 1-8 exceptions")
|
|
258
|
+
end
|
|
259
|
+
truncated = read_required(input, "truncated", "issue exceptionChain truncated")
|
|
260
|
+
unless truncated == true || truncated == false
|
|
261
|
+
raise validation("issue exceptionChain truncated must be a boolean")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
entries = entries_input.each_with_index.map do |entry_input, index|
|
|
265
|
+
validate_exception_chain_entry(entry_input, index)
|
|
266
|
+
end
|
|
267
|
+
root = entries.fetch(0)
|
|
268
|
+
root_exception = { "type" => root.fetch("type") }
|
|
269
|
+
root_exception["mechanism"] = root.fetch("mechanism") if root.key?("mechanism")
|
|
270
|
+
unless legacy_exception.is_a?(Hash) && root_exception == legacy_exception
|
|
271
|
+
raise validation("issue exceptionChain reported exception must match exception")
|
|
272
|
+
end
|
|
273
|
+
if root.fetch("stackFramesState") == "not_captured"
|
|
274
|
+
unless legacy_frames.nil?
|
|
275
|
+
raise validation("issue exceptionChain reported stack must match stackFrames")
|
|
276
|
+
end
|
|
277
|
+
elsif root["stackFrames"] != legacy_frames
|
|
278
|
+
raise validation("issue exceptionChain reported stack must match stackFrames")
|
|
279
|
+
end
|
|
280
|
+
{ "entries" => entries, "truncated" => truncated }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def validate_exception_chain_entry(input, index)
|
|
284
|
+
unless input.is_a?(Hash)
|
|
285
|
+
raise validation("issue exceptionChain entry #{index} must be an object")
|
|
286
|
+
end
|
|
287
|
+
reject_unknown_keys(
|
|
288
|
+
input,
|
|
289
|
+
%w[id parentId relationship type message messageState module mechanism stackFrames stackFramesState],
|
|
290
|
+
"issue exceptionChain entry #{index}"
|
|
291
|
+
)
|
|
292
|
+
unless read_required(input, "id", "issue exceptionChain entry id") == index
|
|
293
|
+
raise validation("issue exceptionChain ids must be contiguous and match array order")
|
|
294
|
+
end
|
|
295
|
+
relationship = read_required(input, "relationship", "issue exceptionChain relationship")
|
|
296
|
+
if index.zero?
|
|
297
|
+
unless relationship == "reported" && !has_key?(input, "parentId")
|
|
298
|
+
raise validation("issue exceptionChain entry 0 must be the parentless reported exception")
|
|
299
|
+
end
|
|
300
|
+
else
|
|
301
|
+
parent_id = read_value(input, "parentId")
|
|
302
|
+
unless %w[cause context aggregate_member suppressed].include?(relationship) &&
|
|
303
|
+
parent_id.is_a?(Integer) && parent_id.between?(0, index - 1)
|
|
304
|
+
raise validation("issue exceptionChain parent relationship is invalid")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
value = {
|
|
309
|
+
"id" => index,
|
|
310
|
+
"relationship" => relationship,
|
|
311
|
+
"type" => require_text(
|
|
312
|
+
"issue exceptionChain type",
|
|
313
|
+
read_required(input, "type", "issue exceptionChain type"),
|
|
314
|
+
MAX_EXCEPTION_TYPE,
|
|
315
|
+
true
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
value["parentId"] = read_value(input, "parentId") unless index.zero?
|
|
319
|
+
message_state = read_required(input, "messageState", "issue exceptionChain messageState")
|
|
320
|
+
if %w[captured truncated].include?(message_state)
|
|
321
|
+
value["message"] = require_text(
|
|
322
|
+
"issue exceptionChain message",
|
|
323
|
+
read_required(input, "message", "issue exceptionChain message"),
|
|
324
|
+
MAX_EXCEPTION_MESSAGE,
|
|
325
|
+
false
|
|
326
|
+
)
|
|
327
|
+
elsif !%w[redacted not_captured].include?(message_state) || has_key?(input, "message")
|
|
328
|
+
raise validation("issue exceptionChain message must match messageState")
|
|
329
|
+
end
|
|
330
|
+
value["messageState"] = message_state
|
|
331
|
+
if has_key?(input, "module")
|
|
332
|
+
value["module"] = require_text(
|
|
333
|
+
"issue exceptionChain module",
|
|
334
|
+
read_value(input, "module"),
|
|
335
|
+
MAX_EXCEPTION_MODULE,
|
|
336
|
+
true
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
value["mechanism"] = validate_exception_mechanism(read_value(input, "mechanism")) if has_key?(input, "mechanism")
|
|
340
|
+
stack_state = read_required(input, "stackFramesState", "issue exceptionChain stackFramesState")
|
|
341
|
+
if %w[captured truncated].include?(stack_state)
|
|
342
|
+
value["stackFrames"] = validate_stack_frames(read_required(input, "stackFrames", "issue exceptionChain stackFrames"))
|
|
343
|
+
elsif stack_state != "not_captured" || has_key?(input, "stackFrames")
|
|
344
|
+
raise validation("issue exceptionChain stackFrames must match stackFramesState")
|
|
345
|
+
end
|
|
346
|
+
value["stackFramesState"] = stack_state
|
|
347
|
+
value
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def validate_exception_mechanism(mechanism)
|
|
351
|
+
validate_exception({ "type" => "Exception", "mechanism" => mechanism }).fetch("mechanism")
|
|
352
|
+
end
|
|
353
|
+
|
|
233
354
|
def validate_stack_frames(input)
|
|
234
355
|
unless input.is_a?(Array) && input.length.between?(1, MAX_STACK_FRAMES)
|
|
235
356
|
raise validation("issue stackFrames must contain 1-32 frames")
|
|
@@ -380,16 +501,98 @@ module LogBrew
|
|
|
380
501
|
"Exception"
|
|
381
502
|
end
|
|
382
503
|
|
|
504
|
+
def exception_chain_from_exception(error, root_exception:, root_stack:, include_stack_frames:)
|
|
505
|
+
entries = []
|
|
506
|
+
seen = {}
|
|
507
|
+
current = error
|
|
508
|
+
parent_id = nil
|
|
509
|
+
truncated = false
|
|
510
|
+
|
|
511
|
+
until current.nil?
|
|
512
|
+
if seen.key?(current.object_id) || entries.length >= MAX_EXCEPTIONS
|
|
513
|
+
truncated = true
|
|
514
|
+
break
|
|
515
|
+
end
|
|
516
|
+
seen[current.object_id] = true
|
|
517
|
+
id = entries.length
|
|
518
|
+
stack = id.zero? ? root_stack : (include_stack_frames ? stack_evidence_from_exception(current) : nil)
|
|
519
|
+
entry = {
|
|
520
|
+
"id" => id,
|
|
521
|
+
"relationship" => id.zero? ? "reported" : "cause",
|
|
522
|
+
"type" => safe_exception_type(current),
|
|
523
|
+
"messageState" => safe_exception_has_message?(current) ? "redacted" : "not_captured",
|
|
524
|
+
"mechanism" => id.zero? ? root_exception["mechanism"] : { "type" => "ruby.cause", "handled" => true },
|
|
525
|
+
"stackFramesState" => if stack.nil? || stack.fetch(:frames).empty?
|
|
526
|
+
"not_captured"
|
|
527
|
+
elsif stack.fetch(:truncated)
|
|
528
|
+
"truncated"
|
|
529
|
+
else
|
|
530
|
+
"captured"
|
|
531
|
+
end
|
|
532
|
+
}
|
|
533
|
+
entry["parentId"] = parent_id unless parent_id.nil?
|
|
534
|
+
exception_module = safe_exception_module(current)
|
|
535
|
+
entry["module"] = exception_module unless exception_module.nil?
|
|
536
|
+
entry["stackFrames"] = stack.fetch(:frames) unless stack.nil? || stack.fetch(:frames).empty?
|
|
537
|
+
entries << entry
|
|
538
|
+
parent_id = id
|
|
539
|
+
current = safe_exception_cause(current)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
root_frames = root_stack&.fetch(:frames)
|
|
543
|
+
root_frames = nil if root_frames&.empty?
|
|
544
|
+
validate_exception_chain(
|
|
545
|
+
{ "entries" => entries, "truncated" => truncated },
|
|
546
|
+
root_exception,
|
|
547
|
+
root_frames
|
|
548
|
+
)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def stack_evidence_from_exception(error)
|
|
552
|
+
locations = safe_backtrace_locations(error)
|
|
553
|
+
frames = locations.first(MAX_STACK_FRAMES).map { |location| frame_from_location(location) }.compact
|
|
554
|
+
return { frames: frames, truncated: locations.length > MAX_STACK_FRAMES } unless frames.empty?
|
|
555
|
+
|
|
556
|
+
lines = safe_backtrace(error)
|
|
557
|
+
{
|
|
558
|
+
frames: lines.first(MAX_STACK_FRAMES).map { |line| frame_from_backtrace_line(line) }.compact,
|
|
559
|
+
truncated: lines.length > MAX_STACK_FRAMES
|
|
560
|
+
}
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def safe_exception_has_message?(error)
|
|
564
|
+
message = error.message
|
|
565
|
+
message.is_a?(String) && !message.strip.empty?
|
|
566
|
+
rescue StandardError
|
|
567
|
+
false
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def safe_exception_module(error)
|
|
571
|
+
name = error.class.name
|
|
572
|
+
return nil unless name.is_a?(String) && name.include?("::")
|
|
573
|
+
|
|
574
|
+
safe_generated_text(name.split("::")[0...-1].join("::"), MAX_EXCEPTION_MODULE, true, nil)
|
|
575
|
+
rescue StandardError
|
|
576
|
+
nil
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def safe_exception_cause(error)
|
|
580
|
+
cause = error.cause
|
|
581
|
+
cause.is_a?(Exception) ? cause : nil
|
|
582
|
+
rescue StandardError
|
|
583
|
+
nil
|
|
584
|
+
end
|
|
585
|
+
|
|
383
586
|
def safe_backtrace_locations(error)
|
|
384
587
|
locations = error.backtrace_locations
|
|
385
|
-
locations.respond_to?(:first) ? locations.first(MAX_STACK_FRAMES) : []
|
|
588
|
+
locations.respond_to?(:first) ? locations.first(MAX_STACK_FRAMES + 1) : []
|
|
386
589
|
rescue StandardError
|
|
387
590
|
[]
|
|
388
591
|
end
|
|
389
592
|
|
|
390
593
|
def safe_backtrace(error)
|
|
391
594
|
backtrace = error.backtrace
|
|
392
|
-
backtrace.is_a?(Array) ? backtrace.first(MAX_STACK_FRAMES) : []
|
|
595
|
+
backtrace.is_a?(Array) ? backtrace.first(MAX_STACK_FRAMES + 1) : []
|
|
393
596
|
rescue StandardError
|
|
394
597
|
[]
|
|
395
598
|
end
|
|
@@ -545,6 +748,9 @@ module LogBrew
|
|
|
545
748
|
|
|
546
749
|
private_class_method(
|
|
547
750
|
:validate_exception,
|
|
751
|
+
:validate_exception_chain,
|
|
752
|
+
:validate_exception_chain_entry,
|
|
753
|
+
:validate_exception_mechanism,
|
|
548
754
|
:validate_stack_frames,
|
|
549
755
|
:validate_stack_frame,
|
|
550
756
|
:validate_breadcrumbs,
|
|
@@ -553,6 +759,11 @@ module LogBrew
|
|
|
553
759
|
:validate_breadcrumb_data_value,
|
|
554
760
|
:safe_backtrace_locations,
|
|
555
761
|
:safe_backtrace,
|
|
762
|
+
:exception_chain_from_exception,
|
|
763
|
+
:stack_evidence_from_exception,
|
|
764
|
+
:safe_exception_has_message?,
|
|
765
|
+
:safe_exception_module,
|
|
766
|
+
:safe_exception_cause,
|
|
556
767
|
:frame_from_location,
|
|
557
768
|
:frame_from_backtrace_line,
|
|
558
769
|
:safe_location_value,
|
|
@@ -419,6 +419,15 @@ module LogBrew
|
|
|
419
419
|
"rails-exception"
|
|
420
420
|
end
|
|
421
421
|
|
|
422
|
+
def exception_status_code(error)
|
|
423
|
+
return super unless defined?(::ActionDispatch::ExceptionWrapper)
|
|
424
|
+
|
|
425
|
+
status = ::ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
|
|
426
|
+
status.is_a?(Integer) && status.between?(400, 599) ? status : super
|
|
427
|
+
rescue StandardError
|
|
428
|
+
super
|
|
429
|
+
end
|
|
430
|
+
|
|
422
431
|
def request_name(env)
|
|
423
432
|
"#{request_method(env)} #{route_template(env)}"
|
|
424
433
|
end
|
|
@@ -438,9 +447,7 @@ module LogBrew
|
|
|
438
447
|
metadata.delete("action_dispatch.request_id")
|
|
439
448
|
metadata.delete("HTTP_X_REQUEST_ID")
|
|
440
449
|
metadata["source"] = "rails"
|
|
441
|
-
metadata["http.method"] = request_method(env)
|
|
442
450
|
metadata["http.route"] = route_template(env)
|
|
443
|
-
metadata["http.status_code"] = status_code
|
|
444
451
|
metadata["http.status_class"] = "#{status_code.to_i / 100}xx"
|
|
445
452
|
controller, action = controller_and_action(env)
|
|
446
453
|
metadata["rails.controller"] = controller unless controller.nil?
|
|
@@ -449,10 +456,8 @@ module LogBrew
|
|
|
449
456
|
end
|
|
450
457
|
|
|
451
458
|
def route_template(env)
|
|
452
|
-
route = bounded_route(env_value(env, "action_dispatch.route_uri_pattern"))
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
route = bounded_route(matched_route_pattern(env))
|
|
459
|
+
route = bounded_route(env_value(env, "action_dispatch.route_uri_pattern")) ||
|
|
460
|
+
bounded_route(matched_route_pattern(env))
|
|
456
461
|
return route unless route.nil?
|
|
457
462
|
|
|
458
463
|
controller, action = controller_and_action(env)
|
data/lib/logbrew/version.rb
CHANGED
data/lib/logbrew.rb
CHANGED
|
@@ -406,10 +406,10 @@ module LogBrew
|
|
|
406
406
|
begin
|
|
407
407
|
response = @app.call(env)
|
|
408
408
|
rescue StandardError => error
|
|
409
|
+
status_code = exception_status_code(error)
|
|
409
410
|
safely_capture do
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
capture_request_span(env, 500, elapsed_ms, "error")
|
|
411
|
+
capture_exception_issue(env, error) if status_code >= 500
|
|
412
|
+
capture_request_span(env, status_code, duration_ms(started_at), status_code >= 500 ? "error" : "ok")
|
|
413
413
|
flush_if_configured
|
|
414
414
|
end
|
|
415
415
|
raise
|
|
@@ -425,6 +425,10 @@ module LogBrew
|
|
|
425
425
|
|
|
426
426
|
private
|
|
427
427
|
|
|
428
|
+
def exception_status_code(_error)
|
|
429
|
+
500
|
|
430
|
+
end
|
|
431
|
+
|
|
428
432
|
def capture_request_span(env, status_code, elapsed_ms, status)
|
|
429
433
|
@client.span(
|
|
430
434
|
next_event_id("span"),
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: logbrew-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LogBrew
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Public LogBrew Ruby SDK with typed issue diagnostics, automatic Rails
|
|
14
14
|
request/error capture, and standard-library delivery.
|