logbrew-sdk 0.1.1 → 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,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logbrew"
4
+
5
+ module LogBrew
6
+ # Explicit Sidekiq middleware for app-owned client and server chains.
7
+ module Sidekiq
8
+ CARRIER_KEY = "logbrew".freeze
9
+ CARRIER_VERSION = 1
10
+ MAX_QUEUE_WAIT_MS = 604_800_000
11
+ MAX_RETRY_COUNT = 1_000
12
+ MAX_REPORTED_FAILURES = 1_024
13
+ private_constant :CARRIER_KEY, :CARRIER_VERSION, :MAX_QUEUE_WAIT_MS, :MAX_RETRY_COUNT, :MAX_REPORTED_FAILURES
14
+
15
+ class TraceOperation
16
+ attr_reader :context, :traceparent
17
+
18
+ def initialize(instrumentation:, context:, name:, source:, retry_count: nil, queue_wait_ms: nil)
19
+ @instrumentation = instrumentation
20
+ @context = context
21
+ @traceparent = Trace.create_headers(context).fetch("traceparent")
22
+ @name = name
23
+ @source = source
24
+ @retry_count = retry_count
25
+ @queue_wait_ms = queue_wait_ms
26
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ @finished = false
28
+ @finish_mutex = Mutex.new
29
+ end
30
+
31
+ def around(terminal_failure: false)
32
+ Trace.with_context(@context) do
33
+ begin
34
+ result = yield
35
+ rescue Exception => error # rubocop:disable Lint/RescueException
36
+ finish(error: error, terminal_failure: terminal_failure)
37
+ raise
38
+ else
39
+ finish
40
+ result
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def finish(error: nil, terminal_failure: false)
48
+ return unless @finish_mutex.synchronize do
49
+ next false if @finished
50
+
51
+ @finished = true
52
+ end
53
+
54
+ @instrumentation.send(:capture_span, self, error)
55
+ @instrumentation.send(:capture_terminal_issue, self) if error && terminal_failure && !cancellation?(error)
56
+ rescue StandardError => capture_error
57
+ @instrumentation.send(:report_capture_error, capture_error)
58
+ end
59
+
60
+ def cancellation?(error)
61
+ error.is_a?(Interrupt)
62
+ end
63
+
64
+ public
65
+
66
+ def span_attributes(error)
67
+ metadata = {
68
+ "source" => @source,
69
+ "sampled" => @context.sampled
70
+ }
71
+ metadata["retryCount"] = @retry_count unless @retry_count.nil?
72
+ metadata["queueWaitMs"] = @queue_wait_ms unless @queue_wait_ms.nil?
73
+ metadata["cancelled"] = true if error.is_a?(Interrupt)
74
+
75
+ {
76
+ "name" => @name,
77
+ "traceId" => @context.trace_id,
78
+ "spanId" => @context.span_id,
79
+ "parentSpanId" => @context.parent_span_id,
80
+ "status" => error ? "error" : "ok",
81
+ "durationMs" => elapsed_ms,
82
+ "metadata" => metadata
83
+ }
84
+ end
85
+
86
+ def issue_attributes
87
+ {
88
+ "title" => "Sidekiq job failed",
89
+ "level" => "error",
90
+ "metadata" => {
91
+ "source" => @source,
92
+ "sampled" => @context.sampled,
93
+ "retryCount" => @retry_count + 1
94
+ }
95
+ }
96
+ end
97
+
98
+ def retry_count
99
+ @retry_count
100
+ end
101
+
102
+ def failure_key
103
+ [@context.trace_id, @context.parent_span_id, @retry_count].join(":")
104
+ end
105
+
106
+ private
107
+
108
+ def elapsed_ms
109
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
110
+ (elapsed * 1000.0).round(3)
111
+ end
112
+ end
113
+ private_constant :TraceOperation
114
+
115
+ # App-owned Sidekiq registration and lifecycle state.
116
+ class Instrumentation
117
+ def self.create(client:, transport: nil, max_retries: 25, on_capture_error: nil)
118
+ unless client.is_a?(Client)
119
+ raise SdkError.new("validation_error", "client must be a LogBrew::Client")
120
+ end
121
+ unless transport.nil? || transport.respond_to?(:send)
122
+ raise SdkError.new("validation_error", "transport must respond to send")
123
+ end
124
+ unless max_retries.is_a?(Integer) && max_retries.between?(0, MAX_RETRY_COUNT)
125
+ raise SdkError.new("validation_error", "max_retries must be a bounded non-negative integer")
126
+ end
127
+ unless on_capture_error.nil? || on_capture_error.respond_to?(:call)
128
+ raise SdkError.new("validation_error", "on_capture_error must be callable")
129
+ end
130
+
131
+ new(
132
+ client: client,
133
+ transport: transport,
134
+ max_retries: max_retries,
135
+ on_capture_error: on_capture_error
136
+ )
137
+ end
138
+
139
+ private_class_method :new
140
+
141
+ def initialize(client:, transport:, max_retries:, on_capture_error:)
142
+ @client = client
143
+ @transport = transport
144
+ @max_retries = max_retries
145
+ @on_capture_error = on_capture_error
146
+ @owner_process_id = Process.pid
147
+ @state_mutex = Mutex.new
148
+ @state = :enabled
149
+ @shutdown_mutex = Mutex.new
150
+ @shutdown_response = nil
151
+ @failure_mutex = Mutex.new
152
+ @reported_failures = {}
153
+ @registration_mutex = Mutex.new
154
+ @registrations = {}
155
+ end
156
+
157
+ def register_client(config)
158
+ register(config, :client_middleware, ClientMiddleware)
159
+ end
160
+
161
+ def unregister_client(config)
162
+ unregister(config, :client_middleware, ClientMiddleware)
163
+ end
164
+
165
+ def register_server(config)
166
+ register(config, :server_middleware, ServerMiddleware)
167
+ end
168
+
169
+ def unregister_server(config)
170
+ unregister(config, :server_middleware, ServerMiddleware)
171
+ end
172
+
173
+ def enable
174
+ update_state(:enabled)
175
+ end
176
+
177
+ def disable
178
+ update_state(:disabled)
179
+ end
180
+
181
+ def quiet
182
+ update_state(:quiet)
183
+ end
184
+
185
+ def shutdown
186
+ assert_process_ownership!
187
+ @shutdown_mutex.synchronize do
188
+ return @shutdown_response unless @shutdown_response.nil?
189
+
190
+ quiet
191
+ response = @transport.nil? ? @client.shutdown : @client.shutdown(@transport)
192
+ @state_mutex.synchronize { @state = :closed }
193
+ @shutdown_response = response
194
+ end
195
+ end
196
+
197
+ def around_client(job)
198
+ return yield unless capture_enabled?
199
+ return yield unless job.is_a?(Hash)
200
+ return yield if job.key?(CARRIER_KEY)
201
+
202
+ operation = prepare_operation(name: "sidekiq.enqueue", source: "sidekiq.client", continue_current: true)
203
+ return yield if operation.nil?
204
+
205
+ begin
206
+ job[CARRIER_KEY] = {
207
+ "version" => CARRIER_VERSION,
208
+ "traceparent" => operation.traceparent,
209
+ "enqueuedAtMs" => wall_time_ms
210
+ }
211
+ rescue StandardError => error
212
+ report_capture_error(error)
213
+ return yield
214
+ end
215
+ operation.around { yield }
216
+ end
217
+
218
+ def around_server(job)
219
+ return yield unless capture_enabled?
220
+ return yield unless job.is_a?(Hash)
221
+
222
+ begin
223
+ carrier = read_carrier(job[CARRIER_KEY])
224
+ retry_count = normalized_retry_count(job["retry_count"], job.key?("retry_count"))
225
+ operation = prepare_operation(
226
+ name: "sidekiq.perform",
227
+ source: "sidekiq.server",
228
+ carrier: carrier,
229
+ retry_count: retry_count,
230
+ queue_wait_ms: queue_wait_ms(carrier)
231
+ )
232
+ rescue StandardError => error
233
+ report_capture_error(error)
234
+ operation = nil
235
+ end
236
+ return yield if operation.nil?
237
+
238
+ terminal = terminal_failure?(job["retry"], retry_count)
239
+ operation.around(terminal_failure: terminal) { yield }
240
+ end
241
+
242
+ private :around_client, :around_server
243
+
244
+ private
245
+
246
+ def register(config, method_name, middleware)
247
+ assert_process_ownership!
248
+ changed = false
249
+ with_chain(config, method_name) do |chain|
250
+ unless chain.exists?(middleware)
251
+ chain.add(middleware, self)
252
+ changed = true
253
+ end
254
+ end
255
+ if changed
256
+ @registration_mutex.synchronize { @registrations[registration_key(config, method_name, middleware)] = config }
257
+ end
258
+ changed
259
+ end
260
+
261
+ def unregister(config, method_name, middleware)
262
+ assert_process_ownership!
263
+ key = registration_key(config, method_name, middleware)
264
+ owned = @registration_mutex.synchronize do
265
+ registered = @registrations[key]
266
+ registered.equal?(config)
267
+ end
268
+ return false unless owned
269
+
270
+ changed = false
271
+ with_chain(config, method_name) do |chain|
272
+ if chain.exists?(middleware)
273
+ chain.remove(middleware)
274
+ changed = true
275
+ end
276
+ end
277
+ @registration_mutex.synchronize { @registrations.delete(key) }
278
+ changed
279
+ end
280
+
281
+ def registration_key(config, method_name, middleware)
282
+ [config.object_id, method_name, middleware]
283
+ end
284
+
285
+ def with_chain(config, method_name)
286
+ unless config.respond_to?(method_name)
287
+ raise SdkError.new("validation_error", "Sidekiq configuration does not expose the requested middleware chain")
288
+ end
289
+
290
+ config.public_send(method_name) do |chain|
291
+ unless chain.respond_to?(:exists?) && chain.respond_to?(:add) && chain.respond_to?(:remove)
292
+ raise SdkError.new("validation_error", "Sidekiq middleware chain is unavailable")
293
+ end
294
+
295
+ yield chain
296
+ end
297
+ end
298
+
299
+ def update_state(state)
300
+ assert_process_ownership!
301
+ @state_mutex.synchronize do
302
+ raise SdkError.new("shutdown_error", "Sidekiq instrumentation is shut down") if @state == :closed
303
+
304
+ @state = state
305
+ end
306
+ self
307
+ end
308
+
309
+ def capture_enabled?
310
+ unless current_process?
311
+ report_capture_error(SdkError.new("process_ownership_error", "Sidekiq instrumentation belongs to another process"))
312
+ return false
313
+ end
314
+
315
+ @state_mutex.synchronize { @state == :enabled }
316
+ end
317
+
318
+ def current_process?
319
+ Process.pid == @owner_process_id
320
+ rescue StandardError
321
+ false
322
+ end
323
+
324
+ def assert_process_ownership!
325
+ return if current_process?
326
+
327
+ raise SdkError.new("process_ownership_error", "Sidekiq instrumentation belongs to another process")
328
+ end
329
+
330
+ def prepare_operation(name:, source:, carrier: nil, retry_count: nil, queue_wait_ms: nil, continue_current: false)
331
+ parsed = carrier && Traceparent.parse(carrier.fetch("traceparent"))
332
+ parent = parsed.nil? && continue_current ? Trace.current : parsed
333
+ context = if parsed
334
+ Trace.create(
335
+ trace_id: parsed.trace_id,
336
+ span_id: Trace.generate_span_id,
337
+ parent_span_id: parsed.parent_span_id,
338
+ trace_flags: parsed.trace_flags
339
+ )
340
+ elsif parent
341
+ Trace.create(
342
+ trace_id: parent.trace_id,
343
+ span_id: Trace.generate_span_id,
344
+ parent_span_id: parent.span_id,
345
+ trace_flags: parent.trace_flags
346
+ )
347
+ else
348
+ Trace.create_root
349
+ end
350
+ TraceOperation.new(
351
+ instrumentation: self,
352
+ context: context,
353
+ name: name,
354
+ source: source,
355
+ retry_count: retry_count,
356
+ queue_wait_ms: queue_wait_ms
357
+ )
358
+ rescue StandardError => error
359
+ report_capture_error(error)
360
+ nil
361
+ end
362
+
363
+ def read_carrier(value)
364
+ return nil unless value.is_a?(Hash)
365
+ keys = %w[enqueuedAtMs traceparent version]
366
+ return nil unless value.size == keys.length && keys.all? { |key| value.key?(key) }
367
+ return nil unless value["version"] == CARRIER_VERSION
368
+ return nil unless value["traceparent"].is_a?(String) && value["traceparent"].bytesize <= 55
369
+ return nil unless value["enqueuedAtMs"].is_a?(Integer) && value["enqueuedAtMs"].between?(0, 9_007_199_254_740_991)
370
+
371
+ Traceparent.parse(value["traceparent"])
372
+ value
373
+ rescue SdkError
374
+ nil
375
+ end
376
+
377
+ def normalized_retry_count(value, present)
378
+ return 0 unless present
379
+ return nil unless value.is_a?(Integer) && value.between?(0, MAX_RETRY_COUNT)
380
+
381
+ value
382
+ end
383
+
384
+ def terminal_failure?(retry_setting, retry_count)
385
+ return false if retry_count.nil?
386
+ return true if retry_setting == false || retry_setting == 0
387
+ return false unless retry_setting.nil? || retry_setting == true || retry_setting.is_a?(Integer)
388
+
389
+ limit = retry_setting.is_a?(Integer) ? retry_setting : @max_retries
390
+ return false if limit.negative?
391
+
392
+ retry_count >= [limit - 1, 0].max
393
+ end
394
+
395
+ def queue_wait_ms(carrier)
396
+ return nil if carrier.nil?
397
+
398
+ elapsed = wall_time_ms - carrier.fetch("enqueuedAtMs")
399
+ [[elapsed, 0].max, MAX_QUEUE_WAIT_MS].min
400
+ end
401
+
402
+ def wall_time_ms
403
+ (Time.now.to_f * 1000.0).floor
404
+ end
405
+
406
+ def capture_span(operation, error)
407
+ @client.span(
408
+ "ruby_sidekiq_span_#{operation.context.span_id}",
409
+ Time.now.utc.iso8601,
410
+ operation.span_attributes(error)
411
+ )
412
+ end
413
+
414
+ def capture_terminal_issue(operation)
415
+ key = operation.failure_key
416
+ reserved = @failure_mutex.synchronize do
417
+ next false if @reported_failures.key?(key)
418
+
419
+ @reported_failures[key] = true
420
+ @reported_failures.shift while @reported_failures.length > MAX_REPORTED_FAILURES
421
+ true
422
+ end
423
+ return unless reserved
424
+
425
+ begin
426
+ @client.issue(
427
+ "ruby_sidekiq_issue_#{operation.context.span_id}",
428
+ Time.now.utc.iso8601,
429
+ operation.issue_attributes
430
+ )
431
+ rescue StandardError
432
+ @failure_mutex.synchronize { @reported_failures.delete(key) }
433
+ raise
434
+ end
435
+ end
436
+
437
+ def report_capture_error(error)
438
+ @on_capture_error&.call(error)
439
+ rescue StandardError
440
+ nil
441
+ end
442
+ end
443
+
444
+ # Sidekiq client middleware installed in an app-owned middleware chain.
445
+ class ClientMiddleware
446
+ def initialize(instrumentation)
447
+ @instrumentation = instrumentation
448
+ end
449
+
450
+ def call(_worker_class, job, _queue, _redis_pool)
451
+ @instrumentation.send(:around_client, job) { yield }
452
+ end
453
+ end
454
+
455
+ # Sidekiq server middleware installed in an app-owned middleware chain.
456
+ class ServerMiddleware
457
+ def initialize(instrumentation)
458
+ @instrumentation = instrumentation
459
+ end
460
+
461
+ def call(_worker, job, _queue)
462
+ @instrumentation.send(:around_server, job) { yield }
463
+ end
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ module SpanEvents
5
+ LIMIT = 8
6
+ private_constant :LIMIT
7
+
8
+ module_function
9
+
10
+ def validate(events)
11
+ return nil if events.nil?
12
+ raise SdkError.new("validation_error", "span events must be an array") unless events.is_a?(Array)
13
+ raise SdkError.new("validation_error", "span events must contain at most #{LIMIT} entries") if events.length > LIMIT
14
+ return nil if events.empty?
15
+
16
+ events.map.with_index do |event, index|
17
+ raise SdkError.new("validation_error", "span event #{index} must be an object") unless event.is_a?(Hash)
18
+
19
+ event_name = Validation.read(event, "name")
20
+ Validation.require_non_empty("span event name", event_name)
21
+ event_timestamp = Validation.read(event, "timestamp")
22
+ Validation.require_timestamp(event_timestamp) unless event_timestamp.nil?
23
+ event_metadata = Validation.require_metadata(Validation.read(event, "metadata"))
24
+
25
+ {
26
+ "name" => event_name
27
+ }.tap do |payload|
28
+ payload["timestamp"] = event_timestamp unless event_timestamp.nil?
29
+ payload["metadata"] = event_metadata unless event_metadata.nil?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ 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