flare 0.3.1 → 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.
@@ -9,14 +9,17 @@ module Flare
9
9
  class MetricStorage
10
10
  def initialize
11
11
  @storage = Concurrent::Map.new
12
+ @pid = $$
12
13
  end
13
14
 
14
15
  def increment(key, duration_ms:, error: false)
16
+ detect_forking
15
17
  counter = @storage.compute_if_absent(key) { MetricCounter.new }
16
18
  counter.increment(duration_ms: duration_ms, error: error)
17
19
  end
18
20
 
19
21
  def add(key, count:, sum_ms:, error_count: 0)
22
+ detect_forking
20
23
  counter = @storage.compute_if_absent(key) { MetricCounter.new }
21
24
  counter.add(count: count, sum_ms: sum_ms, error_count: error_count)
22
25
  end
@@ -24,6 +27,7 @@ module Flare
24
27
  # Atomically retrieves and clears all metrics.
25
28
  # Returns a frozen hash of MetricKey => counter data.
26
29
  def drain
30
+ detect_forking
27
31
  result = {}
28
32
  @storage.keys.each do |key|
29
33
  counter = @storage.delete(key)
@@ -33,15 +37,31 @@ module Flare
33
37
  end
34
38
 
35
39
  def size
40
+ detect_forking
36
41
  @storage.size
37
42
  end
38
43
 
39
44
  def empty?
45
+ detect_forking
40
46
  @storage.empty?
41
47
  end
42
48
 
43
49
  def [](key)
50
+ detect_forking
44
51
  @storage[key]
45
52
  end
53
+
54
+ def after_fork
55
+ return if @pid == $$
56
+
57
+ @pid = $$
58
+ @storage = Concurrent::Map.new
59
+ end
60
+
61
+ private
62
+
63
+ def detect_forking
64
+ after_fork
65
+ end
46
66
  end
47
67
  end
@@ -7,6 +7,7 @@ require "stringio"
7
7
  require "securerandom"
8
8
 
9
9
  require_relative "client_headers"
10
+ require_relative "deadline"
10
11
 
11
12
  module Flare
12
13
  # Submits metrics to the Flare metrics service via HTTP.
@@ -45,6 +46,8 @@ module Flare
45
46
  end
46
47
  end
47
48
 
49
+ class DeadlineExceeded < StandardError; end
50
+
48
51
  attr_reader :endpoint, :api_key, :backoff_policy
49
52
 
50
53
  def initialize(endpoint:, api_key:, project: nil, environment: nil, backoff_policy: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil)
@@ -60,23 +63,28 @@ module Flare
60
63
 
61
64
  # Submit drained metrics to the server.
62
65
  # Returns [success_count, error] where error may be nil on success.
63
- def submit(drained)
66
+ def submit(drained, timeout: nil)
64
67
  return [0, nil] if drained.empty?
65
68
 
69
+ deadline = Deadline.new(timeout)
70
+
66
71
  request_id = SecureRandom.uuid
67
- Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})"
72
+ Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})" if Flare.respond_to?(:log)
68
73
 
69
74
  body = build_body(drained, request_id)
70
75
  return [0, nil] if body.nil?
76
+ return [0, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
71
77
 
72
78
  @backoff_policy.reset
73
- response, error = retry_with_backoff(MAX_RETRIES) { post(body, request_id) }
79
+ response, error = retry_with_backoff(MAX_RETRIES, deadline: deadline) do
80
+ post(body, request_id, timeout: deadline.remaining)
81
+ end
74
82
 
75
83
  if error
76
- Flare.log "Submission failed: #{error.message} (request_id=#{request_id})"
84
+ Flare.log "Submission failed: #{error.message} (request_id=#{request_id})" if Flare.respond_to?(:log)
77
85
  [0, error]
78
86
  else
79
- Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})"
87
+ Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})" if Flare.respond_to?(:log)
80
88
  [drained.size, nil]
81
89
  end
82
90
  end
@@ -111,12 +119,14 @@ module Flare
111
119
  nil
112
120
  end
113
121
 
114
- def post(body, request_id)
122
+ def post(body, request_id, timeout: nil)
123
+ raise DeadlineExceeded, "metric submission deadline exceeded" if timeout == 0
124
+
115
125
  http = Net::HTTP.new(@endpoint.host, @endpoint.port)
116
126
  http.use_ssl = @endpoint.scheme == "https"
117
- http.open_timeout = @open_timeout
118
- http.read_timeout = @read_timeout
119
- http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
127
+ http.open_timeout = effective_timeout(@open_timeout, timeout)
128
+ http.read_timeout = effective_timeout(@read_timeout, timeout)
129
+ http.write_timeout = effective_timeout(@write_timeout, timeout) if http.respond_to?(:write_timeout=)
120
130
 
121
131
  request_uri = @endpoint.request_uri
122
132
  request = Net::HTTP::Post.new(request_uri == "" ? "/" : request_uri)
@@ -158,20 +168,27 @@ module Flare
158
168
  )
159
169
  end
160
170
 
161
- def retry_with_backoff(max_attempts)
171
+ def retry_with_backoff(max_attempts, deadline:)
162
172
  attempts_remaining = max_attempts
163
173
  last_error = nil
164
174
 
165
175
  while attempts_remaining > 0
166
176
  begin
177
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
178
+
167
179
  result, should_retry = yield
180
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
168
181
  return [result, nil] unless should_retry
169
- rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
182
+ rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
170
183
  last_error = e
171
184
  attempts_remaining -= 1
172
185
 
173
186
  if attempts_remaining > 0
174
187
  sleep_time = @backoff_policy.next_interval / 1000.0
188
+ remaining = deadline.remaining
189
+ if remaining && sleep_time >= remaining
190
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")]
191
+ end
175
192
  sleep(sleep_time)
176
193
  end
177
194
  next
@@ -184,6 +201,12 @@ module Flare
184
201
  [nil, last_error]
185
202
  end
186
203
 
204
+ def effective_timeout(configured_timeout, remaining)
205
+ return configured_timeout unless remaining
206
+
207
+ [configured_timeout, remaining].min
208
+ end
209
+
187
210
  def gzip(string)
188
211
  io = StringIO.new
189
212
  io.set_encoding("BINARY")
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "opentelemetry/sdk"
5
+
6
+ require_relative "deadline"
7
+
8
+ module Flare
9
+ # An asynchronous, bounded span processor that exports every ended recording
10
+ # span. OpenTelemetry's BatchSpanProcessor only accepts sampled spans, which
11
+ # excludes RECORD_ONLY spans needed by Flare's local development dashboard.
12
+ class RecordingBatchSpanProcessor
13
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
14
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
15
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
16
+
17
+ def initialize(exporter, exporter_timeout: 30_000, schedule_delay: 5_000,
18
+ max_queue_size: 2_048, max_export_batch_size: 512, logger: nil)
19
+ raise ArgumentError if max_export_batch_size > max_queue_size
20
+
21
+ @exporter = exporter
22
+ @exporter_timeout = exporter_timeout / 1_000.0
23
+ @schedule_delay = schedule_delay / 1_000.0
24
+ @max_queue_size = max_queue_size
25
+ @max_export_batch_size = max_export_batch_size
26
+ @logger = logger || Logger.new($stderr, level: Logger::WARN)
27
+ @pid = $$
28
+ initialize_synchronization
29
+ start_worker
30
+ end
31
+
32
+ def on_start(_span, _parent_context); end
33
+
34
+ def on_finish(span)
35
+ detect_forking
36
+
37
+ @mutex.synchronize do
38
+ overflow = @queue.length + 1 - @max_queue_size
39
+ @queue.shift(overflow) if overflow.positive?
40
+ @queue << span
41
+ @condition.signal if @queue.length >= @max_export_batch_size
42
+ end
43
+ end
44
+
45
+ def force_flush(timeout: nil)
46
+ detect_forking
47
+ deadline = Deadline.new(timeout)
48
+ return TIMEOUT unless begin_flush(deadline)
49
+
50
+ snapshot = snapshot_for_flush
51
+ operation = start_flush_export(snapshot, deadline)
52
+ result = wait_for_flush_export(operation, deadline)
53
+ [@flush_prior_result, result].max
54
+ rescue StandardError => e
55
+ log_export_error(e)
56
+ FAILURE
57
+ ensure
58
+ finish_flush if @flush_owner == Thread.current
59
+ end
60
+
61
+ def shutdown(timeout: nil)
62
+ detect_forking
63
+ deadline = Deadline.new(timeout)
64
+
65
+ worker = @mutex.synchronize do
66
+ @stopped = true
67
+ @condition.broadcast
68
+ @worker
69
+ end
70
+ worker&.join(deadline.remaining)
71
+ return TIMEOUT if worker&.alive? || deadline.expired?
72
+
73
+ result = force_flush(timeout: deadline.remaining)
74
+ return result unless result == SUCCESS
75
+ return TIMEOUT if deadline.expired?
76
+
77
+ exporter_result = @exporter.shutdown(timeout: deadline.remaining)
78
+ deadline.expired? ? TIMEOUT : exporter_result
79
+ rescue StandardError => e
80
+ log_export_error(e)
81
+ FAILURE
82
+ end
83
+
84
+ private
85
+
86
+ def initialize_synchronization
87
+ @mutex = Mutex.new
88
+ @condition = ConditionVariable.new
89
+ @queue = []
90
+ @active_exports = 0
91
+ @export_completion_sequence = 0
92
+ @last_export_result = SUCCESS
93
+ @flush_prior_result = SUCCESS
94
+ @flush_owner = nil
95
+ @stopped = false
96
+ @worker = nil
97
+ end
98
+
99
+ def worker_loop
100
+ loop do
101
+ batch = @mutex.synchronize do
102
+ while !@stopped && (@queue.empty? || @flush_owner || @active_exports.positive?)
103
+ @condition.wait(@mutex, @schedule_delay)
104
+ break if !@queue.empty? && !@flush_owner && @active_exports.zero?
105
+ end
106
+ return if @stopped
107
+
108
+ @active_exports += 1
109
+ @queue.shift(@max_export_batch_size)
110
+ end
111
+
112
+ result = export_batch(batch, timeout: @exporter_timeout)
113
+ ensure
114
+ export_finished(result || FAILURE) if batch
115
+ end
116
+ end
117
+
118
+ def begin_flush(deadline)
119
+ @mutex.synchronize do
120
+ initial_sequence = @export_completion_sequence
121
+ while @flush_owner && @flush_owner != Thread.current
122
+ return false if deadline.expired?
123
+
124
+ @condition.wait(@mutex, deadline.remaining)
125
+ end
126
+ @flush_owner = Thread.current
127
+
128
+ while @active_exports.positive?
129
+ return false if deadline.expired?
130
+
131
+ @condition.wait(@mutex, deadline.remaining)
132
+ end
133
+ @flush_prior_result = if @export_completion_sequence > initial_sequence
134
+ @last_export_result
135
+ else
136
+ SUCCESS
137
+ end
138
+ end
139
+ true
140
+ end
141
+
142
+ def finish_flush
143
+ @mutex.synchronize do
144
+ @flush_owner = nil
145
+ @condition.broadcast
146
+ end
147
+ end
148
+
149
+ def snapshot_for_flush
150
+ @mutex.synchronize { @queue.shift(@queue.length) }
151
+ end
152
+
153
+ def export_snapshot(snapshot, deadline)
154
+ until snapshot.empty?
155
+ return TIMEOUT if deadline.expired?
156
+
157
+ batch = snapshot.shift(@max_export_batch_size)
158
+ result = export_batch(batch, timeout: deadline.remaining)
159
+ return result unless result == SUCCESS
160
+ end
161
+ SUCCESS
162
+ ensure
163
+ @mutex.synchronize { @queue.unshift(*snapshot) } if snapshot&.any?
164
+ end
165
+
166
+ def start_flush_export(snapshot, deadline)
167
+ operation = { done: false, result: nil }
168
+ @mutex.synchronize { @active_exports += 1 }
169
+ Thread.new do
170
+ result = export_snapshot(snapshot, deadline)
171
+ if result == SUCCESS && !deadline.expired?
172
+ result = @exporter.force_flush(timeout: deadline.remaining)
173
+ end
174
+ operation[:result] = deadline.expired? ? TIMEOUT : result
175
+ rescue StandardError => e
176
+ log_export_error(e)
177
+ operation[:result] = FAILURE
178
+ ensure
179
+ @mutex.synchronize do
180
+ operation[:done] = true
181
+ complete_export(operation[:result])
182
+ end
183
+ end
184
+ operation
185
+ end
186
+
187
+ def wait_for_flush_export(operation, deadline)
188
+ @mutex.synchronize do
189
+ until operation[:done]
190
+ return TIMEOUT if deadline.expired?
191
+
192
+ @condition.wait(@mutex, deadline.remaining)
193
+ end
194
+ end
195
+ operation[:result]
196
+ end
197
+
198
+ def export_batch(spans, timeout:)
199
+ span_data = spans.map { |span| span.respond_to?(:to_span_data) ? span.to_span_data : span }
200
+ @exporter.export(span_data, timeout: timeout)
201
+ rescue StandardError => e
202
+ log_export_error(e)
203
+ FAILURE
204
+ end
205
+
206
+ def export_finished(result)
207
+ @mutex.synchronize do
208
+ complete_export(result)
209
+ end
210
+ end
211
+
212
+ def complete_export(result)
213
+ @active_exports -= 1
214
+ @export_completion_sequence += 1
215
+ @last_export_result = result || FAILURE
216
+ @condition.broadcast
217
+ end
218
+
219
+ def detect_forking
220
+ return if @pid == $$
221
+
222
+ # Only the forking thread survives. Replacing synchronization objects
223
+ # avoids waiting on locks or in-flight state owned by vanished threads.
224
+ @pid = $$
225
+ initialize_synchronization
226
+ start_worker
227
+ end
228
+
229
+ def start_worker
230
+ @worker = Thread.new { worker_loop }
231
+ @worker.name = "flare-recording-batch-span-processor"
232
+ end
233
+
234
+ def log_export_error(error)
235
+ @logger.warn("[Flare::RecordingBatchSpanProcessor] export failed: #{error.class}: #{error.message}")
236
+ end
237
+ end
238
+ end
@@ -3,6 +3,8 @@
3
3
  require "sqlite3"
4
4
  require "json"
5
5
 
6
+ require_relative "deadline"
7
+
6
8
  module Flare
7
9
  class SQLiteExporter
8
10
  SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
@@ -16,45 +18,61 @@ module Flare
16
18
  @database_path = database_path
17
19
  @mutex = Mutex.new
18
20
  @setup = false
21
+ @pid = $$
19
22
  end
20
23
 
21
24
  # Maximum number of retry attempts when the database is busy.
22
25
  # Mirrors ActiveRecord's retry strategy for SQLite.
23
26
  MAX_RETRIES = 3
27
+ ExportDeadlineExceeded = Class.new(StandardError)
24
28
 
25
29
  def export(span_datas, timeout: nil)
26
- setup_database unless @setup
30
+ detect_forking
31
+ deadline = Deadline.new(timeout)
27
32
 
28
33
  retries = 0
29
34
  exported = 0
30
35
 
31
36
  begin
32
- @mutex.synchronize do
37
+ setup_database(deadline) unless @setup
38
+ raise ExportDeadlineExceeded if deadline.expired?
39
+ raise ExportDeadlineExceeded unless lock_before_deadline(deadline)
40
+
41
+ begin
42
+ apply_busy_timeout(deadline)
33
43
  connection.transaction do
34
44
  span_datas.each do |span_data|
45
+ raise ExportDeadlineExceeded if deadline.expired?
35
46
  next if should_ignore_span?(span_data)
36
47
 
37
48
  create_span(span_data)
38
49
  exported += 1
39
50
  end
40
51
  end
52
+ ensure
53
+ @mutex.unlock
41
54
  end
55
+ rescue ExportDeadlineExceeded
56
+ return TIMEOUT
42
57
  rescue ::SQLite3::BusyException
43
58
  retries += 1
44
59
  if retries <= MAX_RETRIES
45
- sleep 0.1 * retries
60
+ sleep_time = 0.1 * retries
61
+ return TIMEOUT if deadline.remaining && sleep_time >= deadline.remaining
62
+
63
+ sleep(sleep_time)
46
64
  retry
47
65
  end
48
66
  warn "[Flare] SQLite export error: database is busy after #{MAX_RETRIES} retries"
49
67
  return FAILURE
50
68
  end
51
69
 
52
- Flare.log "Exported #{exported} spans to SQLite" if exported > 0
70
+ Flare.log "Exported #{exported} spans to SQLite" if exported > 0 && Flare.respond_to?(:log)
53
71
 
54
72
  # Periodically prune old data
55
- maybe_prune
73
+ maybe_prune unless deadline.expired?
56
74
 
57
- SUCCESS
75
+ deadline.expired? ? TIMEOUT : SUCCESS
58
76
  rescue => e
59
77
  warn "[Flare] SQLite export error: #{e.message}"
60
78
  FAILURE
@@ -70,6 +88,31 @@ module Flare
70
88
 
71
89
  private
72
90
 
91
+ def detect_forking
92
+ return if @pid == $$
93
+
94
+ @pid = $$
95
+ @mutex = Mutex.new
96
+ close_connection
97
+ end
98
+
99
+ def lock_before_deadline(deadline)
100
+ return @mutex.lock unless deadline.remaining
101
+
102
+ until @mutex.try_lock
103
+ return false if deadline.expired?
104
+
105
+ sleep([deadline.remaining, 0.001].min)
106
+ end
107
+ true
108
+ end
109
+
110
+ def apply_busy_timeout(deadline)
111
+ remaining = deadline.remaining
112
+ timeout_ms = remaining ? [(remaining * 1_000).floor, 1].max : 5_000
113
+ connection.busy_timeout = [timeout_ms, 5_000].min
114
+ end
115
+
73
116
  def maybe_prune
74
117
  return unless rand < PRUNE_PROBABILITY
75
118
 
@@ -162,14 +205,16 @@ module Flare
162
205
  end
163
206
  end
164
207
 
165
- def setup_database
166
- @mutex.synchronize do
208
+ def setup_database(deadline)
209
+ raise ExportDeadlineExceeded unless lock_before_deadline(deadline)
210
+
211
+ begin
167
212
  return if @setup
168
213
 
169
214
  db = connection
170
- configure_pragmas(db)
215
+ configure_pragmas(db, deadline)
171
216
 
172
- db.execute(<<~SQL)
217
+ execute_setup(db, deadline, <<~SQL)
173
218
  CREATE TABLE IF NOT EXISTS flare_spans (
174
219
  id INTEGER PRIMARY KEY AUTOINCREMENT,
175
220
  name TEXT NOT NULL,
@@ -187,23 +232,23 @@ module Flare
187
232
  )
188
233
  SQL
189
234
 
190
- db.execute(<<~SQL)
235
+ execute_setup(db, deadline, <<~SQL)
191
236
  CREATE INDEX IF NOT EXISTS idx_spans_span_id ON flare_spans(span_id)
192
237
  SQL
193
238
 
194
- db.execute(<<~SQL)
239
+ execute_setup(db, deadline, <<~SQL)
195
240
  CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON flare_spans(trace_id)
196
241
  SQL
197
242
 
198
- db.execute(<<~SQL)
243
+ execute_setup(db, deadline, <<~SQL)
199
244
  CREATE INDEX IF NOT EXISTS idx_spans_parent_span_id ON flare_spans(parent_span_id)
200
245
  SQL
201
246
 
202
- db.execute(<<~SQL)
247
+ execute_setup(db, deadline, <<~SQL)
203
248
  CREATE INDEX IF NOT EXISTS idx_spans_created_at ON flare_spans(created_at)
204
249
  SQL
205
250
 
206
- db.execute(<<~SQL)
251
+ execute_setup(db, deadline, <<~SQL)
207
252
  CREATE TABLE IF NOT EXISTS flare_events (
208
253
  id INTEGER PRIMARY KEY AUTOINCREMENT,
209
254
  span_id INTEGER NOT NULL,
@@ -214,11 +259,11 @@ module Flare
214
259
  )
215
260
  SQL
216
261
 
217
- db.execute(<<~SQL)
262
+ execute_setup(db, deadline, <<~SQL)
218
263
  CREATE INDEX IF NOT EXISTS idx_events_span_id ON flare_events(span_id)
219
264
  SQL
220
265
 
221
- db.execute(<<~SQL)
266
+ execute_setup(db, deadline, <<~SQL)
222
267
  CREATE TABLE IF NOT EXISTS flare_properties (
223
268
  id INTEGER PRIMARY KEY AUTOINCREMENT,
224
269
  key TEXT NOT NULL,
@@ -231,27 +276,37 @@ module Flare
231
276
  )
232
277
  SQL
233
278
 
234
- db.execute(<<~SQL)
279
+ execute_setup(db, deadline, <<~SQL)
235
280
  CREATE INDEX IF NOT EXISTS idx_properties_owner ON flare_properties(owner_type, owner_id)
236
281
  SQL
237
282
 
238
- db.execute(<<~SQL)
283
+ execute_setup(db, deadline, <<~SQL)
239
284
  CREATE INDEX IF NOT EXISTS idx_properties_key ON flare_properties(key)
240
285
  SQL
241
286
 
242
287
  close_connection # avoid inheriting connection across fork
243
288
  @setup = true
289
+ ensure
290
+ @mutex.unlock
244
291
  end
245
292
  end
246
293
 
247
294
  # Applies the same SQLite pragmas that ActiveRecord uses for good
248
295
  # concurrency and performance with threaded/multi-process access.
249
- def configure_pragmas(db)
250
- db.execute("PRAGMA journal_mode=WAL")
251
- db.execute("PRAGMA synchronous=NORMAL")
252
- db.execute("PRAGMA mmap_size=134217728") # 128MB
253
- db.execute("PRAGMA journal_size_limit=67108864") # 64MB
254
- db.execute("PRAGMA cache_size=2000")
296
+ def configure_pragmas(db, deadline)
297
+ execute_setup(db, deadline, "PRAGMA journal_mode=WAL")
298
+ execute_setup(db, deadline, "PRAGMA synchronous=NORMAL")
299
+ execute_setup(db, deadline, "PRAGMA mmap_size=134217728") # 128MB
300
+ execute_setup(db, deadline, "PRAGMA journal_size_limit=67108864") # 64MB
301
+ execute_setup(db, deadline, "PRAGMA cache_size=2000")
302
+ end
303
+
304
+ def execute_setup(db, deadline, statement)
305
+ raise ExportDeadlineExceeded if deadline.expired?
306
+
307
+ apply_busy_timeout(deadline)
308
+ db.execute(statement)
309
+ raise ExportDeadlineExceeded if deadline.expired?
255
310
  end
256
311
 
257
312
  def connection_key