logbrew-sdk 0.1.3 → 0.1.5

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.
@@ -0,0 +1,787 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module LogBrew
6
+ # Privacy-bounded builders and validators for first-class issue evidence.
7
+ #
8
+ # Generated exception frames contain code identity only. They are newest
9
+ # first, use basename-only filenames, and never include source text, local
10
+ # variables, arguments, raw stack strings, or exception messages.
11
+ module IssueDiagnostics
12
+ MAX_STACK_FRAMES = 32
13
+ MAX_BREADCRUMBS = 64
14
+ MAX_EXCEPTIONS = 8
15
+ MAX_EXCEPTION_TYPE = 256
16
+ MAX_EXCEPTION_MESSAGE = 1_024
17
+ MAX_EXCEPTION_MODULE = 512
18
+ MAX_MECHANISM_TYPE = 64
19
+ MAX_FRAME_FILENAME = 2_048
20
+ MAX_FRAME_FUNCTION = 256
21
+ MAX_FRAME_MODULE = 512
22
+ MAX_BREADCRUMB_NAME = 64
23
+ MAX_BREADCRUMB_MESSAGE = 512
24
+ MAX_BREADCRUMB_DATA_FIELDS = 8
25
+ MAX_BREADCRUMB_DATA_STRING = 256
26
+ MAX_COORDINATE = 2_147_483_647
27
+
28
+ MACHINE_NAME = /\A[A-Za-z][A-Za-z0-9_.:-]{0,63}\z/.freeze
29
+ DATA_KEY = /\A[A-Za-z][A-Za-z0-9_.-]{0,63}\z/.freeze
30
+ DEBUG_ID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
31
+ RFC3339 = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})\z/.freeze
32
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/.freeze
33
+ BREADCRUMB_LEVELS = {
34
+ "trace" => "debug",
35
+ "debug" => "debug",
36
+ "log" => "info",
37
+ "info" => "info",
38
+ "warn" => "warning",
39
+ "warning" => "warning",
40
+ "error" => "error",
41
+ "fatal" => "critical",
42
+ "critical" => "critical"
43
+ }.freeze
44
+
45
+ module_function
46
+
47
+ # Build a complete issue attribute payload from an exception. Exception
48
+ # text remains application-controlled through the explicit message option.
49
+ def from_exception(
50
+ error,
51
+ title: nil,
52
+ level: "error",
53
+ message: nil,
54
+ mechanism_type: "ruby.exception",
55
+ handled: true,
56
+ metadata: nil,
57
+ breadcrumbs: nil,
58
+ breadcrumbs_truncated: false,
59
+ include_stack_frames: true,
60
+ context: nil
61
+ )
62
+ unless error.is_a?(Exception)
63
+ raise validation("issue error must be an exception")
64
+ end
65
+
66
+ exception_type = safe_exception_type(error)
67
+ attributes = {
68
+ "title" => title.nil? ? exception_type : title,
69
+ "level" => level,
70
+ "exception" => exception(
71
+ type: exception_type,
72
+ mechanism_type: mechanism_type,
73
+ handled: handled
74
+ )
75
+ }
76
+ attributes["message"] = message unless message.nil?
77
+ root_stack = include_stack_frames ? stack_evidence_from_exception(error) : nil
78
+ if include_stack_frames
79
+ frames = root_stack.fetch(:frames)
80
+ attributes["stackFrames"] = frames unless frames.empty?
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
+ )
88
+ attributes["breadcrumbs"] = breadcrumbs unless breadcrumbs.nil?
89
+ attributes["breadcrumbsTruncated"] = true if breadcrumbs_truncated
90
+ attributes["metadata"] = metadata unless metadata.nil?
91
+ validated = validate_issue_attributes(attributes)
92
+ unless context.nil?
93
+ unless context.is_a?(TelemetryContext)
94
+ raise validation("issue context must be a LogBrew::TelemetryContext")
95
+ end
96
+ validated["context"] = context
97
+ end
98
+ validated
99
+ end
100
+
101
+ # Build a typed exception identity and optional observation mechanism.
102
+ def exception(type:, mechanism_type: nil, handled: nil)
103
+ value = { "type" => require_text("issue exception type", type, MAX_EXCEPTION_TYPE, true) }
104
+ unless mechanism_type.nil? && handled.nil?
105
+ if mechanism_type.nil? || (handled != true && handled != false)
106
+ raise validation("issue exception mechanism must include type and handled")
107
+ end
108
+ value["mechanism"] = {
109
+ "type" => require_machine_name(
110
+ "issue exception mechanism type",
111
+ mechanism_type,
112
+ MAX_MECHANISM_TYPE,
113
+ true
114
+ ),
115
+ "handled" => handled
116
+ }
117
+ end
118
+ value
119
+ end
120
+
121
+ # Build one explicit structured frame. Absolute filenames are reduced to
122
+ # their basename and URL query or fragment text is removed.
123
+ def stack_frame(
124
+ filename:,
125
+ line:,
126
+ column: 1,
127
+ function: nil,
128
+ module_name: nil,
129
+ in_app: nil,
130
+ debug_id: nil
131
+ )
132
+ frame = {
133
+ "filename" => filename,
134
+ "line" => line,
135
+ "column" => column
136
+ }
137
+ frame["function"] = function unless function.nil?
138
+ frame["module"] = module_name unless module_name.nil?
139
+ frame["inApp"] = in_app unless in_app.nil?
140
+ frame["debugId"] = debug_id unless debug_id.nil?
141
+ validate_stack_frame(frame)
142
+ end
143
+
144
+ # Project an exception backtrace into at most 32 newest-first code frames.
145
+ def stack_frames_from_exception(error)
146
+ unless error.is_a?(Exception)
147
+ raise validation("issue error must be an exception")
148
+ end
149
+
150
+ locations = safe_backtrace_locations(error)
151
+ frames = locations.first(MAX_STACK_FRAMES).map { |location| frame_from_location(location) }.compact
152
+ return frames unless frames.empty?
153
+
154
+ safe_backtrace(error).first(MAX_STACK_FRAMES).map { |line| frame_from_backtrace_line(line) }.compact
155
+ end
156
+
157
+ # Build one oldest-to-newest breadcrumb with bounded flat primitive data.
158
+ def breadcrumb(timestamp:, category:, type: nil, level: nil, message: nil, data: nil)
159
+ value = {
160
+ "timestamp" => timestamp,
161
+ "category" => category
162
+ }
163
+ value["type"] = type unless type.nil?
164
+ value["level"] = level unless level.nil?
165
+ value["message"] = message unless message.nil?
166
+ value["data"] = data unless data.nil?
167
+ validate_breadcrumb(value)
168
+ end
169
+
170
+ # Validate and detach a complete issue attribute payload.
171
+ def validate_issue_attributes(attributes)
172
+ unless attributes.is_a?(Hash)
173
+ raise validation("issue attributes must be an object")
174
+ end
175
+
176
+ title = read_required(attributes, "title", "issue title")
177
+ Validation.require_non_empty("issue title", title)
178
+ level = read_required(attributes, "level", "issue level")
179
+ Validation.require_allowed_value("issue level", level, SEVERITY_VALUES)
180
+ payload = {
181
+ "title" => copy_string(title),
182
+ "level" => SEVERITY_ALIASES.fetch(level)
183
+ }
184
+
185
+ if has_key?(attributes, "message")
186
+ message = read_value(attributes, "message")
187
+ unless message.is_a?(String) && message.valid_encoding?
188
+ raise validation("issue message must be a string")
189
+ end
190
+ payload["message"] = message.dup
191
+ end
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
200
+ payload["stackFrames"] = validate_stack_frames(read_value(attributes, "stackFrames")) if has_key?(attributes, "stackFrames")
201
+ payload["breadcrumbs"] = validate_breadcrumbs(read_value(attributes, "breadcrumbs")) if has_key?(attributes, "breadcrumbs")
202
+ if has_key?(attributes, "breadcrumbsTruncated")
203
+ truncated = read_value(attributes, "breadcrumbsTruncated")
204
+ unless truncated == true || truncated == false
205
+ raise validation("issue breadcrumbsTruncated must be a boolean")
206
+ end
207
+ payload["breadcrumbsTruncated"] = true if truncated
208
+ end
209
+
210
+ if has_key?(attributes, "metadata")
211
+ metadata = Validation.require_metadata(read_value(attributes, "metadata"))
212
+ payload["metadata"] = detach_metadata(metadata) unless metadata.nil?
213
+ end
214
+ payload
215
+ end
216
+
217
+ def validate_exception(input)
218
+ unless input.is_a?(Hash)
219
+ raise validation("issue exception must be an object")
220
+ end
221
+ reject_unknown_keys(input, %w[type mechanism], "issue exception")
222
+ type = read_required(input, "type", "issue exception type")
223
+ value = {
224
+ "type" => require_text("issue exception type", type, MAX_EXCEPTION_TYPE, true)
225
+ }
226
+ return value unless has_key?(input, "mechanism")
227
+
228
+ mechanism = read_value(input, "mechanism")
229
+ unless mechanism.is_a?(Hash)
230
+ raise validation("issue exception mechanism must be an object")
231
+ end
232
+ reject_unknown_keys(mechanism, %w[type handled], "issue exception mechanism")
233
+ mechanism_type = read_required(mechanism, "type", "issue exception mechanism type")
234
+ handled = read_required(mechanism, "handled", "issue exception mechanism handled")
235
+ unless handled == true || handled == false
236
+ raise validation("issue exception mechanism handled must be a boolean")
237
+ end
238
+ value["mechanism"] = {
239
+ "type" => require_machine_name(
240
+ "issue exception mechanism type",
241
+ mechanism_type,
242
+ MAX_MECHANISM_TYPE,
243
+ true
244
+ ),
245
+ "handled" => handled
246
+ }
247
+ value
248
+ end
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
+
354
+ def validate_stack_frames(input)
355
+ unless input.is_a?(Array) && input.length.between?(1, MAX_STACK_FRAMES)
356
+ raise validation("issue stackFrames must contain 1-32 frames")
357
+ end
358
+ input.map { |frame| validate_stack_frame(frame) }
359
+ end
360
+
361
+ def validate_stack_frame(input)
362
+ unless input.is_a?(Hash)
363
+ raise validation("issue stack frame must be an object")
364
+ end
365
+ reject_unknown_keys(input, %w[filename line column function module inApp debugId], "issue stack frame")
366
+ filename = read_required(input, "filename", "issue stack frame filename")
367
+ line = require_coordinate("issue stack frame line", read_required(input, "line", "issue stack frame line"))
368
+ column = require_coordinate(
369
+ "issue stack frame column",
370
+ read_required(input, "column", "issue stack frame column")
371
+ )
372
+ value = {
373
+ "filename" => sanitize_filename(filename),
374
+ "line" => line,
375
+ "column" => column
376
+ }
377
+ if has_key?(input, "function")
378
+ value["function"] = require_text(
379
+ "issue stack frame function",
380
+ read_value(input, "function"),
381
+ MAX_FRAME_FUNCTION,
382
+ false
383
+ )
384
+ end
385
+ if has_key?(input, "module")
386
+ value["module"] = require_text(
387
+ "issue stack frame module",
388
+ read_value(input, "module"),
389
+ MAX_FRAME_MODULE,
390
+ true
391
+ )
392
+ end
393
+ if has_key?(input, "inApp")
394
+ in_app = read_value(input, "inApp")
395
+ unless in_app == true || in_app == false
396
+ raise validation("issue stack frame inApp must be a boolean")
397
+ end
398
+ value["inApp"] = in_app
399
+ end
400
+ if has_key?(input, "debugId")
401
+ debug_id = read_value(input, "debugId")
402
+ normalized = debug_id.is_a?(String) ? debug_id.strip.downcase : ""
403
+ raise validation("issue stack frame debugId is invalid") unless normalized.match?(DEBUG_ID)
404
+
405
+ value["debugId"] = normalized
406
+ end
407
+ value
408
+ end
409
+
410
+ def validate_breadcrumbs(input)
411
+ unless input.is_a?(Array) && input.length.between?(1, MAX_BREADCRUMBS)
412
+ raise validation("issue breadcrumbs must contain 1-64 entries")
413
+ end
414
+ input.map { |item| validate_breadcrumb(item) }
415
+ end
416
+
417
+ def validate_breadcrumb(input)
418
+ unless input.is_a?(Hash)
419
+ raise validation("issue breadcrumb must be an object")
420
+ end
421
+ reject_unknown_keys(input, %w[timestamp type category level message data], "issue breadcrumb")
422
+ timestamp = read_required(input, "timestamp", "issue breadcrumb timestamp")
423
+ require_breadcrumb_timestamp(timestamp)
424
+ category = read_required(input, "category", "issue breadcrumb category")
425
+ value = {
426
+ "timestamp" => timestamp.dup,
427
+ "category" => require_machine_name(
428
+ "issue breadcrumb category",
429
+ category,
430
+ MAX_BREADCRUMB_NAME,
431
+ true
432
+ )
433
+ }
434
+ if has_key?(input, "type")
435
+ value["type"] = require_machine_name(
436
+ "issue breadcrumb type",
437
+ read_value(input, "type"),
438
+ MAX_BREADCRUMB_NAME,
439
+ true
440
+ )
441
+ end
442
+ if has_key?(input, "level")
443
+ level = read_value(input, "level")
444
+ normalized = level.is_a?(String) ? BREADCRUMB_LEVELS[level] : nil
445
+ unless normalized
446
+ raise validation(
447
+ "issue breadcrumb level must be one of: trace, debug, info, log, warn, warning, error, fatal, critical"
448
+ )
449
+ end
450
+ value["level"] = normalized
451
+ end
452
+ if has_key?(input, "message")
453
+ value["message"] = require_text(
454
+ "issue breadcrumb message",
455
+ read_value(input, "message"),
456
+ MAX_BREADCRUMB_MESSAGE,
457
+ false
458
+ )
459
+ end
460
+ value["data"] = validate_breadcrumb_data(read_value(input, "data")) if has_key?(input, "data")
461
+ value
462
+ end
463
+
464
+ def validate_breadcrumb_data(input)
465
+ unless input.is_a?(Hash)
466
+ raise validation("issue breadcrumb data must be an object")
467
+ end
468
+ if input.length > MAX_BREADCRUMB_DATA_FIELDS
469
+ raise validation("issue breadcrumb data must contain at most 8 fields")
470
+ end
471
+
472
+ input.each_with_object({}) do |(raw_key, raw_value), copied|
473
+ key = raw_key.to_s
474
+ unless key.match?(DATA_KEY)
475
+ raise validation("issue breadcrumb data keys must be stable machine names")
476
+ end
477
+ copied[key] = validate_breadcrumb_data_value(key, raw_value)
478
+ end
479
+ end
480
+
481
+ def validate_breadcrumb_data_value(key, value)
482
+ return value if value.nil? || value == true || value == false || value.is_a?(Integer)
483
+ return value if value.is_a?(Float) && value.finite?
484
+ if value.is_a?(String)
485
+ return require_text(
486
+ "issue breadcrumb data value for #{key}",
487
+ value,
488
+ MAX_BREADCRUMB_DATA_STRING,
489
+ false
490
+ )
491
+ end
492
+
493
+ raise validation("issue breadcrumb data value for #{key} must be a finite primitive")
494
+ end
495
+
496
+ def safe_exception_type(error)
497
+ candidate = error.class.name
498
+ candidate = "anonymous_exception" if candidate.nil? || candidate.to_s.strip.empty?
499
+ safe_generated_text(candidate, MAX_EXCEPTION_TYPE, true, "Exception")
500
+ rescue StandardError
501
+ "Exception"
502
+ end
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
+
586
+ def safe_backtrace_locations(error)
587
+ locations = error.backtrace_locations
588
+ locations.respond_to?(:first) ? locations.first(MAX_STACK_FRAMES + 1) : []
589
+ rescue StandardError
590
+ []
591
+ end
592
+
593
+ def safe_backtrace(error)
594
+ backtrace = error.backtrace
595
+ backtrace.is_a?(Array) ? backtrace.first(MAX_STACK_FRAMES + 1) : []
596
+ rescue StandardError
597
+ []
598
+ end
599
+
600
+ def frame_from_location(location)
601
+ path = safe_location_value(location, :absolute_path) || safe_location_value(location, :path)
602
+ filename = generated_filename(path)
603
+ line = safe_location_value(location, :lineno)
604
+ line = 1 unless line.is_a?(Integer) && line.between?(1, MAX_COORDINATE)
605
+ function = safe_generated_text(safe_location_value(location, :base_label), MAX_FRAME_FUNCTION, false, nil)
606
+ frame = { "filename" => filename, "line" => line, "column" => 1 }
607
+ frame["function"] = function unless function.nil?
608
+ validate_stack_frame(frame)
609
+ rescue StandardError
610
+ nil
611
+ end
612
+
613
+ def frame_from_backtrace_line(line)
614
+ return nil unless line.is_a?(String) && line.valid_encoding?
615
+
616
+ match = line.match(/\A(.+):(\d+)(?::in [`'](.+?)[`'])?\z/)
617
+ return nil unless match
618
+
619
+ line_number = match[2].to_i
620
+ line_number = 1 unless line_number.between?(1, MAX_COORDINATE)
621
+ function = safe_generated_text(match[3], MAX_FRAME_FUNCTION, false, nil)
622
+ frame = {
623
+ "filename" => generated_filename(match[1]),
624
+ "line" => line_number,
625
+ "column" => 1
626
+ }
627
+ frame["function"] = function unless function.nil?
628
+ validate_stack_frame(frame)
629
+ rescue StandardError
630
+ nil
631
+ end
632
+
633
+ def safe_location_value(location, method_name)
634
+ return nil unless location.respond_to?(method_name)
635
+
636
+ location.public_send(method_name)
637
+ rescue StandardError
638
+ nil
639
+ end
640
+
641
+ def generated_filename(path)
642
+ candidate = path.is_a?(String) ? path : "unknown.rb"
643
+ filename = sanitize_filename(candidate)
644
+ basename(filename)
645
+ rescue SdkError
646
+ "unknown.rb"
647
+ end
648
+
649
+ def sanitize_filename(value)
650
+ unless value.is_a?(String) && value.valid_encoding?
651
+ raise validation("issue stack frame filename is invalid")
652
+ end
653
+
654
+ filename = value.strip
655
+ file_url = filename.downcase.start_with?("file://")
656
+ filename = filename[7..-1] if file_url
657
+ query = filename.index("?")
658
+ fragment = filename.index("#")
659
+ finish = [query, fragment].compact.min
660
+ filename = filename[0...finish] unless finish.nil?
661
+ filename = filename.strip
662
+ absolute = file_url || filename.start_with?("/", "\\") || filename.match?(/\A[A-Za-z]:[\\\/]/)
663
+ filename = basename(filename) if absolute
664
+ require_text("issue stack frame filename", filename, MAX_FRAME_FILENAME, true)
665
+ end
666
+
667
+ def basename(value)
668
+ value.tr("\\", "/").split("/").last.to_s
669
+ end
670
+
671
+ def require_coordinate(label, value)
672
+ unless value.is_a?(Integer) && value.between?(1, MAX_COORDINATE)
673
+ raise validation("#{label} must be a positive integer")
674
+ end
675
+ value
676
+ end
677
+
678
+ def require_breadcrumb_timestamp(value)
679
+ unless value.is_a?(String) && value.valid_encoding? && value.match?(RFC3339)
680
+ raise validation("issue breadcrumb timestamp must be RFC 3339 with an explicit timezone")
681
+ end
682
+ Time.iso8601(value)
683
+ rescue ArgumentError
684
+ raise validation("issue breadcrumb timestamp must be RFC 3339 with an explicit timezone")
685
+ end
686
+
687
+ def require_machine_name(label, value, maximum, allow_colon)
688
+ normalized = value.is_a?(String) ? value.strip : ""
689
+ pattern = allow_colon ? MACHINE_NAME : DATA_KEY
690
+ unless normalized.length <= maximum && normalized.match?(pattern)
691
+ raise validation("#{label} must be a stable machine name")
692
+ end
693
+ normalized.dup
694
+ end
695
+
696
+ def require_text(label, value, maximum, reject_location_text)
697
+ unless value.is_a?(String) && value.valid_encoding? && !value.strip.empty? && value.length <= maximum &&
698
+ !value.match?(CONTROL_CHARACTERS) && (!reject_location_text || !value.match?(/[?#]/))
699
+ raise validation("#{label} is invalid or exceeds #{maximum} characters")
700
+ end
701
+ value.dup
702
+ end
703
+
704
+ def safe_generated_text(value, maximum, reject_location_text, fallback)
705
+ return fallback if value.nil?
706
+
707
+ require_text("generated issue text", value.to_s, maximum, reject_location_text)
708
+ rescue SdkError
709
+ fallback
710
+ end
711
+
712
+ def read_required(input, key, label)
713
+ raise validation("#{label} must be provided") unless has_key?(input, key)
714
+
715
+ read_value(input, key)
716
+ end
717
+
718
+ def read_value(input, key)
719
+ return input[key] if input.key?(key)
720
+
721
+ input[key.to_sym]
722
+ end
723
+
724
+ def has_key?(input, key)
725
+ input.key?(key) || input.key?(key.to_sym)
726
+ end
727
+
728
+ def reject_unknown_keys(input, allowed, label)
729
+ unknown = input.keys.map(&:to_s).reject { |key| allowed.include?(key) }
730
+ return if unknown.empty?
731
+
732
+ raise validation("#{label} contains unsupported field #{unknown.first}")
733
+ end
734
+
735
+ def detach_metadata(metadata)
736
+ metadata.each_with_object({}) do |(key, value), copied|
737
+ copied[key.to_s.dup] = value.is_a?(String) ? value.dup : value
738
+ end
739
+ end
740
+
741
+ def copy_string(value)
742
+ value.is_a?(String) ? value.dup : value
743
+ end
744
+
745
+ def validation(message)
746
+ SdkError.new("validation_error", message)
747
+ end
748
+
749
+ private_class_method(
750
+ :validate_exception,
751
+ :validate_exception_chain,
752
+ :validate_exception_chain_entry,
753
+ :validate_exception_mechanism,
754
+ :validate_stack_frames,
755
+ :validate_stack_frame,
756
+ :validate_breadcrumbs,
757
+ :validate_breadcrumb,
758
+ :validate_breadcrumb_data,
759
+ :validate_breadcrumb_data_value,
760
+ :safe_backtrace_locations,
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,
767
+ :frame_from_location,
768
+ :frame_from_backtrace_line,
769
+ :safe_location_value,
770
+ :generated_filename,
771
+ :sanitize_filename,
772
+ :basename,
773
+ :require_coordinate,
774
+ :require_breadcrumb_timestamp,
775
+ :require_machine_name,
776
+ :require_text,
777
+ :safe_generated_text,
778
+ :read_required,
779
+ :read_value,
780
+ :has_key?,
781
+ :reject_unknown_keys,
782
+ :detach_metadata,
783
+ :copy_string,
784
+ :validation
785
+ )
786
+ end
787
+ end