logbrew-sdk 0.1.0 → 0.1.1

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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LogBrew
6
+ TraceContext = Struct.new(:trace_id, :span_id, :parent_span_id, :trace_flags, :sampled, keyword_init: true)
7
+
8
+ class TraceScope
9
+ def initialize(scope_id)
10
+ @scope_id = scope_id
11
+ @closed = false
12
+ end
13
+
14
+ def close
15
+ return if @closed
16
+
17
+ LogBrew::Trace.close_scope(@scope_id)
18
+ @closed = true
19
+ end
20
+ end
21
+
22
+ # Request-local trace context for app-owned logs, errors, actions, and metrics.
23
+ module Trace
24
+ STACK_KEY = :logbrew_trace_stack
25
+ private_constant :STACK_KEY
26
+
27
+ module_function
28
+
29
+ def current
30
+ entry = stack.last
31
+ entry && entry[:context]
32
+ end
33
+
34
+ def activate(context)
35
+ unless context.is_a?(TraceContext)
36
+ raise SdkError.new("validation_error", "trace context must be a LogBrew::TraceContext")
37
+ end
38
+
39
+ scope_id = Object.new.object_id
40
+ stack << { id: scope_id, context: context }
41
+ TraceScope.new(scope_id)
42
+ end
43
+
44
+ def with_context(context)
45
+ scope = activate(context)
46
+ yield context
47
+ ensure
48
+ scope.close if scope
49
+ end
50
+
51
+ def close_scope(scope_id)
52
+ entries = stack
53
+ index = entries.rindex { |entry| entry[:id] == scope_id }
54
+ entries.delete_at(index) unless index.nil?
55
+ end
56
+
57
+ def continue_or_create(traceparent)
58
+ text = traceparent.to_s.strip
59
+ return create_root if text.empty?
60
+
61
+ from_traceparent(text)
62
+ rescue SdkError
63
+ create_root
64
+ end
65
+
66
+ def from_traceparent(traceparent)
67
+ context = Traceparent.parse(traceparent)
68
+ create(
69
+ trace_id: context.trace_id,
70
+ span_id: generate_span_id,
71
+ parent_span_id: context.parent_span_id,
72
+ trace_flags: context.trace_flags
73
+ )
74
+ end
75
+
76
+ def create_root
77
+ create(trace_id: generate_trace_id, span_id: generate_span_id, trace_flags: "01")
78
+ end
79
+
80
+ def create(trace_id:, span_id:, trace_flags: "01", parent_span_id: nil)
81
+ normalized_traceparent = Traceparent.create(trace_id: trace_id, span_id: span_id, trace_flags: trace_flags)
82
+ _version, normalized_trace_id, normalized_span_id, normalized_flags = normalized_traceparent.split("-")
83
+ normalized_parent_span_id = nil
84
+ unless parent_span_id.nil?
85
+ parent_traceparent = Traceparent.create(
86
+ trace_id: normalized_trace_id,
87
+ span_id: parent_span_id,
88
+ trace_flags: normalized_flags
89
+ )
90
+ normalized_parent_span_id = parent_traceparent.split("-")[2]
91
+ end
92
+
93
+ TraceContext.new(
94
+ trace_id: normalized_trace_id,
95
+ span_id: normalized_span_id,
96
+ parent_span_id: normalized_parent_span_id,
97
+ trace_flags: normalized_flags,
98
+ sampled: (normalized_flags.to_i(16) & 1) == 1
99
+ )
100
+ end
101
+
102
+ def create_headers(context = current)
103
+ return {} unless context
104
+
105
+ Traceparent.create_headers(
106
+ trace_id: context.trace_id,
107
+ span_id: context.span_id,
108
+ trace_flags: context.trace_flags
109
+ )
110
+ end
111
+
112
+ def metadata(context = current)
113
+ return {} unless context
114
+
115
+ {
116
+ "traceId" => context.trace_id,
117
+ "spanId" => context.span_id,
118
+ "traceFlags" => context.trace_flags,
119
+ "traceSampled" => context.sampled
120
+ }.tap do |payload|
121
+ payload["parentSpanId"] = context.parent_span_id unless context.parent_span_id.nil?
122
+ end
123
+ end
124
+
125
+ def add_metadata(target, context = current)
126
+ return target unless context
127
+
128
+ metadata(context).each do |key, value|
129
+ target[key] = value unless target.key?(key) || target.key?(key.to_sym)
130
+ end
131
+ target
132
+ end
133
+
134
+ def merge_attributes(attributes, context = current)
135
+ return attributes unless context && attributes.is_a?(Hash)
136
+
137
+ metadata_value = attributes.key?("metadata") ? attributes["metadata"] : attributes[:metadata]
138
+ return attributes unless metadata_value.nil? || metadata_value.is_a?(Hash)
139
+
140
+ copied = attributes.dup
141
+ merged_metadata = metadata_value.nil? ? {} : metadata_value.dup
142
+ add_metadata(merged_metadata, context)
143
+ if copied.key?(:metadata) && !copied.key?("metadata")
144
+ copied[:metadata] = merged_metadata
145
+ else
146
+ copied["metadata"] = merged_metadata
147
+ end
148
+ copied
149
+ end
150
+
151
+ def from_rack_env(env)
152
+ existing = env_value(env, "logbrew.trace")
153
+ return existing if existing.is_a?(TraceContext)
154
+
155
+ traceparent = env_value(env, "HTTP_TRACEPARENT") || env_value(env, "traceparent") || env_value(env, "logbrew.traceparent")
156
+ return continue_or_create(traceparent) unless traceparent.nil? || traceparent.empty?
157
+
158
+ trace_id = env_value(env, "logbrew.trace_id")
159
+ span_id = env_value(env, "logbrew.span_id")
160
+ if trace_id && span_id
161
+ begin
162
+ return create(
163
+ trace_id: trace_id,
164
+ span_id: span_id,
165
+ parent_span_id: env_value(env, "logbrew.parent_span_id"),
166
+ trace_flags: env_value(env, "logbrew.trace_flags") || "01"
167
+ )
168
+ rescue SdkError
169
+ nil
170
+ end
171
+ end
172
+
173
+ create_root
174
+ end
175
+
176
+ def generate_trace_id
177
+ loop do
178
+ value = SecureRandom.hex(16)
179
+ return value unless value.delete("0").empty?
180
+ end
181
+ end
182
+
183
+ def generate_span_id
184
+ loop do
185
+ value = SecureRandom.hex(8)
186
+ return value unless value.delete("0").empty?
187
+ end
188
+ end
189
+
190
+ def stack
191
+ Thread.current[STACK_KEY] ||= []
192
+ end
193
+ private_class_method :stack
194
+
195
+ def env_value(env, key)
196
+ return nil unless env.respond_to?(:[])
197
+
198
+ value = env[key]
199
+ return value if value.is_a?(TraceContext)
200
+ return nil if value.nil?
201
+
202
+ text = value.to_s
203
+ text.empty? ? nil : text
204
+ end
205
+ private_class_method :env_value
206
+ end
207
+
208
+ module TraceClientMethods
209
+ def issue(id, timestamp, attributes)
210
+ super(id, timestamp, Trace.merge_attributes(attributes))
211
+ end
212
+
213
+ def log(id, timestamp, attributes)
214
+ super(id, timestamp, Trace.merge_attributes(attributes))
215
+ end
216
+
217
+ def metric(id, timestamp, attributes)
218
+ super(id, timestamp, Trace.merge_attributes(attributes))
219
+ end
220
+
221
+ def action(id, timestamp, attributes)
222
+ super(id, timestamp, Trace.merge_attributes(attributes))
223
+ end
224
+ end
225
+
226
+ module TraceLoggerMethods
227
+ private
228
+
229
+ def logbrew_metadata(severity, message, progname)
230
+ Trace.add_metadata(super)
231
+ end
232
+ end
233
+
234
+ module TraceRackMiddlewareMethods
235
+ def call(env)
236
+ trace_context = Trace.from_rack_env(env)
237
+ env["logbrew.trace"] = trace_context if env.respond_to?(:[]=)
238
+ Trace.with_context(trace_context) { super(env) }
239
+ end
240
+
241
+ private
242
+
243
+ def capture_request_span(env, status_code, elapsed_ms, status)
244
+ context = rack_trace_context(env)
245
+ attributes = {
246
+ name: request_name(env),
247
+ traceId: context ? context.trace_id : trace_id(env),
248
+ spanId: context ? context.span_id : span_id(env),
249
+ status: status,
250
+ durationMs: elapsed_ms,
251
+ metadata: request_metadata(env, status_code)
252
+ }
253
+ attributes[:parentSpanId] = context.parent_span_id if context && context.parent_span_id
254
+
255
+ @client.span(next_event_id("span"), logbrew_timestamp, attributes)
256
+ end
257
+
258
+ def trace_id(env)
259
+ context = rack_trace_context(env)
260
+ return context.trace_id if context
261
+
262
+ super
263
+ end
264
+
265
+ def span_id(env)
266
+ context = rack_trace_context(env)
267
+ return context.span_id if context
268
+
269
+ super
270
+ end
271
+
272
+ def request_metadata(env, status_code)
273
+ Trace.add_metadata(super, rack_trace_context(env))
274
+ end
275
+
276
+ def exception_metadata(env, error)
277
+ Trace.add_metadata(super, rack_trace_context(env))
278
+ end
279
+
280
+ def rack_trace_context(env)
281
+ trace = env["logbrew.trace"] if env.respond_to?(:[])
282
+ trace.is_a?(TraceContext) ? trace : Trace.current
283
+ end
284
+ end
285
+
286
+ module TraceRailsErrorSubscriberMethods
287
+ private
288
+
289
+ def rails_metadata(error, handled, severity, context, source)
290
+ Trace.add_metadata(super)
291
+ end
292
+ end
293
+
294
+ Client.prepend(TraceClientMethods)
295
+ Logger.prepend(TraceLoggerMethods)
296
+ RackMiddleware.prepend(TraceRackMiddlewareMethods)
297
+ RailsErrorSubscriber.prepend(TraceRailsErrorSubscriberMethods)
298
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ TraceparentContext = Struct.new(:version, :trace_id, :parent_span_id, :trace_flags, :sampled, keyword_init: true)
5
+ TraceparentSpanInput = Struct.new(:name, :span_id, :status, :duration_ms, :metadata, keyword_init: true) do
6
+ def initialize(name:, span_id:, status: "ok", duration_ms: nil, metadata: nil)
7
+ super(
8
+ name: name,
9
+ span_id: span_id,
10
+ status: status,
11
+ duration_ms: duration_ms,
12
+ metadata: metadata
13
+ )
14
+ end
15
+ end
16
+
17
+ # Dependency-free W3C traceparent helpers for explicit app-owned propagation.
18
+ module Traceparent
19
+ VERSION = "00"
20
+ private_constant :VERSION
21
+
22
+ module_function
23
+
24
+ def parse(traceparent)
25
+ Validation.require_non_empty("traceparent", traceparent)
26
+ parts = traceparent.to_s.strip.downcase.split("-")
27
+ raise SdkError.new("validation_error", "traceparent must have four fields") unless parts.length == 4
28
+
29
+ version, trace_id, parent_span_id, trace_flags = parts
30
+ require_version(version)
31
+ require_trace_id(trace_id)
32
+ require_span_id("traceparent parent span id", parent_span_id)
33
+ flags = normalize_trace_flags(trace_flags)
34
+
35
+ TraceparentContext.new(
36
+ version: version,
37
+ trace_id: trace_id,
38
+ parent_span_id: parent_span_id,
39
+ trace_flags: flags,
40
+ sampled: (flags.to_i(16) & 1) == 1
41
+ )
42
+ end
43
+
44
+ def create(trace_id:, span_id:, trace_flags: "01")
45
+ normalized_trace_id = normalize_trace_id(trace_id)
46
+ normalized_span_id = normalize_span_id("traceparent span id", span_id)
47
+ flags = normalize_trace_flags(trace_flags)
48
+
49
+ "#{VERSION}-#{normalized_trace_id}-#{normalized_span_id}-#{flags}"
50
+ end
51
+
52
+ def create_headers(trace_id:, span_id:, trace_flags: "01")
53
+ { "traceparent" => create(trace_id: trace_id, span_id: span_id, trace_flags: trace_flags) }
54
+ end
55
+
56
+ def span_attributes_from_traceparent(traceparent, input)
57
+ context = traceparent.is_a?(TraceparentContext) ? traceparent : parse(traceparent)
58
+ attributes = {
59
+ "name" => required_name(input.name),
60
+ "traceId" => context.trace_id,
61
+ "spanId" => normalize_span_id("span spanId", input.span_id),
62
+ "parentSpanId" => context.parent_span_id,
63
+ "status" => normalize_status(input.status)
64
+ }
65
+
66
+ unless input.duration_ms.nil?
67
+ duration_ms = Validation.require_finite_number("span durationMs", input.duration_ms)
68
+ raise SdkError.new("validation_error", "span durationMs must be non-negative") if duration_ms.negative?
69
+
70
+ attributes["durationMs"] = duration_ms
71
+ end
72
+
73
+ metadata = Validation.require_metadata(input.metadata)
74
+ attributes["metadata"] = metadata unless metadata.nil?
75
+ attributes
76
+ end
77
+
78
+ def require_version(version)
79
+ unless version.length == 2 && lower_hex?(version) && version != "ff"
80
+ raise SdkError.new("validation_error", "traceparent version must be two hex characters and not ff")
81
+ end
82
+ end
83
+ private_class_method :require_version
84
+
85
+ def normalize_trace_id(trace_id)
86
+ normalized = trace_id.to_s.strip.downcase
87
+ require_trace_id(normalized)
88
+ normalized
89
+ end
90
+ private_class_method :normalize_trace_id
91
+
92
+ def require_trace_id(trace_id)
93
+ unless trace_id.length == 32 && lower_hex?(trace_id) && !all_zero?(trace_id)
94
+ raise SdkError.new("validation_error", "traceparent trace id must be 32 non-zero hex characters")
95
+ end
96
+ end
97
+ private_class_method :require_trace_id
98
+
99
+ def normalize_span_id(label, span_id)
100
+ normalized = span_id.to_s.strip.downcase
101
+ require_span_id(label, normalized)
102
+ normalized
103
+ end
104
+ private_class_method :normalize_span_id
105
+
106
+ def require_span_id(label, span_id)
107
+ unless span_id.length == 16 && lower_hex?(span_id) && !all_zero?(span_id)
108
+ raise SdkError.new("validation_error", "#{label} must be 16 non-zero hex characters")
109
+ end
110
+ end
111
+ private_class_method :require_span_id
112
+
113
+ def normalize_trace_flags(trace_flags)
114
+ normalized = trace_flags.to_s.strip.downcase
115
+ unless normalized.length == 2 && lower_hex?(normalized)
116
+ raise SdkError.new("validation_error", "traceparent flags must be two hex characters")
117
+ end
118
+
119
+ normalized
120
+ end
121
+ private_class_method :normalize_trace_flags
122
+
123
+ def required_name(name)
124
+ Validation.require_non_empty("span name", name)
125
+ name.to_s.strip
126
+ end
127
+ private_class_method :required_name
128
+
129
+ def normalize_status(status)
130
+ Validation.require_allowed_value("span status", status, LogBrew::SPAN_STATUSES)
131
+ status
132
+ end
133
+ private_class_method :normalize_status
134
+
135
+ def lower_hex?(value)
136
+ value.match?(/\A[0-9a-f]+\z/)
137
+ end
138
+ private_class_method :lower_hex?
139
+
140
+ def all_zero?(value)
141
+ value.delete("0").empty?
142
+ end
143
+ private_class_method :all_zero?
144
+ end
145
+ end
data/lib/logbrew.rb CHANGED
@@ -9,10 +9,26 @@ require "timeout"
9
9
  require "uri"
10
10
 
11
11
  module LogBrew
12
- ISSUE_LEVELS = %w[info warning error critical].freeze
13
- LOG_LEVELS = %w[debug info warning error].freeze
12
+ SEVERITY_VALUES = %w[trace debug info warn warning error fatal critical].freeze
13
+ SEVERITY_ALIASES = {
14
+ "trace" => "info",
15
+ "debug" => "info",
16
+ "info" => "info",
17
+ "warn" => "warning",
18
+ "warning" => "warning",
19
+ "error" => "error",
20
+ "fatal" => "critical",
21
+ "critical" => "critical"
22
+ }.freeze
14
23
  SPAN_STATUSES = %w[ok error].freeze
15
24
  ACTION_STATUSES = %w[queued running success failure].freeze
25
+ METRIC_TEMPORALITIES_BY_KIND = {
26
+ "counter" => %w[delta cumulative].freeze,
27
+ "gauge" => %w[instant].freeze,
28
+ "histogram" => %w[delta cumulative].freeze
29
+ }.freeze
30
+ METRIC_KINDS = METRIC_TEMPORALITIES_BY_KIND.keys.freeze
31
+ NON_NEGATIVE_METRIC_KINDS = %w[counter histogram].freeze
16
32
 
17
33
  class SdkError < StandardError
18
34
  attr_reader :code
@@ -158,11 +174,11 @@ module LogBrew
158
174
  class Logger < ::Logger
159
175
  DEFAULT_LOGGER_NAME = "ruby-logger"
160
176
  SEVERITY_TO_LOGBREW_LEVEL = {
161
- ::Logger::DEBUG => "debug",
177
+ ::Logger::DEBUG => "info",
162
178
  ::Logger::INFO => "info",
163
179
  ::Logger::WARN => "warning",
164
180
  ::Logger::ERROR => "error",
165
- ::Logger::FATAL => "error",
181
+ ::Logger::FATAL => "critical",
166
182
  ::Logger::UNKNOWN => "error"
167
183
  }.freeze
168
184
 
@@ -275,7 +291,7 @@ module LogBrew
275
291
  end
276
292
 
277
293
  def logbrew_level(severity)
278
- SEVERITY_TO_LOGBREW_LEVEL.fetch(severity.to_i, severity.to_i >= ::Logger::WARN ? "error" : "info")
294
+ SEVERITY_TO_LOGBREW_LEVEL.fetch(severity.to_i, severity.to_i >= ::Logger::FATAL ? "critical" : "info")
279
295
  end
280
296
 
281
297
  def event_logger_name(progname)
@@ -314,7 +330,6 @@ module LogBrew
314
330
  value.is_a?(Float) && value.finite?
315
331
  end
316
332
  end
317
-
318
333
  # Rack-compatible middleware for Rails, Sinatra, and other Rack-based Ruby apps.
319
334
  #
320
335
  # The middleware captures completed requests as span events and unhandled app
@@ -699,6 +714,15 @@ module LogBrew
699
714
  end
700
715
  end
701
716
 
717
+ def require_finite_number(label, value)
718
+ unless value.is_a?(Integer) || value.is_a?(Float)
719
+ raise SdkError.new("validation_error", "#{label} must be a finite number")
720
+ end
721
+ raise SdkError.new("validation_error", "#{label} must be a finite number") if numeric_nan?(value)
722
+
723
+ value
724
+ end
725
+
702
726
  def read(attributes, key)
703
727
  return nil unless attributes.is_a?(Hash)
704
728
 
@@ -764,6 +788,10 @@ module LogBrew
764
788
  push_event("span", id, timestamp, validate_span(attributes))
765
789
  end
766
790
 
791
+ def metric(id, timestamp, attributes)
792
+ push_event("metric", id, timestamp, validate_metric(attributes))
793
+ end
794
+
767
795
  def action(id, timestamp, attributes)
768
796
  push_event("action", id, timestamp, validate_action(attributes))
769
797
  end
@@ -858,7 +886,7 @@ module LogBrew
858
886
  title = Validation.read(attributes, "title")
859
887
  level = Validation.read(attributes, "level")
860
888
  Validation.require_non_empty("issue title", title)
861
- Validation.require_allowed_value("issue level", level, ISSUE_LEVELS)
889
+ level = normalize_severity("issue level", level)
862
890
  with_metadata(
863
891
  {
864
892
  "title" => title,
@@ -875,7 +903,7 @@ module LogBrew
875
903
  message = Validation.read(attributes, "message")
876
904
  level = Validation.read(attributes, "level")
877
905
  Validation.require_non_empty("log message", message)
878
- Validation.require_allowed_value("log level", level, LOG_LEVELS)
906
+ level = normalize_severity("log level", level)
879
907
  with_metadata(
880
908
  {
881
909
  "message" => message,
@@ -888,6 +916,11 @@ module LogBrew
888
916
  )
889
917
  end
890
918
 
919
+ def normalize_severity(label, level)
920
+ Validation.require_allowed_value(label, level, SEVERITY_VALUES)
921
+ SEVERITY_ALIASES.fetch(level)
922
+ end
923
+
891
924
  def validate_span(attributes)
892
925
  name = Validation.read(attributes, "name")
893
926
  trace_id = Validation.read(attributes, "traceId")
@@ -919,6 +952,32 @@ module LogBrew
919
952
  )
920
953
  end
921
954
 
955
+ def validate_metric(attributes)
956
+ name = Validation.read(attributes, "name")
957
+ kind = Validation.read(attributes, "kind")
958
+ unit = Validation.read(attributes, "unit")
959
+ temporality = Validation.read(attributes, "temporality")
960
+ Validation.require_non_empty("metric name", name)
961
+ Validation.require_allowed_value("metric kind", kind, METRIC_KINDS)
962
+ value = Validation.require_finite_number("metric value", Validation.read(attributes, "value"))
963
+ Validation.require_non_empty("metric unit", unit)
964
+ Validation.require_allowed_value("metric temporality for #{kind}", temporality, METRIC_TEMPORALITIES_BY_KIND[kind])
965
+ if NON_NEGATIVE_METRIC_KINDS.include?(kind) && value.negative?
966
+ raise SdkError.new("validation_error", "metric #{kind} value must be non-negative")
967
+ end
968
+
969
+ with_metadata(
970
+ {
971
+ "name" => name,
972
+ "kind" => kind,
973
+ "value" => value,
974
+ "unit" => unit,
975
+ "temporality" => temporality
976
+ },
977
+ attributes
978
+ )
979
+ end
980
+
922
981
  def validate_action(attributes)
923
982
  name = Validation.read(attributes, "name")
924
983
  status = Validation.read(attributes, "status")
@@ -934,3 +993,7 @@ module LogBrew
934
993
  end
935
994
  end
936
995
  end
996
+
997
+ %w[product_timeline traceparent trace operation_tracing support_ticket].each do |path|
998
+ require_relative "logbrew/#{path}"
999
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logbrew-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - LogBrew
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Public LogBrew Ruby SDK for building, validating, and flushing event
14
14
  batches.
@@ -19,9 +19,16 @@ extra_rdoc_files: []
19
19
  files:
20
20
  - README.md
21
21
  - examples/Makefile
22
+ - examples/first_useful_telemetry.rb
23
+ - examples/http_trace_correlation.rb
22
24
  - examples/readme_example.rb
23
25
  - examples/real_user_smoke.rb
24
26
  - lib/logbrew.rb
27
+ - lib/logbrew/operation_tracing.rb
28
+ - lib/logbrew/product_timeline.rb
29
+ - lib/logbrew/support_ticket.rb
30
+ - lib/logbrew/trace.rb
31
+ - lib/logbrew/traceparent.rb
25
32
  homepage: https://github.com/LogBrewCo/sdk
26
33
  licenses:
27
34
  - MIT