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.
@@ -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
@@ -353,10 +353,23 @@ module LogBrew
353
353
  sdk_version: LogBrew::VERSION,
354
354
  transport: transport,
355
355
  flush_interval: configuration.flush_interval,
356
- flush_threshold: configuration.flush_threshold
356
+ flush_threshold: configuration.flush_threshold,
357
+ context: client_context(configuration)
357
358
  )
358
359
  end
359
360
 
361
+ def client_context(configuration)
362
+ resource = LogBrew::TelemetryResource.create
363
+ .with_service(name: configuration.service_name)
364
+ .with_deployment(
365
+ environment: configuration.app_environment,
366
+ release: configuration.release
367
+ )
368
+ .with_framework(name: "rails", version: configuration.rails_version)
369
+ .build
370
+ LogBrew::TelemetryContext.create.with_resource(resource).build
371
+ end
372
+
360
373
  def record_process_context(created)
361
374
  timestamp = logbrew_timestamp
362
375
  metadata = base_metadata
@@ -398,6 +411,14 @@ module LogBrew
398
411
  class RailsRackMiddleware < LogBrew::RackMiddleware
399
412
  private
400
413
 
414
+ def exception_mechanism_type
415
+ "rails.middleware"
416
+ end
417
+
418
+ def exception_grouping_prefix
419
+ "rails-exception"
420
+ end
421
+
401
422
  def request_name(env)
402
423
  "#{request_method(env)} #{route_template(env)}"
403
424
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Idempotent owner for one active shared-context scope.
5
+ class TelemetryScope
6
+ def initialize(activation_id)
7
+ @activation_id = activation_id
8
+ @closed = false
9
+ end
10
+
11
+ def close
12
+ return if @closed
13
+
14
+ Telemetry.close_scope(@activation_id)
15
+ @closed = true
16
+ end
17
+ end
18
+
19
+ # Fiber/thread-local shared context for request, job, and operation boundaries.
20
+ module Telemetry
21
+ STACK_KEY = :logbrew_telemetry_context_stack
22
+ private_constant :STACK_KEY
23
+
24
+ module_function
25
+
26
+ def current_context
27
+ entry = stack.last
28
+ entry && entry[:context]
29
+ end
30
+
31
+ def activate_context(context)
32
+ unless context.is_a?(TelemetryContext)
33
+ raise TelemetryContextValue.invalid("context must be a LogBrew::TelemetryContext")
34
+ end
35
+
36
+ merged = TelemetryContext.merge(current_context, context)
37
+ activation_id = Object.new
38
+ stack << { id: activation_id, context: merged }
39
+ TelemetryScope.new(activation_id)
40
+ end
41
+
42
+ def with_context(context)
43
+ scope = activate_context(context)
44
+ yield context
45
+ ensure
46
+ scope.close if scope
47
+ end
48
+
49
+ def close_scope(activation_id)
50
+ entries = stack
51
+ index = entries.rindex { |entry| entry[:id].equal?(activation_id) }
52
+ entries.delete_at(index) unless index.nil?
53
+ end
54
+
55
+ def stack
56
+ Thread.current[STACK_KEY] ||= []
57
+ end
58
+ private_class_method :stack
59
+ end
60
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "rbconfig"
5
+
6
+ module LogBrew
7
+ # Immutable schema-v1 resource, trace, session, subject, and tag context.
8
+ class TelemetryContext
9
+ SCHEMA_VERSION = 1
10
+ ROOT_FIELDS = %w[schemaVersion resource trace session subject tags].freeze
11
+ TRACE_FIELDS = %w[traceId spanId parentSpanId sampled].freeze
12
+ SESSION_FIELDS = %w[id previousId].freeze
13
+ SUBJECT_FIELDS = %w[id kind].freeze
14
+
15
+ def self.create
16
+ TelemetryContextBuilder.new
17
+ end
18
+
19
+ def self.from_hash(value = nil, **keywords)
20
+ if value.nil?
21
+ value = keywords
22
+ elsif !keywords.empty?
23
+ raise TelemetryContextValue.invalid("telemetry context must be one object")
24
+ end
25
+ object = TelemetryContextValue.object(value, "telemetry context")
26
+ TelemetryContextValue.reject_unknown_fields(object, ROOT_FIELDS, "telemetry context")
27
+ unless object["schemaVersion"] == SCHEMA_VERSION
28
+ raise TelemetryContextValue.invalid("telemetry context schemaVersion must be 1")
29
+ end
30
+
31
+ normalized = { "schemaVersion" => SCHEMA_VERSION }
32
+ if object.key?("resource")
33
+ normalized["resource"] = TelemetryResource.from_hash(object.fetch("resource")).to_h
34
+ end
35
+ normalized["trace"] = normalize_trace(object.fetch("trace")) if object.key?("trace")
36
+ normalized["session"] = normalize_session(object.fetch("session")) if object.key?("session")
37
+ normalized["subject"] = normalize_subject(object.fetch("subject")) if object.key?("subject")
38
+ normalized["tags"] = normalize_tags(object.fetch("tags")) if object.key?("tags")
39
+ if normalized.length == 1
40
+ raise TelemetryContextValue.invalid(
41
+ "telemetry context must include resource, trace, session, subject, or tags"
42
+ )
43
+ end
44
+
45
+ new(normalized)
46
+ end
47
+
48
+ # Resource fields and tags merge by field. Trace, session, and subject are replaced.
49
+ def self.merge(base, override)
50
+ require_context_or_nil(base, "base telemetry context")
51
+ require_context_or_nil(override, "override telemetry context")
52
+ return nil if base.nil? && override.nil?
53
+ return from_hash(override.to_h) if base.nil?
54
+ return from_hash(base.to_h) if override.nil?
55
+
56
+ base_value = base.to_h
57
+ override_value = override.to_h
58
+ merged = { "schemaVersion" => SCHEMA_VERSION }
59
+
60
+ base_resource = resource_from_value(base_value["resource"])
61
+ override_resource = resource_from_value(override_value["resource"])
62
+ resource = TelemetryResource.merge(base_resource, override_resource)
63
+ merged["resource"] = resource.to_h unless resource.nil?
64
+
65
+ %w[trace session subject].each do |section|
66
+ value = override_value[section] || base_value[section]
67
+ merged[section] = value unless value.nil?
68
+ end
69
+
70
+ base_tags = base_value["tags"] || {}
71
+ override_tags = override_value["tags"] || {}
72
+ unless base_tags.empty? && override_tags.empty?
73
+ tags = base_tags.merge(override_tags)
74
+ TelemetryContextValue.require_tag_count(tags.length)
75
+ merged["tags"] = tags.keys.sort.each_with_object({}) { |key, sorted| sorted[key] = tags.fetch(key) }
76
+ end
77
+
78
+ from_hash(merged)
79
+ end
80
+
81
+ # Add exact active trace correlation over any existing context.
82
+ def self.with_trace(context, trace)
83
+ require_context_or_nil(context, "telemetry context")
84
+ unless defined?(LogBrew::TraceContext) && trace.is_a?(LogBrew::TraceContext)
85
+ raise TelemetryContextValue.invalid("trace must be a LogBrew::TraceContext")
86
+ end
87
+
88
+ trace_context = create.with_trace(trace).build
89
+ merge(context, trace_context)
90
+ end
91
+
92
+ # Conservative Ruby runtime, OS-family/release, and architecture identity.
93
+ def self.runtime_defaults
94
+ runtime_name = defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
95
+ resource = TelemetryResource.create.with_runtime(name: runtime_name, version: RUBY_VERSION)
96
+ uname = safe_uname
97
+ operating_system = normalize_os_name(
98
+ safe_runtime_value(uname && uname[:sysname]) ||
99
+ safe_runtime_value(RbConfig::CONFIG["host_os"])
100
+ )
101
+ operating_system_version = safe_runtime_value(uname && uname[:release])
102
+ architecture = safe_runtime_value(uname && uname[:machine]) ||
103
+ safe_runtime_value(RbConfig::CONFIG["host_cpu"])
104
+ unless operating_system.nil?
105
+ resource.with_operating_system(name: operating_system, version: operating_system_version)
106
+ end
107
+ resource.with_device(architecture: architecture) unless architecture.nil?
108
+ create.with_resource(resource.build).build
109
+ end
110
+
111
+ def to_h
112
+ TelemetryContextValue.deep_copy(@value)
113
+ end
114
+
115
+ class << self
116
+ private
117
+
118
+ def normalize_trace(value)
119
+ object = TelemetryContextValue.object(value, "telemetry context trace")
120
+ TelemetryContextValue.reject_unknown_fields(object, TRACE_FIELDS, "telemetry context trace")
121
+ unless object.key?("traceId")
122
+ raise TelemetryContextValue.invalid("traceId must be 32 non-zero hex characters")
123
+ end
124
+
125
+ normalized = {
126
+ "traceId" => TelemetryContextValue.trace_id(object.fetch("traceId"))
127
+ }
128
+ if object.key?("spanId")
129
+ normalized["spanId"] = TelemetryContextValue.span_id(object.fetch("spanId"), "spanId")
130
+ end
131
+ if object.key?("parentSpanId")
132
+ normalized["parentSpanId"] = TelemetryContextValue.span_id(
133
+ object.fetch("parentSpanId"),
134
+ "parentSpanId"
135
+ )
136
+ end
137
+ if object.key?("sampled")
138
+ sampled = object.fetch("sampled")
139
+ unless sampled == true || sampled == false
140
+ raise TelemetryContextValue.invalid("sampled must be a boolean")
141
+ end
142
+ normalized["sampled"] = sampled
143
+ end
144
+ normalized
145
+ end
146
+
147
+ def normalize_session(value)
148
+ object = TelemetryContextValue.object(value, "telemetry context session")
149
+ TelemetryContextValue.reject_unknown_fields(object, SESSION_FIELDS, "telemetry context session")
150
+ unless object.key?("id")
151
+ raise TelemetryContextValue.invalid("session id must be a string")
152
+ end
153
+ id = TelemetryContextValue.required_id(object.fetch("id"), "session id")
154
+ normalized = { "id" => id }
155
+ if object.key?("previousId")
156
+ previous_id = TelemetryContextValue.required_id(
157
+ object.fetch("previousId"),
158
+ "session previousId"
159
+ )
160
+ if previous_id == id
161
+ raise TelemetryContextValue.invalid("session previousId must differ from id")
162
+ end
163
+ normalized["previousId"] = previous_id
164
+ end
165
+ normalized
166
+ end
167
+
168
+ def normalize_subject(value)
169
+ object = TelemetryContextValue.object(value, "telemetry context subject")
170
+ TelemetryContextValue.reject_unknown_fields(object, SUBJECT_FIELDS, "telemetry context subject")
171
+ unless object.key?("id")
172
+ raise TelemetryContextValue.invalid("subject id must be a string")
173
+ end
174
+ kind = object["kind"]
175
+ unless %w[anonymous user].include?(kind)
176
+ raise TelemetryContextValue.invalid("subject kind must be anonymous or user")
177
+ end
178
+ {
179
+ "id" => TelemetryContextValue.required_id(object.fetch("id"), "subject id"),
180
+ "kind" => kind
181
+ }
182
+ end
183
+
184
+ def normalize_tags(value)
185
+ object = TelemetryContextValue.object(value, "telemetry context tags")
186
+ TelemetryContextValue.require_tag_count(object.length)
187
+ object.keys.sort.each_with_object({}) do |key, normalized|
188
+ normalized_key = TelemetryContextValue.tag_key(key)
189
+ normalized[normalized_key] = TelemetryContextValue.required_string(
190
+ object.fetch(key),
191
+ "tag value for #{normalized_key}"
192
+ )
193
+ end
194
+ end
195
+
196
+ def resource_from_value(value)
197
+ value.nil? ? nil : TelemetryResource.from_hash(value)
198
+ end
199
+
200
+ def require_context_or_nil(value, label)
201
+ return if value.nil? || value.is_a?(TelemetryContext)
202
+
203
+ raise TelemetryContextValue.invalid("#{label} must be a LogBrew::TelemetryContext")
204
+ end
205
+
206
+ def safe_uname
207
+ Etc.uname
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ def safe_runtime_value(value)
213
+ return nil if value.nil?
214
+
215
+ TelemetryContextValue.required_string(value.to_s, "runtime context value")
216
+ rescue StandardError
217
+ nil
218
+ end
219
+
220
+ def normalize_os_name(value)
221
+ return nil if value.nil?
222
+
223
+ normalized = value.downcase
224
+ return "darwin" if normalized.include?("darwin") || normalized.include?("mac os")
225
+ return "linux" if normalized.include?("linux")
226
+ return "windows" if normalized.match?(/windows|mswin|mingw|cygwin/)
227
+
228
+ normalized
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ def initialize(value)
235
+ @value = TelemetryContextValue.deep_freeze(TelemetryContextValue.deep_copy(value))
236
+ freeze
237
+ end
238
+ end
239
+
240
+ # Builder for one immutable, privacy-bounded shared telemetry context.
241
+ class TelemetryContextBuilder
242
+ def initialize
243
+ @value = { "schemaVersion" => TelemetryContext::SCHEMA_VERSION }
244
+ end
245
+
246
+ def with_resource(resource)
247
+ unless resource.is_a?(TelemetryResource)
248
+ raise TelemetryContextValue.invalid("resource must be a LogBrew::TelemetryResource")
249
+ end
250
+ @value["resource"] = resource.to_h
251
+ self
252
+ end
253
+
254
+ def with_trace(trace)
255
+ unless defined?(LogBrew::TraceContext) && trace.is_a?(LogBrew::TraceContext)
256
+ raise TelemetryContextValue.invalid("trace must be a LogBrew::TraceContext")
257
+ end
258
+ with_trace_ids(
259
+ trace_id: trace.trace_id,
260
+ span_id: trace.span_id,
261
+ parent_span_id: trace.parent_span_id,
262
+ sampled: trace.sampled
263
+ )
264
+ end
265
+
266
+ def with_trace_ids(trace_id:, span_id: nil, parent_span_id: nil, sampled: nil)
267
+ trace = { "traceId" => trace_id }
268
+ trace["spanId"] = span_id unless span_id.nil?
269
+ trace["parentSpanId"] = parent_span_id unless parent_span_id.nil?
270
+ trace["sampled"] = sampled unless sampled.nil?
271
+ @value["trace"] = trace
272
+ self
273
+ end
274
+
275
+ def with_session(id:, previous_id: nil)
276
+ session = { "id" => id }
277
+ session["previousId"] = previous_id unless previous_id.nil?
278
+ @value["session"] = session
279
+ self
280
+ end
281
+
282
+ def with_subject(id:, kind:)
283
+ @value["subject"] = { "id" => id, "kind" => kind }
284
+ self
285
+ end
286
+
287
+ def with_tag(key, value)
288
+ normalized_key = TelemetryContextValue.tag_key(key.to_s)
289
+ normalized_value = TelemetryContextValue.required_string(value, "tag value for #{normalized_key}")
290
+ tags = (@value["tags"] ||= {})
291
+ tags[normalized_key] = normalized_value
292
+ TelemetryContextValue.require_tag_count(tags.length)
293
+ self
294
+ end
295
+
296
+ def with_tags(tags)
297
+ object = TelemetryContextValue.object(tags, "telemetry context tags")
298
+ object.each { |key, value| with_tag(key, value) }
299
+ self
300
+ end
301
+
302
+ def build
303
+ TelemetryContext.from_hash(@value)
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Shared validation and detachment rules for schema-v1 telemetry context.
5
+ module TelemetryContextValue
6
+ MAX_CONTEXT_STRING = 256
7
+ MAX_CONTEXT_ID = 200
8
+ MAX_TAGS = 32
9
+ MAX_TAG_KEY = 64
10
+ TAG_KEY = /\A[A-Za-z][A-Za-z0-9_.-]*\z/.freeze
11
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/.freeze
12
+ LOWER_HEX = /\A[0-9a-f]+\z/.freeze
13
+
14
+ module_function
15
+
16
+ def object(value, label)
17
+ raise invalid("#{label} must be an object") unless value.is_a?(Hash)
18
+
19
+ value.each_with_object({}) do |(key, item), normalized|
20
+ unless key.is_a?(String) || key.is_a?(Symbol)
21
+ raise invalid("#{label} keys must be strings")
22
+ end
23
+
24
+ normalized_key = key.to_s
25
+ if normalized.key?(normalized_key)
26
+ raise invalid("#{label} contains duplicate field #{normalized_key}")
27
+ end
28
+ normalized[normalized_key] = item
29
+ end
30
+ end
31
+
32
+ def reject_unknown_fields(value, allowed, label)
33
+ unknown = value.keys - allowed
34
+ return if unknown.empty?
35
+
36
+ raise invalid("#{label} has unknown field #{unknown.sort.first}")
37
+ end
38
+
39
+ def required_string(value, label, maximum = MAX_CONTEXT_STRING)
40
+ raise invalid("#{label} must be a string") unless value.is_a?(String)
41
+
42
+ normalized = utf8_string(value, label).strip
43
+ raise invalid("#{label} must not be empty") if normalized.empty? || !normalized.match?(/\S/)
44
+ if normalized.length > maximum
45
+ raise invalid("#{label} must contain at most #{maximum} characters")
46
+ end
47
+ if normalized.match?(CONTROL_CHARACTERS)
48
+ raise invalid("#{label} must not contain control characters")
49
+ end
50
+
51
+ normalized
52
+ end
53
+
54
+ def optional_string(value, label, maximum = MAX_CONTEXT_STRING)
55
+ return nil if value.nil?
56
+
57
+ required_string(value, label, maximum)
58
+ end
59
+
60
+ def required_id(value, label)
61
+ required_string(value, label, MAX_CONTEXT_ID)
62
+ end
63
+
64
+ def optional_id(value, label)
65
+ return nil if value.nil?
66
+
67
+ required_id(value, label)
68
+ end
69
+
70
+ def trace_id(value, label = "traceId")
71
+ normalized_hex_id(value, 32, "0" * 32, label)
72
+ end
73
+
74
+ def span_id(value, label)
75
+ normalized_hex_id(value, 16, "0" * 16, label)
76
+ end
77
+
78
+ def optional_span_id(value, label)
79
+ return nil if value.nil?
80
+
81
+ span_id(value, label)
82
+ end
83
+
84
+ def tag_key(value)
85
+ raise invalid("tag key must be a string") unless value.is_a?(String)
86
+
87
+ normalized = utf8_string(value, "tag key")
88
+ unless normalized.length <= MAX_TAG_KEY && normalized.match?(TAG_KEY)
89
+ raise invalid("tag key #{normalized} must start with a letter and contain only letters, numbers, _, ., or -")
90
+ end
91
+
92
+ normalized
93
+ end
94
+
95
+ def require_tag_count(count)
96
+ unless count.between?(1, MAX_TAGS)
97
+ raise invalid("telemetry context must contain 1 to at most #{MAX_TAGS} tags")
98
+ end
99
+ end
100
+
101
+ def deep_copy(value)
102
+ case value
103
+ when Hash
104
+ value.each_with_object({}) { |(key, item), copy| copy[key.dup] = deep_copy(item) }
105
+ when Array
106
+ value.map { |item| deep_copy(item) }
107
+ when String
108
+ value.dup
109
+ else
110
+ value
111
+ end
112
+ end
113
+
114
+ def deep_freeze(value)
115
+ case value
116
+ when Hash
117
+ value.each do |key, item|
118
+ key.freeze
119
+ deep_freeze(item)
120
+ end
121
+ when Array
122
+ value.each { |item| deep_freeze(item) }
123
+ end
124
+ value.freeze
125
+ end
126
+
127
+ def invalid(message)
128
+ SdkError.new("validation_error", message)
129
+ end
130
+
131
+ def normalized_hex_id(value, width, all_zero, label)
132
+ raise invalid("#{label} must be #{width} non-zero hex characters") unless value.is_a?(String)
133
+
134
+ normalized = utf8_string(value, label).downcase
135
+ unless normalized.length == width && normalized.match?(LOWER_HEX) && normalized != all_zero
136
+ raise invalid("#{label} must be #{width} non-zero hex characters")
137
+ end
138
+ normalized
139
+ end
140
+ private_class_method :normalized_hex_id
141
+
142
+ def utf8_string(value, label)
143
+ normalized = value.encode(Encoding::UTF_8)
144
+ raise EncodingError unless normalized.valid_encoding?
145
+
146
+ normalized.dup
147
+ rescue EncodingError
148
+ raise invalid("#{label} must be valid UTF-8")
149
+ end
150
+ private_class_method :utf8_string
151
+ end
152
+ end