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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d461f01e0341b81cbdd17c4903f50726cd7966f26e50d7fd6fbef97a8fbf83dc
4
- data.tar.gz: 42efb0bf7c1312d5c0bb3337885b00de0921e043952e8c57d6571391110e2c7d
3
+ metadata.gz: 4e89660190354962ae56323ce67dd3192d2b6ac7fc52f73fefe8cca3e4cffe31
4
+ data.tar.gz: d92f16257ab1911e72f025f0b1fa29a85138e522653a68fdd4466e8f5c0311c7
5
5
  SHA512:
6
- metadata.gz: d7f55a9b0a8f8813762a2f497382a78c6678d22972ffe8bd0512359c53a8927491ea4fd7fc5bc92b40aaefab156cd8a57e27a090eedbd012c4b786360d1c6514
7
- data.tar.gz: bc21387db2691a355300645a333d4f57f878815415e6b52d1d56d7f38a5bca6be4eb27a4e205ba65ce777d9d05b04e018feeacb752a0ba5df7db5f1ecc83fab4
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.4"
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, up to 32
483
- newest-first stack frames, and up to 64 oldest-to-newest breadcrumbs. Generated
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 = stack_frames_from_exception(error)
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
- return route unless route.nil?
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBrew
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
5
5
  end
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
- elapsed_ms = duration_ms(started_at)
411
- capture_exception_issue(env, error)
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
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-10 00:00:00.000000000 Z
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.