flare 0.3.1 → 0.4.1

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.
@@ -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
@@ -12,6 +12,7 @@ require_relative "client_headers"
12
12
  require_relative "sampler"
13
13
  require_relative "trace_blob"
14
14
  require_relative "http_transport"
15
+ require_relative "deadline"
15
16
 
16
17
  module Flare
17
18
  # Custom OTel exporter. For each batch FilteringSpanProcessor hands over:
@@ -33,6 +34,7 @@ module Flare
33
34
  class TraceExporter
34
35
  SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
35
36
  FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
37
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
36
38
 
37
39
  PUT_HEADERS = {
38
40
  "Content-Type" => "application/json",
@@ -58,16 +60,22 @@ module Flare
58
60
  end
59
61
 
60
62
  def export(spans, timeout: nil)
63
+ deadline = Deadline.new(timeout)
61
64
  grouped = spans.group_by(&:trace_id)
62
65
  return SUCCESS if grouped.empty?
63
66
 
64
67
  overall = SUCCESS
65
68
  grouped.each do |trace_id, group|
66
- result = ship(TraceBlob.build(trace_id: trace_id, spans: group))
69
+ return TIMEOUT if deadline.expired?
70
+
71
+ result = ship(TraceBlob.build(trace_id: trace_id, spans: group), deadline: deadline)
72
+ return TIMEOUT if result == TIMEOUT || deadline.expired?
67
73
  overall = FAILURE if result == FAILURE
68
74
  end
69
75
  overall
70
76
  rescue StandardError => e
77
+ return TIMEOUT if deadline&.expired? || e.is_a?(HttpTransport::DeadlineExceeded)
78
+
71
79
  @exception_count.increment
72
80
  @logger.warn("[Flare::TraceExporter] export raised: #{e.class}: #{e.message}")
73
81
  FAILURE
@@ -83,8 +91,9 @@ module Flare
83
91
 
84
92
  private
85
93
 
86
- def ship(blob, retried: false)
94
+ def ship(blob, deadline:, retried: false)
87
95
  return FAILURE if blob.nil?
96
+ return TIMEOUT if deadline.expired?
88
97
 
89
98
  entry = @pool.checkout
90
99
  if entry.nil?
@@ -93,29 +102,55 @@ module Flare
93
102
  end
94
103
 
95
104
  body = gzip(JSON.generate(blob.to_h))
96
- response = @transport.put(entry[:put_url], body, PUT_HEADERS)
105
+ response = transport_request(:put, entry[:put_url], body, PUT_HEADERS, timeout: deadline.remaining)
106
+ return TIMEOUT if deadline.expired?
97
107
 
98
108
  case response.code
99
109
  when "200", "204"
100
- notify(entry[:key])
101
- SUCCESS
110
+ notify(entry[:key], deadline: deadline)
102
111
  when "403"
103
112
  # Presigned URL probably expired; try once more with the next one.
104
- retried ? record_put_failure(response) : ship(blob, retried: true)
113
+ retried ? record_put_failure(response) : ship(blob, deadline: deadline, retried: true)
105
114
  else
106
115
  record_put_failure(response)
107
116
  end
108
117
  end
109
118
 
110
- def notify(key)
111
- response = @transport.post(@notify_url, JSON.generate(key: key), notify_headers)
112
- return if response.code == "202"
119
+ def notify(key, deadline:)
120
+ return TIMEOUT if deadline.expired?
121
+
122
+ response = transport_request(
123
+ :post,
124
+ @notify_url,
125
+ JSON.generate(key: key),
126
+ notify_headers,
127
+ timeout: deadline.remaining
128
+ )
129
+ return TIMEOUT if deadline.expired?
130
+ return SUCCESS if response.code == "202"
113
131
 
114
132
  @notify_failure_count.increment
115
133
  @logger.warn("[Flare::TraceExporter] notify failed: HTTP #{response.code}")
134
+ SUCCESS
116
135
  rescue StandardError => e
136
+ return TIMEOUT if deadline.expired? || e.is_a?(HttpTransport::DeadlineExceeded)
137
+
117
138
  @notify_failure_count.increment
118
139
  @logger.warn("[Flare::TraceExporter] notify exception: #{e.class}: #{e.message}")
140
+ SUCCESS
141
+ end
142
+
143
+ def transport_request(method, *args, timeout:)
144
+ parameters = @transport.method(method).parameters
145
+ accepts_timeout = parameters.any? do |type, name|
146
+ type == :keyrest || ([:key, :keyreq].include?(type) && name == :timeout)
147
+ end
148
+
149
+ if accepts_timeout
150
+ @transport.public_send(method, *args, timeout: timeout)
151
+ else
152
+ @transport.public_send(method, *args)
153
+ end
119
154
  end
120
155
 
121
156
  # Identifies the client on the Flare-API notify POST. The presigned R2
data/lib/flare/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flare
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/flare.rb CHANGED
@@ -13,6 +13,8 @@ require_relative "flare/metric_span_processor"
13
13
  require_relative "flare/metric_flusher"
14
14
  require_relative "flare/backoff_policy"
15
15
  require_relative "flare/metric_submitter"
16
+ require_relative "flare/recording_batch_span_processor"
17
+ require_relative "flare/lifecycle"
16
18
 
17
19
  require_relative "flare/sampler"
18
20
  require_relative "flare/marker"
@@ -88,7 +90,7 @@ module Flare
88
90
  end
89
91
 
90
92
  def span_processor
91
- @span_processor ||= OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
93
+ @span_processor ||= RecordingBatchSpanProcessor.new(
92
94
  exporter,
93
95
  max_queue_size: 1000,
94
96
  max_export_batch_size: 100,
@@ -228,20 +230,7 @@ module Flare
228
230
  subscribe_to_notifications
229
231
  end
230
232
 
231
- at_exit do
232
- log "Shutting down..."
233
- if configuration.spans_enabled && @span_processor
234
- span_processor.force_flush
235
- span_processor.shutdown
236
- log "Span processor flushed and stopped"
237
- end
238
- if @trace_span_processor
239
- @trace_span_processor.force_flush
240
- @trace_span_processor.shutdown
241
- log "Trace span processor flushed and stopped"
242
- end
243
- log "Shutdown complete"
244
- end
233
+ install_shutdown_hook
245
234
 
246
235
  @otel_configured = true
247
236
  end
@@ -270,7 +259,7 @@ module Flare
270
259
  @rule_manager.start
271
260
  log "Rule manager started (poll=#{configuration.tracing_poll_interval}s)"
272
261
 
273
- at_exit { @rule_manager&.stop }
262
+ install_shutdown_hook
274
263
  end
275
264
 
276
265
  def setup_tracing_components
@@ -355,7 +344,7 @@ module Flare
355
344
  @metric_flusher.start
356
345
  log "Metrics flusher started (interval=#{configuration.metrics_flush_interval}s)"
357
346
 
358
- at_exit { @metric_flusher&.stop }
347
+ install_shutdown_hook
359
348
  else
360
349
  log "Metrics submission not configured (missing url or key)"
361
350
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flare
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
@@ -198,10 +198,12 @@ files:
198
198
  - lib/flare/cli/status_command.rb
199
199
  - lib/flare/client_headers.rb
200
200
  - lib/flare/configuration.rb
201
+ - lib/flare/deadline.rb
201
202
  - lib/flare/engine.rb
202
203
  - lib/flare/filtering_span_processor.rb
203
204
  - lib/flare/http_metrics_config.rb
204
205
  - lib/flare/http_transport.rb
206
+ - lib/flare/lifecycle.rb
205
207
  - lib/flare/marker.rb
206
208
  - lib/flare/metric_counter.rb
207
209
  - lib/flare/metric_flusher.rb
@@ -209,6 +211,7 @@ files:
209
211
  - lib/flare/metric_span_processor.rb
210
212
  - lib/flare/metric_storage.rb
211
213
  - lib/flare/metric_submitter.rb
214
+ - lib/flare/recording_batch_span_processor.rb
212
215
  - lib/flare/rule_manager.rb
213
216
  - lib/flare/sampler.rb
214
217
  - lib/flare/source_location.rb
@@ -245,7 +248,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
245
248
  - !ruby/object:Gem::Version
246
249
  version: '0'
247
250
  requirements: []
248
- rubygems_version: 4.0.7
251
+ rubygems_version: 4.0.18
249
252
  specification_version: 4
250
253
  summary: Light up what's slowing you down
251
254
  test_files: []