logbrew-sdk 0.1.2 → 0.1.4
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 +334 -63
- data/examples/Makefile +5 -1
- data/examples/automatic_delivery.rb +1 -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/examples/sidekiq_tracing.rb +1 -1
- data/lib/logbrew/issue_diagnostics.rb +576 -0
- data/lib/logbrew/product_timeline.rb +24 -3
- data/lib/logbrew/rails.rb +105 -0
- data/lib/logbrew/rails_integration.rb +643 -0
- 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 +5 -0
- data/lib/logbrew-sdk.rb +4 -0
- data/lib/logbrew.rb +180 -48
- metadata +14 -4
|
@@ -0,0 +1,576 @@
|
|
|
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_EXCEPTION_TYPE = 256
|
|
15
|
+
MAX_MECHANISM_TYPE = 64
|
|
16
|
+
MAX_FRAME_FILENAME = 2_048
|
|
17
|
+
MAX_FRAME_FUNCTION = 256
|
|
18
|
+
MAX_FRAME_MODULE = 512
|
|
19
|
+
MAX_BREADCRUMB_NAME = 64
|
|
20
|
+
MAX_BREADCRUMB_MESSAGE = 512
|
|
21
|
+
MAX_BREADCRUMB_DATA_FIELDS = 8
|
|
22
|
+
MAX_BREADCRUMB_DATA_STRING = 256
|
|
23
|
+
MAX_COORDINATE = 2_147_483_647
|
|
24
|
+
|
|
25
|
+
MACHINE_NAME = /\A[A-Za-z][A-Za-z0-9_.:-]{0,63}\z/.freeze
|
|
26
|
+
DATA_KEY = /\A[A-Za-z][A-Za-z0-9_.-]{0,63}\z/.freeze
|
|
27
|
+
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
|
|
28
|
+
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
|
|
29
|
+
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/.freeze
|
|
30
|
+
BREADCRUMB_LEVELS = {
|
|
31
|
+
"trace" => "debug",
|
|
32
|
+
"debug" => "debug",
|
|
33
|
+
"log" => "info",
|
|
34
|
+
"info" => "info",
|
|
35
|
+
"warn" => "warning",
|
|
36
|
+
"warning" => "warning",
|
|
37
|
+
"error" => "error",
|
|
38
|
+
"fatal" => "critical",
|
|
39
|
+
"critical" => "critical"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Build a complete issue attribute payload from an exception. Exception
|
|
45
|
+
# text remains application-controlled through the explicit message option.
|
|
46
|
+
def from_exception(
|
|
47
|
+
error,
|
|
48
|
+
title: nil,
|
|
49
|
+
level: "error",
|
|
50
|
+
message: nil,
|
|
51
|
+
mechanism_type: "ruby.exception",
|
|
52
|
+
handled: true,
|
|
53
|
+
metadata: nil,
|
|
54
|
+
breadcrumbs: nil,
|
|
55
|
+
breadcrumbs_truncated: false,
|
|
56
|
+
include_stack_frames: true,
|
|
57
|
+
context: nil
|
|
58
|
+
)
|
|
59
|
+
unless error.is_a?(Exception)
|
|
60
|
+
raise validation("issue error must be an exception")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
exception_type = safe_exception_type(error)
|
|
64
|
+
attributes = {
|
|
65
|
+
"title" => title.nil? ? exception_type : title,
|
|
66
|
+
"level" => level,
|
|
67
|
+
"exception" => exception(
|
|
68
|
+
type: exception_type,
|
|
69
|
+
mechanism_type: mechanism_type,
|
|
70
|
+
handled: handled
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
attributes["message"] = message unless message.nil?
|
|
74
|
+
if include_stack_frames
|
|
75
|
+
frames = stack_frames_from_exception(error)
|
|
76
|
+
attributes["stackFrames"] = frames unless frames.empty?
|
|
77
|
+
end
|
|
78
|
+
attributes["breadcrumbs"] = breadcrumbs unless breadcrumbs.nil?
|
|
79
|
+
attributes["breadcrumbsTruncated"] = true if breadcrumbs_truncated
|
|
80
|
+
attributes["metadata"] = metadata unless metadata.nil?
|
|
81
|
+
validated = validate_issue_attributes(attributes)
|
|
82
|
+
unless context.nil?
|
|
83
|
+
unless context.is_a?(TelemetryContext)
|
|
84
|
+
raise validation("issue context must be a LogBrew::TelemetryContext")
|
|
85
|
+
end
|
|
86
|
+
validated["context"] = context
|
|
87
|
+
end
|
|
88
|
+
validated
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build a typed exception identity and optional observation mechanism.
|
|
92
|
+
def exception(type:, mechanism_type: nil, handled: nil)
|
|
93
|
+
value = { "type" => require_text("issue exception type", type, MAX_EXCEPTION_TYPE, true) }
|
|
94
|
+
unless mechanism_type.nil? && handled.nil?
|
|
95
|
+
if mechanism_type.nil? || (handled != true && handled != false)
|
|
96
|
+
raise validation("issue exception mechanism must include type and handled")
|
|
97
|
+
end
|
|
98
|
+
value["mechanism"] = {
|
|
99
|
+
"type" => require_machine_name(
|
|
100
|
+
"issue exception mechanism type",
|
|
101
|
+
mechanism_type,
|
|
102
|
+
MAX_MECHANISM_TYPE,
|
|
103
|
+
true
|
|
104
|
+
),
|
|
105
|
+
"handled" => handled
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Build one explicit structured frame. Absolute filenames are reduced to
|
|
112
|
+
# their basename and URL query or fragment text is removed.
|
|
113
|
+
def stack_frame(
|
|
114
|
+
filename:,
|
|
115
|
+
line:,
|
|
116
|
+
column: 1,
|
|
117
|
+
function: nil,
|
|
118
|
+
module_name: nil,
|
|
119
|
+
in_app: nil,
|
|
120
|
+
debug_id: nil
|
|
121
|
+
)
|
|
122
|
+
frame = {
|
|
123
|
+
"filename" => filename,
|
|
124
|
+
"line" => line,
|
|
125
|
+
"column" => column
|
|
126
|
+
}
|
|
127
|
+
frame["function"] = function unless function.nil?
|
|
128
|
+
frame["module"] = module_name unless module_name.nil?
|
|
129
|
+
frame["inApp"] = in_app unless in_app.nil?
|
|
130
|
+
frame["debugId"] = debug_id unless debug_id.nil?
|
|
131
|
+
validate_stack_frame(frame)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Project an exception backtrace into at most 32 newest-first code frames.
|
|
135
|
+
def stack_frames_from_exception(error)
|
|
136
|
+
unless error.is_a?(Exception)
|
|
137
|
+
raise validation("issue error must be an exception")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
locations = safe_backtrace_locations(error)
|
|
141
|
+
frames = locations.first(MAX_STACK_FRAMES).map { |location| frame_from_location(location) }.compact
|
|
142
|
+
return frames unless frames.empty?
|
|
143
|
+
|
|
144
|
+
safe_backtrace(error).first(MAX_STACK_FRAMES).map { |line| frame_from_backtrace_line(line) }.compact
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Build one oldest-to-newest breadcrumb with bounded flat primitive data.
|
|
148
|
+
def breadcrumb(timestamp:, category:, type: nil, level: nil, message: nil, data: nil)
|
|
149
|
+
value = {
|
|
150
|
+
"timestamp" => timestamp,
|
|
151
|
+
"category" => category
|
|
152
|
+
}
|
|
153
|
+
value["type"] = type unless type.nil?
|
|
154
|
+
value["level"] = level unless level.nil?
|
|
155
|
+
value["message"] = message unless message.nil?
|
|
156
|
+
value["data"] = data unless data.nil?
|
|
157
|
+
validate_breadcrumb(value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validate and detach a complete issue attribute payload.
|
|
161
|
+
def validate_issue_attributes(attributes)
|
|
162
|
+
unless attributes.is_a?(Hash)
|
|
163
|
+
raise validation("issue attributes must be an object")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
title = read_required(attributes, "title", "issue title")
|
|
167
|
+
Validation.require_non_empty("issue title", title)
|
|
168
|
+
level = read_required(attributes, "level", "issue level")
|
|
169
|
+
Validation.require_allowed_value("issue level", level, SEVERITY_VALUES)
|
|
170
|
+
payload = {
|
|
171
|
+
"title" => copy_string(title),
|
|
172
|
+
"level" => SEVERITY_ALIASES.fetch(level)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if has_key?(attributes, "message")
|
|
176
|
+
message = read_value(attributes, "message")
|
|
177
|
+
unless message.is_a?(String) && message.valid_encoding?
|
|
178
|
+
raise validation("issue message must be a string")
|
|
179
|
+
end
|
|
180
|
+
payload["message"] = message.dup
|
|
181
|
+
end
|
|
182
|
+
payload["exception"] = validate_exception(read_value(attributes, "exception")) if has_key?(attributes, "exception")
|
|
183
|
+
payload["stackFrames"] = validate_stack_frames(read_value(attributes, "stackFrames")) if has_key?(attributes, "stackFrames")
|
|
184
|
+
payload["breadcrumbs"] = validate_breadcrumbs(read_value(attributes, "breadcrumbs")) if has_key?(attributes, "breadcrumbs")
|
|
185
|
+
if has_key?(attributes, "breadcrumbsTruncated")
|
|
186
|
+
truncated = read_value(attributes, "breadcrumbsTruncated")
|
|
187
|
+
unless truncated == true || truncated == false
|
|
188
|
+
raise validation("issue breadcrumbsTruncated must be a boolean")
|
|
189
|
+
end
|
|
190
|
+
payload["breadcrumbsTruncated"] = true if truncated
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if has_key?(attributes, "metadata")
|
|
194
|
+
metadata = Validation.require_metadata(read_value(attributes, "metadata"))
|
|
195
|
+
payload["metadata"] = detach_metadata(metadata) unless metadata.nil?
|
|
196
|
+
end
|
|
197
|
+
payload
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_exception(input)
|
|
201
|
+
unless input.is_a?(Hash)
|
|
202
|
+
raise validation("issue exception must be an object")
|
|
203
|
+
end
|
|
204
|
+
reject_unknown_keys(input, %w[type mechanism], "issue exception")
|
|
205
|
+
type = read_required(input, "type", "issue exception type")
|
|
206
|
+
value = {
|
|
207
|
+
"type" => require_text("issue exception type", type, MAX_EXCEPTION_TYPE, true)
|
|
208
|
+
}
|
|
209
|
+
return value unless has_key?(input, "mechanism")
|
|
210
|
+
|
|
211
|
+
mechanism = read_value(input, "mechanism")
|
|
212
|
+
unless mechanism.is_a?(Hash)
|
|
213
|
+
raise validation("issue exception mechanism must be an object")
|
|
214
|
+
end
|
|
215
|
+
reject_unknown_keys(mechanism, %w[type handled], "issue exception mechanism")
|
|
216
|
+
mechanism_type = read_required(mechanism, "type", "issue exception mechanism type")
|
|
217
|
+
handled = read_required(mechanism, "handled", "issue exception mechanism handled")
|
|
218
|
+
unless handled == true || handled == false
|
|
219
|
+
raise validation("issue exception mechanism handled must be a boolean")
|
|
220
|
+
end
|
|
221
|
+
value["mechanism"] = {
|
|
222
|
+
"type" => require_machine_name(
|
|
223
|
+
"issue exception mechanism type",
|
|
224
|
+
mechanism_type,
|
|
225
|
+
MAX_MECHANISM_TYPE,
|
|
226
|
+
true
|
|
227
|
+
),
|
|
228
|
+
"handled" => handled
|
|
229
|
+
}
|
|
230
|
+
value
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_stack_frames(input)
|
|
234
|
+
unless input.is_a?(Array) && input.length.between?(1, MAX_STACK_FRAMES)
|
|
235
|
+
raise validation("issue stackFrames must contain 1-32 frames")
|
|
236
|
+
end
|
|
237
|
+
input.map { |frame| validate_stack_frame(frame) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_stack_frame(input)
|
|
241
|
+
unless input.is_a?(Hash)
|
|
242
|
+
raise validation("issue stack frame must be an object")
|
|
243
|
+
end
|
|
244
|
+
reject_unknown_keys(input, %w[filename line column function module inApp debugId], "issue stack frame")
|
|
245
|
+
filename = read_required(input, "filename", "issue stack frame filename")
|
|
246
|
+
line = require_coordinate("issue stack frame line", read_required(input, "line", "issue stack frame line"))
|
|
247
|
+
column = require_coordinate(
|
|
248
|
+
"issue stack frame column",
|
|
249
|
+
read_required(input, "column", "issue stack frame column")
|
|
250
|
+
)
|
|
251
|
+
value = {
|
|
252
|
+
"filename" => sanitize_filename(filename),
|
|
253
|
+
"line" => line,
|
|
254
|
+
"column" => column
|
|
255
|
+
}
|
|
256
|
+
if has_key?(input, "function")
|
|
257
|
+
value["function"] = require_text(
|
|
258
|
+
"issue stack frame function",
|
|
259
|
+
read_value(input, "function"),
|
|
260
|
+
MAX_FRAME_FUNCTION,
|
|
261
|
+
false
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
if has_key?(input, "module")
|
|
265
|
+
value["module"] = require_text(
|
|
266
|
+
"issue stack frame module",
|
|
267
|
+
read_value(input, "module"),
|
|
268
|
+
MAX_FRAME_MODULE,
|
|
269
|
+
true
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
if has_key?(input, "inApp")
|
|
273
|
+
in_app = read_value(input, "inApp")
|
|
274
|
+
unless in_app == true || in_app == false
|
|
275
|
+
raise validation("issue stack frame inApp must be a boolean")
|
|
276
|
+
end
|
|
277
|
+
value["inApp"] = in_app
|
|
278
|
+
end
|
|
279
|
+
if has_key?(input, "debugId")
|
|
280
|
+
debug_id = read_value(input, "debugId")
|
|
281
|
+
normalized = debug_id.is_a?(String) ? debug_id.strip.downcase : ""
|
|
282
|
+
raise validation("issue stack frame debugId is invalid") unless normalized.match?(DEBUG_ID)
|
|
283
|
+
|
|
284
|
+
value["debugId"] = normalized
|
|
285
|
+
end
|
|
286
|
+
value
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def validate_breadcrumbs(input)
|
|
290
|
+
unless input.is_a?(Array) && input.length.between?(1, MAX_BREADCRUMBS)
|
|
291
|
+
raise validation("issue breadcrumbs must contain 1-64 entries")
|
|
292
|
+
end
|
|
293
|
+
input.map { |item| validate_breadcrumb(item) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def validate_breadcrumb(input)
|
|
297
|
+
unless input.is_a?(Hash)
|
|
298
|
+
raise validation("issue breadcrumb must be an object")
|
|
299
|
+
end
|
|
300
|
+
reject_unknown_keys(input, %w[timestamp type category level message data], "issue breadcrumb")
|
|
301
|
+
timestamp = read_required(input, "timestamp", "issue breadcrumb timestamp")
|
|
302
|
+
require_breadcrumb_timestamp(timestamp)
|
|
303
|
+
category = read_required(input, "category", "issue breadcrumb category")
|
|
304
|
+
value = {
|
|
305
|
+
"timestamp" => timestamp.dup,
|
|
306
|
+
"category" => require_machine_name(
|
|
307
|
+
"issue breadcrumb category",
|
|
308
|
+
category,
|
|
309
|
+
MAX_BREADCRUMB_NAME,
|
|
310
|
+
true
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
if has_key?(input, "type")
|
|
314
|
+
value["type"] = require_machine_name(
|
|
315
|
+
"issue breadcrumb type",
|
|
316
|
+
read_value(input, "type"),
|
|
317
|
+
MAX_BREADCRUMB_NAME,
|
|
318
|
+
true
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
if has_key?(input, "level")
|
|
322
|
+
level = read_value(input, "level")
|
|
323
|
+
normalized = level.is_a?(String) ? BREADCRUMB_LEVELS[level] : nil
|
|
324
|
+
unless normalized
|
|
325
|
+
raise validation(
|
|
326
|
+
"issue breadcrumb level must be one of: trace, debug, info, log, warn, warning, error, fatal, critical"
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
value["level"] = normalized
|
|
330
|
+
end
|
|
331
|
+
if has_key?(input, "message")
|
|
332
|
+
value["message"] = require_text(
|
|
333
|
+
"issue breadcrumb message",
|
|
334
|
+
read_value(input, "message"),
|
|
335
|
+
MAX_BREADCRUMB_MESSAGE,
|
|
336
|
+
false
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
value["data"] = validate_breadcrumb_data(read_value(input, "data")) if has_key?(input, "data")
|
|
340
|
+
value
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def validate_breadcrumb_data(input)
|
|
344
|
+
unless input.is_a?(Hash)
|
|
345
|
+
raise validation("issue breadcrumb data must be an object")
|
|
346
|
+
end
|
|
347
|
+
if input.length > MAX_BREADCRUMB_DATA_FIELDS
|
|
348
|
+
raise validation("issue breadcrumb data must contain at most 8 fields")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
input.each_with_object({}) do |(raw_key, raw_value), copied|
|
|
352
|
+
key = raw_key.to_s
|
|
353
|
+
unless key.match?(DATA_KEY)
|
|
354
|
+
raise validation("issue breadcrumb data keys must be stable machine names")
|
|
355
|
+
end
|
|
356
|
+
copied[key] = validate_breadcrumb_data_value(key, raw_value)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def validate_breadcrumb_data_value(key, value)
|
|
361
|
+
return value if value.nil? || value == true || value == false || value.is_a?(Integer)
|
|
362
|
+
return value if value.is_a?(Float) && value.finite?
|
|
363
|
+
if value.is_a?(String)
|
|
364
|
+
return require_text(
|
|
365
|
+
"issue breadcrumb data value for #{key}",
|
|
366
|
+
value,
|
|
367
|
+
MAX_BREADCRUMB_DATA_STRING,
|
|
368
|
+
false
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
raise validation("issue breadcrumb data value for #{key} must be a finite primitive")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def safe_exception_type(error)
|
|
376
|
+
candidate = error.class.name
|
|
377
|
+
candidate = "anonymous_exception" if candidate.nil? || candidate.to_s.strip.empty?
|
|
378
|
+
safe_generated_text(candidate, MAX_EXCEPTION_TYPE, true, "Exception")
|
|
379
|
+
rescue StandardError
|
|
380
|
+
"Exception"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def safe_backtrace_locations(error)
|
|
384
|
+
locations = error.backtrace_locations
|
|
385
|
+
locations.respond_to?(:first) ? locations.first(MAX_STACK_FRAMES) : []
|
|
386
|
+
rescue StandardError
|
|
387
|
+
[]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def safe_backtrace(error)
|
|
391
|
+
backtrace = error.backtrace
|
|
392
|
+
backtrace.is_a?(Array) ? backtrace.first(MAX_STACK_FRAMES) : []
|
|
393
|
+
rescue StandardError
|
|
394
|
+
[]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def frame_from_location(location)
|
|
398
|
+
path = safe_location_value(location, :absolute_path) || safe_location_value(location, :path)
|
|
399
|
+
filename = generated_filename(path)
|
|
400
|
+
line = safe_location_value(location, :lineno)
|
|
401
|
+
line = 1 unless line.is_a?(Integer) && line.between?(1, MAX_COORDINATE)
|
|
402
|
+
function = safe_generated_text(safe_location_value(location, :base_label), MAX_FRAME_FUNCTION, false, nil)
|
|
403
|
+
frame = { "filename" => filename, "line" => line, "column" => 1 }
|
|
404
|
+
frame["function"] = function unless function.nil?
|
|
405
|
+
validate_stack_frame(frame)
|
|
406
|
+
rescue StandardError
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def frame_from_backtrace_line(line)
|
|
411
|
+
return nil unless line.is_a?(String) && line.valid_encoding?
|
|
412
|
+
|
|
413
|
+
match = line.match(/\A(.+):(\d+)(?::in [`'](.+?)[`'])?\z/)
|
|
414
|
+
return nil unless match
|
|
415
|
+
|
|
416
|
+
line_number = match[2].to_i
|
|
417
|
+
line_number = 1 unless line_number.between?(1, MAX_COORDINATE)
|
|
418
|
+
function = safe_generated_text(match[3], MAX_FRAME_FUNCTION, false, nil)
|
|
419
|
+
frame = {
|
|
420
|
+
"filename" => generated_filename(match[1]),
|
|
421
|
+
"line" => line_number,
|
|
422
|
+
"column" => 1
|
|
423
|
+
}
|
|
424
|
+
frame["function"] = function unless function.nil?
|
|
425
|
+
validate_stack_frame(frame)
|
|
426
|
+
rescue StandardError
|
|
427
|
+
nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def safe_location_value(location, method_name)
|
|
431
|
+
return nil unless location.respond_to?(method_name)
|
|
432
|
+
|
|
433
|
+
location.public_send(method_name)
|
|
434
|
+
rescue StandardError
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def generated_filename(path)
|
|
439
|
+
candidate = path.is_a?(String) ? path : "unknown.rb"
|
|
440
|
+
filename = sanitize_filename(candidate)
|
|
441
|
+
basename(filename)
|
|
442
|
+
rescue SdkError
|
|
443
|
+
"unknown.rb"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def sanitize_filename(value)
|
|
447
|
+
unless value.is_a?(String) && value.valid_encoding?
|
|
448
|
+
raise validation("issue stack frame filename is invalid")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
filename = value.strip
|
|
452
|
+
file_url = filename.downcase.start_with?("file://")
|
|
453
|
+
filename = filename[7..-1] if file_url
|
|
454
|
+
query = filename.index("?")
|
|
455
|
+
fragment = filename.index("#")
|
|
456
|
+
finish = [query, fragment].compact.min
|
|
457
|
+
filename = filename[0...finish] unless finish.nil?
|
|
458
|
+
filename = filename.strip
|
|
459
|
+
absolute = file_url || filename.start_with?("/", "\\") || filename.match?(/\A[A-Za-z]:[\\\/]/)
|
|
460
|
+
filename = basename(filename) if absolute
|
|
461
|
+
require_text("issue stack frame filename", filename, MAX_FRAME_FILENAME, true)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def basename(value)
|
|
465
|
+
value.tr("\\", "/").split("/").last.to_s
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def require_coordinate(label, value)
|
|
469
|
+
unless value.is_a?(Integer) && value.between?(1, MAX_COORDINATE)
|
|
470
|
+
raise validation("#{label} must be a positive integer")
|
|
471
|
+
end
|
|
472
|
+
value
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def require_breadcrumb_timestamp(value)
|
|
476
|
+
unless value.is_a?(String) && value.valid_encoding? && value.match?(RFC3339)
|
|
477
|
+
raise validation("issue breadcrumb timestamp must be RFC 3339 with an explicit timezone")
|
|
478
|
+
end
|
|
479
|
+
Time.iso8601(value)
|
|
480
|
+
rescue ArgumentError
|
|
481
|
+
raise validation("issue breadcrumb timestamp must be RFC 3339 with an explicit timezone")
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def require_machine_name(label, value, maximum, allow_colon)
|
|
485
|
+
normalized = value.is_a?(String) ? value.strip : ""
|
|
486
|
+
pattern = allow_colon ? MACHINE_NAME : DATA_KEY
|
|
487
|
+
unless normalized.length <= maximum && normalized.match?(pattern)
|
|
488
|
+
raise validation("#{label} must be a stable machine name")
|
|
489
|
+
end
|
|
490
|
+
normalized.dup
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def require_text(label, value, maximum, reject_location_text)
|
|
494
|
+
unless value.is_a?(String) && value.valid_encoding? && !value.strip.empty? && value.length <= maximum &&
|
|
495
|
+
!value.match?(CONTROL_CHARACTERS) && (!reject_location_text || !value.match?(/[?#]/))
|
|
496
|
+
raise validation("#{label} is invalid or exceeds #{maximum} characters")
|
|
497
|
+
end
|
|
498
|
+
value.dup
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def safe_generated_text(value, maximum, reject_location_text, fallback)
|
|
502
|
+
return fallback if value.nil?
|
|
503
|
+
|
|
504
|
+
require_text("generated issue text", value.to_s, maximum, reject_location_text)
|
|
505
|
+
rescue SdkError
|
|
506
|
+
fallback
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def read_required(input, key, label)
|
|
510
|
+
raise validation("#{label} must be provided") unless has_key?(input, key)
|
|
511
|
+
|
|
512
|
+
read_value(input, key)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def read_value(input, key)
|
|
516
|
+
return input[key] if input.key?(key)
|
|
517
|
+
|
|
518
|
+
input[key.to_sym]
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def has_key?(input, key)
|
|
522
|
+
input.key?(key) || input.key?(key.to_sym)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def reject_unknown_keys(input, allowed, label)
|
|
526
|
+
unknown = input.keys.map(&:to_s).reject { |key| allowed.include?(key) }
|
|
527
|
+
return if unknown.empty?
|
|
528
|
+
|
|
529
|
+
raise validation("#{label} contains unsupported field #{unknown.first}")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def detach_metadata(metadata)
|
|
533
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
534
|
+
copied[key.to_s.dup] = value.is_a?(String) ? value.dup : value
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def copy_string(value)
|
|
539
|
+
value.is_a?(String) ? value.dup : value
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def validation(message)
|
|
543
|
+
SdkError.new("validation_error", message)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
private_class_method(
|
|
547
|
+
:validate_exception,
|
|
548
|
+
:validate_stack_frames,
|
|
549
|
+
:validate_stack_frame,
|
|
550
|
+
:validate_breadcrumbs,
|
|
551
|
+
:validate_breadcrumb,
|
|
552
|
+
:validate_breadcrumb_data,
|
|
553
|
+
:validate_breadcrumb_data_value,
|
|
554
|
+
:safe_backtrace_locations,
|
|
555
|
+
:safe_backtrace,
|
|
556
|
+
:frame_from_location,
|
|
557
|
+
:frame_from_backtrace_line,
|
|
558
|
+
:safe_location_value,
|
|
559
|
+
:generated_filename,
|
|
560
|
+
:sanitize_filename,
|
|
561
|
+
:basename,
|
|
562
|
+
:require_coordinate,
|
|
563
|
+
:require_breadcrumb_timestamp,
|
|
564
|
+
:require_machine_name,
|
|
565
|
+
:require_text,
|
|
566
|
+
:safe_generated_text,
|
|
567
|
+
:read_required,
|
|
568
|
+
:read_value,
|
|
569
|
+
:has_key?,
|
|
570
|
+
:reject_unknown_keys,
|
|
571
|
+
:detach_metadata,
|
|
572
|
+
:copy_string,
|
|
573
|
+
:validation
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
end
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module LogBrew
|
|
4
4
|
# Builders for app-owned product and network timeline action events.
|
|
5
5
|
class ProductTimeline
|
|
6
|
+
PRODUCT_ANALYTICS_SCHEMA_VERSION = 1
|
|
7
|
+
MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256
|
|
8
|
+
|
|
6
9
|
private_class_method :new
|
|
7
10
|
|
|
8
11
|
def self.product_action(
|
|
@@ -17,12 +20,18 @@ module LogBrew
|
|
|
17
20
|
metadata: nil
|
|
18
21
|
)
|
|
19
22
|
action_metadata = timeline_metadata("product_timeline", metadata)
|
|
20
|
-
|
|
23
|
+
sanitized_route = sanitize_optional_route_template("product route_template", route_template)
|
|
24
|
+
sanitized_screen = optional_label("screen", screen)
|
|
25
|
+
put_if_present(action_metadata, "routeTemplate", sanitized_route)
|
|
21
26
|
put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
|
|
22
27
|
put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
|
|
23
|
-
put_if_present(action_metadata, "screen",
|
|
28
|
+
put_if_present(action_metadata, "screen", sanitized_screen)
|
|
24
29
|
put_if_present(action_metadata, "funnel", optional_label("funnel", funnel))
|
|
25
30
|
put_if_present(action_metadata, "step", optional_label("step", step))
|
|
31
|
+
action_metadata["analyticsSchemaVersion"] = PRODUCT_ANALYTICS_SCHEMA_VERSION
|
|
32
|
+
action_metadata["analyticsKind"] = "interaction"
|
|
33
|
+
surface = bounded_product_analytics_surface(sanitized_route || sanitized_screen)
|
|
34
|
+
surface.nil? ? action_metadata.delete("analyticsSurface") : action_metadata["analyticsSurface"] = surface
|
|
26
35
|
|
|
27
36
|
{
|
|
28
37
|
"name" => required_label("product action name", name),
|
|
@@ -146,8 +155,20 @@ module LogBrew
|
|
|
146
155
|
[first, second].min
|
|
147
156
|
end
|
|
148
157
|
|
|
158
|
+
def self.bounded_product_analytics_surface(surface)
|
|
159
|
+
return nil if surface.nil?
|
|
160
|
+
|
|
161
|
+
normalized = surface.to_s.strip
|
|
162
|
+
characters = normalized.each_codepoint.take(MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH + 1)
|
|
163
|
+
return nil if normalized.empty? || characters.length > MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH
|
|
164
|
+
return nil if characters.any? { |codepoint| codepoint <= 31 || (codepoint >= 127 && codepoint <= 159) }
|
|
165
|
+
|
|
166
|
+
normalized
|
|
167
|
+
end
|
|
168
|
+
|
|
149
169
|
private_class_method :timeline_metadata, :required_label, :optional_label, :normalize_status,
|
|
150
170
|
:sanitize_optional_route_template, :sanitize_route_template, :normalize_method,
|
|
151
|
-
:validate_status_code, :validate_duration_ms, :put_if_present, :first_present_index
|
|
171
|
+
:validate_status_code, :validate_duration_ms, :put_if_present, :first_present_index,
|
|
172
|
+
:bounded_product_analytics_surface
|
|
152
173
|
end
|
|
153
174
|
end
|