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,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
|
data/lib/logbrew-sdk.rb
ADDED