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.
- checksums.yaml +4 -4
- data/README.md +211 -3
- data/examples/Makefile +13 -1
- data/examples/automatic_delivery.rb +32 -0
- data/examples/persistent_worker_delivery.rb +33 -0
- data/examples/sidekiq_tracing.rb +22 -0
- data/lib/logbrew/automatic_delivery.rb +493 -0
- data/lib/logbrew/bounded_event_queue.rb +196 -0
- data/lib/logbrew/event_batcher.rb +67 -0
- data/lib/logbrew/faraday_tracing.rb +55 -0
- data/lib/logbrew/http_client_tracing.rb +282 -0
- data/lib/logbrew/operation_tracing.rb +16 -1
- data/lib/logbrew/persistent_event_store.rb +403 -0
- data/lib/logbrew/sidekiq.rb +466 -0
- data/lib/logbrew/span_events.rb +34 -0
- data/lib/logbrew/worker_lifecycle.rb +211 -0
- data/lib/logbrew.rb +357 -33
- metadata +14 -2
|
@@ -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
|