flare 0.3.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcfa9283973a11f13fd17397781c76cc03d3e39c9bd08a92025417397eb74eb9
4
- data.tar.gz: 30e2f84a92f9689aabdd64b7c6839598dc0c3c2bb1dd32a31e2ab9b3a6f5f85a
3
+ metadata.gz: cee6e0910ebcd04f073efc41638823f7ed4d7abf511ab71b68caead994997af1
4
+ data.tar.gz: f28d669e444b2abb8b6043990b8f1cfc5cbd39abc14d25942cf5f41e08a80cb2
5
5
  SHA512:
6
- metadata.gz: 581e6e9ed512b82ac086ab76644ff0d618408649a110ef3b623a3bf4a1648e3695dfcedcefad01bd54b7a11687a1c5ed154ed01e74f2cf14c27fa400bf97f73a
7
- data.tar.gz: 4a407b09a229f512d6cb1f35ab04dfb8de7ecaa982837fd356c41435c7758d3c4e05ad33812670b2e570830bef5a21e9bdffe297234cec9e33f9ab5250c42af1
6
+ metadata.gz: 06647d35a64ae38dafb6e946b181fd87a8b8dc8e7065c5b0d4af3cb47419bd5be5dc9e930f3aa322740e824279169d9b2ae867c2b3c14dfda22c1d01be25507d
7
+ data.tar.gz: 837f70a2ef44f4d0f8c415cc15c6455ab5250b63e7dd06580ad640492385d8d1e6842cda3f56b036d918bb38c7b55438a2d60546a6c41fd7ce77a429b2d1793a
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ require_relative "version"
6
+
7
+ module Flare
8
+ # Single source of truth for the headers that identify this client and its
9
+ # version to the Flare API. The server parses the gem version out of the
10
+ # User-Agent ("Flare Ruby/X.Y.Z") to gate version-dependent features, and
11
+ # uses the X-Client-* headers to build a per-client picture.
12
+ #
13
+ # Safe to send on any request that targets the Flare API (metrics POST,
14
+ # rules GET, trace-notify POST). Do NOT send these on the presigned R2 blob
15
+ # PUT -- they're meaningless to R2 and can invalidate the signed-header set.
16
+ #
17
+ # Excludes Authorization / Flare-Project / Flare-Environment -- callers add
18
+ # those since they're request- or context-specific.
19
+ module ClientHeaders
20
+ USER_AGENT = "Flare Ruby/#{Flare::VERSION}"
21
+
22
+ def self.to_h
23
+ {
24
+ "User-Agent" => USER_AGENT,
25
+ "X-Client-Language" => "ruby",
26
+ "X-Client-Language-Version" => RUBY_VERSION,
27
+ "X-Client-Platform" => RUBY_PLATFORM,
28
+ "X-Client-Pid" => Process.pid.to_s,
29
+ "X-Client-Hostname" => hostname
30
+ }
31
+ end
32
+
33
+ def self.hostname
34
+ Socket.gethostname
35
+ rescue StandardError
36
+ "unknown"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # A small monotonic deadline shared by lifecycle operations. A nil timeout
5
+ # represents an unbounded operation.
6
+ class Deadline
7
+ def initialize(timeout)
8
+ @expires_at = monotonic_now + [timeout.to_f, 0].max unless timeout.nil?
9
+ end
10
+
11
+ def remaining
12
+ return nil unless @expires_at
13
+
14
+ [@expires_at - monotonic_now, 0].max
15
+ end
16
+
17
+ def expired?
18
+ remaining == 0
19
+ end
20
+
21
+ private
22
+
23
+ def monotonic_now
24
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ end
26
+ end
27
+ end
@@ -4,6 +4,8 @@ require "concurrent/atomic/atomic_fixnum"
4
4
  require "logger"
5
5
  require "opentelemetry/sdk"
6
6
 
7
+ require_relative "deadline"
8
+
7
9
  module Flare
8
10
  # BSP-shaped span processor whose filter is `sampled OR marked` instead
9
11
  # of BSP's `sampled` (BSP early-returns on RECORD_ONLY spans -- our
@@ -17,6 +19,7 @@ module Flare
17
19
  class FilteringSpanProcessor
18
20
  SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
19
21
  FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
22
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
20
23
 
21
24
  DEFAULT_MAX_QUEUE = 5_000
22
25
  DEFAULT_FLUSH_INTERVAL = 5 # seconds
@@ -47,6 +50,10 @@ module Flare
47
50
  @mutex = Mutex.new
48
51
  @cond = ConditionVariable.new
49
52
  @stopped = false
53
+ @active_exports = 0
54
+ @export_completion_sequence = 0
55
+ @last_export_result = SUCCESS
56
+ @flush_owner = nil
50
57
  @pid = $$
51
58
 
52
59
  @dropped_count = Concurrent::AtomicFixnum.new(0)
@@ -78,19 +85,43 @@ module Flare
78
85
  end
79
86
 
80
87
  def force_flush(timeout: nil)
81
- drain_and_export(include_pending: true)
82
- SUCCESS
88
+ detect_forking
89
+ deadline = Deadline.new(timeout)
90
+ return TIMEOUT unless begin_flush(deadline)
91
+ prior_result = @flush_prior_result
92
+
93
+ batch = snapshot_for_flush(deadline)
94
+ return TIMEOUT unless batch
95
+
96
+ operation = start_flush_export(batch, deadline)
97
+ result = wait_for_flush_export(operation, deadline)
98
+ [prior_result, result].max
99
+ ensure
100
+ finish_flush if @flush_owner == Thread.current
83
101
  end
84
102
 
85
103
  def shutdown(timeout: nil)
86
- @mutex.synchronize do
104
+ detect_forking
105
+ deadline = Deadline.new(timeout)
106
+ return TIMEOUT unless lock_before_deadline(deadline)
107
+
108
+ begin
87
109
  @stopped = true
88
110
  @cond.broadcast
111
+ ensure
112
+ @mutex.unlock
89
113
  end
90
- @worker.join(timeout || 5)
91
- drain_and_export(include_pending: true)
92
- @exporter.shutdown(timeout: timeout) if @exporter.respond_to?(:shutdown)
93
- SUCCESS
114
+ @worker.join(deadline.remaining || 5)
115
+ return TIMEOUT if @worker.alive? || deadline.expired?
116
+
117
+ result = force_flush(timeout: deadline.remaining)
118
+ return result unless result == SUCCESS
119
+ return TIMEOUT if deadline.expired?
120
+
121
+ exporter_result = @exporter.shutdown(timeout: deadline.remaining) if @exporter.respond_to?(:shutdown)
122
+ return TIMEOUT if deadline.expired?
123
+
124
+ exporter_result || SUCCESS
94
125
  end
95
126
 
96
127
  def buffer_size
@@ -124,7 +155,8 @@ module Flare
124
155
  until stopped?
125
156
  @mutex.synchronize do
126
157
  timeout = next_wait_timeout
127
- @cond.wait(@mutex, timeout) if @ready_queue.empty? && !@stopped
158
+ waiting_for_export = @flush_owner || @active_exports.positive?
159
+ @cond.wait(@mutex, timeout) if (@ready_queue.empty? || waiting_for_export) && !@stopped
128
160
  end
129
161
  drain_and_export
130
162
  end
@@ -134,30 +166,141 @@ module Flare
134
166
  @mutex.synchronize { @stopped }
135
167
  end
136
168
 
137
- def drain_and_export(include_pending: false)
169
+ def drain_and_export
138
170
  batch = nil
139
171
  @mutex.synchronize do
140
172
  promote_due_delayed_traces
173
+ return if @ready_queue.empty? || @flush_owner || @active_exports.positive?
141
174
 
142
- if include_pending
143
- @ready_queue.concat(@pending_by_trace.values.flatten)
144
- @pending_by_trace.clear
145
- @trace_order.clear
146
- @pending_count = 0
147
- unmark_delayed_traces
148
- @delayed_ready_by_trace.clear
175
+ batch = @ready_queue
176
+ @ready_queue = []
177
+ @active_exports += 1
178
+ end
179
+
180
+ result = export_batch(batch, timeout: @export_timeout)
181
+ ensure
182
+ export_finished(result || FAILURE) if batch
183
+ end
184
+
185
+ def begin_flush(deadline)
186
+ return false unless lock_before_deadline(deadline)
187
+
188
+ begin
189
+ initial_sequence = @export_completion_sequence
190
+ while @flush_owner && @flush_owner != Thread.current
191
+ return false if deadline.expired?
192
+
193
+ @cond.wait(@mutex, deadline.remaining)
149
194
  end
195
+ @flush_owner = Thread.current
196
+
197
+ while @active_exports.positive?
198
+ return false if deadline.expired?
150
199
 
151
- return if @ready_queue.empty?
200
+ @cond.wait(@mutex, deadline.remaining)
201
+ end
202
+ @flush_prior_result = if @export_completion_sequence > initial_sequence
203
+ @last_export_result
204
+ else
205
+ SUCCESS
206
+ end
207
+ ensure
208
+ @mutex.unlock
209
+ end
210
+ true
211
+ end
212
+
213
+ def finish_flush
214
+ @mutex.synchronize do
215
+ @flush_owner = nil
216
+ @cond.broadcast
217
+ end
218
+ end
219
+
220
+ def snapshot_for_flush(deadline)
221
+ return unless lock_before_deadline(deadline)
222
+
223
+ begin
224
+ @ready_queue.concat(@pending_by_trace.values.flatten)
225
+ @pending_by_trace.clear
226
+ @trace_order.clear
227
+ @pending_count = 0
228
+ unmark_delayed_traces
229
+ @delayed_ready_by_trace.clear
152
230
  batch = @ready_queue
153
231
  @ready_queue = []
232
+ batch
233
+ ensure
234
+ @mutex.unlock
235
+ end
236
+ end
237
+
238
+ def start_flush_export(batch, deadline)
239
+ operation = { done: false, result: nil }
240
+ @mutex.synchronize { @active_exports += 1 }
241
+ Thread.new do
242
+ result = batch.empty? ? SUCCESS : export_batch(batch, timeout: deadline.remaining)
243
+ if result == SUCCESS && !deadline.expired? && @exporter.respond_to?(:force_flush)
244
+ result = @exporter.force_flush(timeout: deadline.remaining)
245
+ end
246
+ result = TIMEOUT if deadline.expired?
247
+ operation[:result] = result
248
+ rescue StandardError => e
249
+ @exception_count.increment
250
+ @logger.warn("[Flare::FilteringSpanProcessor] force flush failed: #{e.class}: #{e.message}")
251
+ operation[:result] = FAILURE
252
+ ensure
253
+ @mutex.synchronize do
254
+ operation[:done] = true
255
+ complete_export(operation[:result])
256
+ end
154
257
  end
258
+ operation
259
+ end
260
+
261
+ def wait_for_flush_export(operation, deadline)
262
+ @mutex.synchronize do
263
+ until operation[:done]
264
+ return TIMEOUT if deadline.expired?
155
265
 
156
- result = @exporter.export(batch, timeout: @export_timeout)
266
+ @cond.wait(@mutex, deadline.remaining)
267
+ end
268
+ end
269
+ operation[:result]
270
+ end
271
+
272
+ def export_batch(batch, timeout:)
273
+ result = @exporter.export(batch, timeout: timeout)
157
274
  @failed_export_count.increment if result != SUCCESS
275
+ result
158
276
  rescue StandardError => e
159
277
  @exception_count.increment
160
278
  @logger.warn("[Flare::FilteringSpanProcessor] export failed: #{e.class}: #{e.message}")
279
+ FAILURE
280
+ end
281
+
282
+ def export_finished(result)
283
+ @mutex.synchronize do
284
+ complete_export(result)
285
+ end
286
+ end
287
+
288
+ def complete_export(result)
289
+ @active_exports -= 1
290
+ @export_completion_sequence += 1
291
+ @last_export_result = result || FAILURE
292
+ @cond.broadcast
293
+ end
294
+
295
+ def lock_before_deadline(deadline)
296
+ return @mutex.lock unless deadline.remaining
297
+
298
+ until @mutex.try_lock
299
+ return false if deadline.expired?
300
+
301
+ sleep([deadline.remaining, 0.001].min)
302
+ end
303
+ true
161
304
  end
162
305
 
163
306
  def mark_trace_ready(trace_id)
@@ -255,18 +398,25 @@ module Flare
255
398
  def detect_forking
256
399
  return if @pid == $$
257
400
 
258
- @mutex.synchronize do
259
- return if @pid == $$
260
-
261
- @pid = $$
262
- @pending_by_trace.clear
263
- @trace_order.clear
264
- @ready_queue.clear
265
- @delayed_ready_by_trace.clear
266
- @pending_count = 0
267
- @stopped = false
268
- start_worker
269
- end
401
+ # The child only retains the forking thread. Replace synchronization
402
+ # objects so it cannot inherit locks or active-export bookkeeping owned
403
+ # by vanished threads.
404
+ @pid = $$
405
+ @mutex = Mutex.new
406
+ @cond = ConditionVariable.new
407
+ @pending_by_trace = {}
408
+ @trace_order = []
409
+ @ready_queue = []
410
+ @delayed_ready_by_trace = {}
411
+ @pending_count = 0
412
+ @active_exports = 0
413
+ @export_completion_sequence = 0
414
+ @last_export_result = SUCCESS
415
+ @flush_prior_result = SUCCESS
416
+ @flush_owner = nil
417
+ @stopped = false
418
+ @worker = nil
419
+ start_worker
270
420
  end
271
421
 
272
422
  def start_worker
@@ -3,12 +3,15 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
 
6
+ require_relative "deadline"
7
+
6
8
  module Flare
7
9
  # Tiny HTTP wrapper used by TraceExporter (and anything else that wants
8
10
  # to PUT/POST without pulling in a heavy client). Designed for injection
9
11
  # at the boundary so tests can swap in a recording fake; no other moving
10
12
  # parts.
11
13
  class HttpTransport
14
+ DeadlineExceeded = Class.new(StandardError)
12
15
  DEFAULT_OPEN_TIMEOUT = 2
13
16
  DEFAULT_READ_TIMEOUT = 5
14
17
  DEFAULT_WRITE_TIMEOUT = 5
@@ -28,35 +31,46 @@ module Flare
28
31
  @write_timeout = write_timeout
29
32
  end
30
33
 
31
- def get(url, headers = {})
32
- request(url, nil, headers, Net::HTTP::Get)
34
+ def get(url, headers = {}, timeout: nil)
35
+ request(url, nil, headers, Net::HTTP::Get, timeout: timeout)
33
36
  end
34
37
 
35
- def put(url, body, headers = {})
36
- request(url, body, headers, Net::HTTP::Put)
38
+ def put(url, body, headers = {}, timeout: nil)
39
+ request(url, body, headers, Net::HTTP::Put, timeout: timeout)
37
40
  end
38
41
 
39
- def post(url, body, headers = {})
40
- request(url, body, headers, Net::HTTP::Post)
42
+ def post(url, body, headers = {}, timeout: nil)
43
+ request(url, body, headers, Net::HTTP::Post, timeout: timeout)
41
44
  end
42
45
 
43
46
  private
44
47
 
45
- def request(url, body, headers, klass)
48
+ def request(url, body, headers, klass, timeout: nil)
49
+ deadline = Deadline.new(timeout)
50
+ raise DeadlineExceeded if deadline.expired?
51
+
46
52
  uri = URI(url)
47
53
  http = Net::HTTP.new(uri.host, uri.port)
48
54
  http.use_ssl = uri.scheme == "https"
49
- http.open_timeout = @open_timeout
50
- http.read_timeout = @read_timeout
51
- http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
55
+ http.open_timeout = effective_timeout(@open_timeout, deadline.remaining)
56
+ http.read_timeout = effective_timeout(@read_timeout, deadline.remaining)
57
+ http.write_timeout = effective_timeout(@write_timeout, deadline.remaining) if http.respond_to?(:write_timeout=)
52
58
 
53
59
  req = klass.new(uri.request_uri == "" ? "/" : uri.request_uri)
54
60
  headers.each { |k, v| req[k] = v }
55
61
  req.body = body if body
56
62
 
57
63
  response = http.request(req)
64
+ raise DeadlineExceeded if deadline.expired?
65
+
58
66
  hash = response.each_header.to_h
59
67
  Response.new(code: response.code.to_s, body: response.body, headers: hash)
60
68
  end
69
+
70
+ def effective_timeout(configured_timeout, remaining)
71
+ return configured_timeout unless remaining
72
+
73
+ [configured_timeout, remaining].min
74
+ end
61
75
  end
62
76
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ require_relative "deadline"
6
+
7
+ module Flare
8
+ module Lifecycle
9
+ # Flush all trace processors and the separate aggregated metric pipeline
10
+ # using one monotonic timeout budget. This is safe to call from lifecycle
11
+ # hooks for short-lived and fork-per-job workers.
12
+ def force_flush(timeout: nil)
13
+ deadline = Deadline.new(timeout)
14
+ results = []
15
+
16
+ results << tracer_provider_for_flush.force_flush(timeout: deadline.remaining)
17
+ return OpenTelemetry::SDK::Trace::Export::TIMEOUT if deadline.expired?
18
+
19
+ flusher = metric_flusher_for_flush
20
+ results << flusher.force_flush(timeout: deadline.remaining) if flusher
21
+ return OpenTelemetry::SDK::Trace::Export::TIMEOUT if deadline.expired?
22
+
23
+ results.max || OpenTelemetry::SDK::Trace::Export::SUCCESS
24
+ rescue => e
25
+ warn "[Flare] Telemetry flush error: #{e.message}"
26
+ OpenTelemetry::SDK::Trace::Export::FAILURE
27
+ end
28
+
29
+ def tracer_provider_for_flush
30
+ OpenTelemetry.tracer_provider
31
+ end
32
+
33
+ def metric_flusher_for_flush
34
+ @metric_flusher
35
+ end
36
+ end
37
+
38
+ extend Lifecycle
39
+ end