logbrew-sdk 0.1.0 → 0.1.2

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
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Content-free delivery details safe for application-owned diagnostics.
5
+ class WorkerDeliveryFailure
6
+ attr_reader :stage, :code, :pending_events, :pending_event_bytes, :dropped_events
7
+
8
+ def initialize(stage:, code:, pending_events:, pending_event_bytes:, dropped_events:)
9
+ @stage = stage.dup.freeze
10
+ @code = code.dup.freeze
11
+ @pending_events = pending_events
12
+ @pending_event_bytes = pending_event_bytes
13
+ @dropped_events = dropped_events
14
+ freeze
15
+ end
16
+ end
17
+
18
+ # Explicit delivery boundaries for serialized prefork worker loops.
19
+ class WorkerLifecycle
20
+ SAFE_DELIVERY_CODES = %w[
21
+ delivery_error
22
+ flush_error
23
+ network_failure
24
+ shutdown_error
25
+ transport_error
26
+ unauthenticated
27
+ validation_error
28
+ ].freeze
29
+
30
+ def self.create(client:, transport:, on_delivery_failure: nil)
31
+ unless client.is_a?(Client)
32
+ raise SdkError.new("validation_error", "client must be a LogBrew::Client")
33
+ end
34
+ unless transport.respond_to?(:send)
35
+ raise SdkError.new("validation_error", "transport must respond to send")
36
+ end
37
+ if !on_delivery_failure.nil? && !on_delivery_failure.respond_to?(:call)
38
+ raise SdkError.new("validation_error", "on_delivery_failure must be callable")
39
+ end
40
+
41
+ new(
42
+ client: client,
43
+ transport: transport,
44
+ on_delivery_failure: on_delivery_failure,
45
+ owner_process_id: current_process_id
46
+ )
47
+ end
48
+
49
+ def self.current_process_id
50
+ process_id = Process.pid
51
+ unless process_id.is_a?(Integer) && process_id.positive?
52
+ raise SdkError.new("process_ownership_error", "worker process identity is unavailable")
53
+ end
54
+
55
+ process_id
56
+ end
57
+ private_class_method :current_process_id
58
+
59
+ def initialize(client:, transport:, on_delivery_failure:, owner_process_id:)
60
+ @client = client
61
+ @transport = transport
62
+ @on_delivery_failure = on_delivery_failure
63
+ @owner_process_id = owner_process_id
64
+ @state_mutex = Mutex.new
65
+ @operation_active = false
66
+ @shutdown_response = nil
67
+ end
68
+ private_class_method :new
69
+
70
+ def run
71
+ assert_process_ownership
72
+ begin_run
73
+ begin
74
+ application_error = nil
75
+ result = nil
76
+ begin
77
+ result = yield
78
+ rescue Exception => error # rubocop:disable Lint/RescueException
79
+ application_error = error
80
+ ensure
81
+ finish_work_boundary(application_error)
82
+ end
83
+
84
+ raise application_error unless application_error.nil?
85
+
86
+ result
87
+ ensure
88
+ end_operation
89
+ end
90
+ end
91
+
92
+ def shutdown
93
+ assert_process_ownership
94
+ cached_response = begin_shutdown
95
+ return cached_response unless cached_response.nil?
96
+
97
+ completed = false
98
+ begin
99
+ begin
100
+ response = @client.shutdown(@transport)
101
+ rescue StandardError => delivery_error
102
+ report_delivery_failure("shutdown", delivery_error)
103
+ raise delivery_error
104
+ end
105
+
106
+ complete_shutdown(response)
107
+ completed = true
108
+ response
109
+ ensure
110
+ end_operation unless completed
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def begin_run
117
+ @state_mutex.synchronize do
118
+ raise SdkError.new("shutdown_error", "worker lifecycle is already shut down") unless @shutdown_response.nil?
119
+
120
+ claim_operation
121
+ end
122
+ end
123
+
124
+ def begin_shutdown
125
+ @state_mutex.synchronize do
126
+ return @shutdown_response unless @shutdown_response.nil?
127
+
128
+ claim_operation
129
+ nil
130
+ end
131
+ end
132
+
133
+ def claim_operation
134
+ if @operation_active
135
+ raise SdkError.new("worker_lifecycle_error", "worker lifecycle operation is already in progress")
136
+ end
137
+
138
+ @operation_active = true
139
+ end
140
+
141
+ def complete_shutdown(response)
142
+ @state_mutex.synchronize do
143
+ @shutdown_response = response
144
+ @operation_active = false
145
+ end
146
+ end
147
+
148
+ def end_operation
149
+ if current_process_id == @owner_process_id
150
+ @state_mutex.synchronize { @operation_active = false }
151
+ else
152
+ # An inherited lifecycle is permanently unusable in the child.
153
+ @operation_active = false
154
+ end
155
+ end
156
+
157
+ def assert_process_ownership
158
+ return if current_process_id == @owner_process_id
159
+
160
+ raise SdkError.new(
161
+ "process_ownership_error",
162
+ "worker lifecycle must be created in the current process"
163
+ )
164
+ end
165
+
166
+ def finish_work_boundary(application_error)
167
+ begin
168
+ assert_process_ownership
169
+ rescue SdkError => ownership_error
170
+ raise application_error unless application_error.nil?
171
+
172
+ raise ownership_error
173
+ end
174
+
175
+ begin
176
+ @client.flush(@transport)
177
+ rescue StandardError => delivery_error
178
+ report_delivery_failure("work_boundary", delivery_error)
179
+ end
180
+ end
181
+
182
+ def current_process_id
183
+ process_id = Process.pid
184
+ unless process_id.is_a?(Integer) && process_id.positive?
185
+ raise SdkError.new("process_ownership_error", "worker process identity is unavailable")
186
+ end
187
+
188
+ process_id
189
+ end
190
+
191
+ def report_delivery_failure(stage, error)
192
+ return if @on_delivery_failure.nil?
193
+
194
+ code = if error.is_a?(SdkError) && SAFE_DELIVERY_CODES.include?(error.code)
195
+ error.code
196
+ else
197
+ "delivery_error"
198
+ end
199
+ notice = WorkerDeliveryFailure.new(
200
+ stage: stage,
201
+ code: code,
202
+ pending_events: @client.pending_events,
203
+ pending_event_bytes: @client.pending_event_bytes,
204
+ dropped_events: @client.dropped_events
205
+ )
206
+ @on_delivery_failure.call(notice)
207
+ rescue StandardError
208
+ nil
209
+ end
210
+ end
211
+ end