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,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
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Immutable service, deployment, runtime, framework, OS, device, and app identity.
5
+ class TelemetryResource
6
+ SECTION_FIELDS = {
7
+ "service" => %w[name version].freeze,
8
+ "deployment" => %w[environment release].freeze,
9
+ "runtime" => %w[name version].freeze,
10
+ "framework" => %w[name version].freeze,
11
+ "operatingSystem" => %w[name version build].freeze,
12
+ "device" => %w[family model architecture].freeze,
13
+ "application" => %w[name version build].freeze
14
+ }.freeze
15
+ NAME_REQUIRED_SECTIONS = %w[service runtime framework operatingSystem].freeze
16
+
17
+ def self.create
18
+ TelemetryResourceBuilder.new
19
+ end
20
+
21
+ def self.from_hash(value = nil, **keywords)
22
+ if value.nil?
23
+ value = keywords
24
+ elsif !keywords.empty?
25
+ raise TelemetryContextValue.invalid("telemetry resource must be one object")
26
+ end
27
+ object = TelemetryContextValue.object(value, "telemetry resource")
28
+ TelemetryContextValue.reject_unknown_fields(object, SECTION_FIELDS.keys, "telemetry resource")
29
+ raise TelemetryContextValue.invalid("telemetry resource must not be empty") if object.empty?
30
+
31
+ normalized = {}
32
+ SECTION_FIELDS.each do |section_name, fields|
33
+ next unless object.key?(section_name)
34
+
35
+ section = TelemetryContextValue.object(
36
+ object.fetch(section_name),
37
+ "telemetry resource #{section_name}"
38
+ )
39
+ TelemetryContextValue.reject_unknown_fields(
40
+ section,
41
+ fields,
42
+ "telemetry resource #{section_name}"
43
+ )
44
+ normalized_section = {}
45
+ fields.each do |field|
46
+ next unless section.key?(field)
47
+
48
+ normalized_section[field] = TelemetryContextValue.required_string(
49
+ section.fetch(field),
50
+ "telemetry resource #{section_name} #{field}"
51
+ )
52
+ end
53
+ if NAME_REQUIRED_SECTIONS.include?(section_name) && !normalized_section.key?("name")
54
+ raise TelemetryContextValue.invalid("telemetry resource #{section_name} name is required")
55
+ end
56
+ if normalized_section.empty?
57
+ raise TelemetryContextValue.invalid("telemetry resource #{section_name} must not be empty")
58
+ end
59
+ normalized[section_name] = normalized_section
60
+ end
61
+
62
+ new(normalized)
63
+ end
64
+
65
+ def self.merge(base, override)
66
+ require_resource_or_nil(base, "base telemetry resource")
67
+ require_resource_or_nil(override, "override telemetry resource")
68
+ return nil if base.nil? && override.nil?
69
+ return from_hash(override.to_h) if base.nil?
70
+ return from_hash(base.to_h) if override.nil?
71
+
72
+ base_value = base.to_h
73
+ override_value = override.to_h
74
+ merged = {}
75
+ SECTION_FIELDS.each_key do |section|
76
+ base_section = base_value[section]
77
+ override_section = override_value[section]
78
+ next if base_section.nil? && override_section.nil?
79
+
80
+ merged[section] = (base_section || {}).merge(override_section || {})
81
+ end
82
+ from_hash(merged)
83
+ end
84
+
85
+ def to_h
86
+ TelemetryContextValue.deep_copy(@value)
87
+ end
88
+
89
+ private
90
+
91
+ def initialize(value)
92
+ @value = TelemetryContextValue.deep_freeze(TelemetryContextValue.deep_copy(value))
93
+ freeze
94
+ end
95
+
96
+ def self.require_resource_or_nil(value, label)
97
+ return if value.nil? || value.is_a?(TelemetryResource)
98
+
99
+ raise TelemetryContextValue.invalid("#{label} must be a LogBrew::TelemetryResource")
100
+ end
101
+ private_class_method :require_resource_or_nil
102
+ end
103
+
104
+ # Builder for one immutable telemetry resource.
105
+ class TelemetryResourceBuilder
106
+ def initialize
107
+ @value = {}
108
+ end
109
+
110
+ def with_service(name:, version: nil)
111
+ @value["service"] = named_version(name, version)
112
+ self
113
+ end
114
+
115
+ def with_deployment(environment: nil, release: nil)
116
+ @value["deployment"] = optional_fields(environment: environment, release: release)
117
+ self
118
+ end
119
+
120
+ def with_runtime(name:, version: nil)
121
+ @value["runtime"] = named_version(name, version)
122
+ self
123
+ end
124
+
125
+ def with_framework(name:, version: nil)
126
+ @value["framework"] = named_version(name, version)
127
+ self
128
+ end
129
+
130
+ def with_operating_system(name:, version: nil, build: nil)
131
+ @value["operatingSystem"] = optional_fields(name: name, version: version, build: build)
132
+ self
133
+ end
134
+
135
+ def with_device(family: nil, model: nil, architecture: nil)
136
+ @value["device"] = optional_fields(family: family, model: model, architecture: architecture)
137
+ self
138
+ end
139
+
140
+ def with_application(name: nil, version: nil, build: nil)
141
+ @value["application"] = optional_fields(name: name, version: version, build: build)
142
+ self
143
+ end
144
+
145
+ def build
146
+ TelemetryResource.from_hash(@value)
147
+ end
148
+
149
+ private
150
+
151
+ def named_version(name, version)
152
+ optional_fields(name: name, version: version)
153
+ end
154
+
155
+ def optional_fields(**values)
156
+ values.each_with_object({}) do |(key, value), section|
157
+ section[key.to_s] = value unless value.nil?
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ VERSION = "0.1.4"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logbrew"
4
+ require "logbrew/rails" if defined?(::Rails::Railtie)