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.
@@ -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
- put_if_present(action_metadata, "routeTemplate", sanitize_optional_route_template("product route_template", route_template))
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", optional_label("screen", 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