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.
- checksums.yaml +4 -4
- data/README.md +228 -64
- data/examples/Makefile +5 -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/lib/logbrew/issue_diagnostics.rb +787 -0
- data/lib/logbrew/product_timeline.rb +24 -3
- data/lib/logbrew/rails_integration.rb +22 -1
- 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 +1 -1
- data/lib/logbrew.rb +158 -40
- metadata +10 -4
|
@@ -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
|
|
@@ -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
|