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.
- checksums.yaml +4 -4
- data/README.md +228 -64
- data/examples/Makefile +5 -1
- data/examples/first_useful_telemetry.rb +90 -72
- data/examples/http_trace_correlation.rb +1 -0
- data/examples/issue_diagnostics.rb +55 -0
- data/examples/readme_example.rb +2 -1
- data/examples/real_user_smoke.rb +4 -2
- data/lib/logbrew/issue_diagnostics.rb +787 -0
- data/lib/logbrew/product_timeline.rb +24 -3
- data/lib/logbrew/rails_integration.rb +22 -1
- data/lib/logbrew/telemetry.rb +60 -0
- data/lib/logbrew/telemetry_context.rb +306 -0
- data/lib/logbrew/telemetry_context_value.rb +152 -0
- data/lib/logbrew/telemetry_resource.rb +161 -0
- data/lib/logbrew/version.rb +1 -1
- data/lib/logbrew.rb +158 -40
- metadata +10 -4
|
@@ -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
|