flare 0.2.0 → 0.3.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.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_reference"
4
+
5
+ module Flare
6
+ # Thread-safe pool of presigned R2 PUT URLs the RuleManager fills from
7
+ # the /api/rules response. TraceExporter checks one out before each
8
+ # upload; if the pool is empty (no active rules, no fresh URLs) it
9
+ # returns nil and the exporter gives up on that batch -- caller decides
10
+ # what to do.
11
+ #
12
+ # Each entry is a Hash: { upload_id:, key:, put_url:, expires_at: }.
13
+ # expires_at is a Time; entries past their expiry are skipped on checkout.
14
+ #
15
+ # Fork-safe: after_fork clears the pool so child processes don't reuse
16
+ # parent URLs (each child polls its own copy from /api/rules anyway).
17
+ class UploadUrlPool
18
+ attr_reader :checkouts, :empty_count, :expired_count
19
+
20
+ def initialize
21
+ @entries_ref = Concurrent::AtomicReference.new([].freeze)
22
+ @checkouts = Concurrent::AtomicFixnum.new(0)
23
+ @empty_count = Concurrent::AtomicFixnum.new(0)
24
+ @expired_count = Concurrent::AtomicFixnum.new(0)
25
+ end
26
+
27
+ def replace(entries)
28
+ normalized = (entries || []).filter_map { |raw| normalize(raw) }
29
+ @entries_ref.set(normalized.freeze)
30
+ end
31
+
32
+ def checkout
33
+ now = Time.now
34
+ loop do
35
+ current = @entries_ref.get
36
+ if current.empty?
37
+ @empty_count.increment
38
+ return nil
39
+ end
40
+
41
+ candidate, *rest = current
42
+ next_state = rest.freeze
43
+ next unless @entries_ref.compare_and_set(current, next_state)
44
+
45
+ if expired?(candidate, now)
46
+ @expired_count.increment
47
+ next # try the next one
48
+ end
49
+
50
+ @checkouts.increment
51
+ return candidate
52
+ end
53
+ end
54
+
55
+ def size
56
+ @entries_ref.get.length
57
+ end
58
+
59
+ def empty?
60
+ size.zero?
61
+ end
62
+
63
+ def clear
64
+ @entries_ref.set([].freeze)
65
+ end
66
+
67
+ # Drop URLs that have already passed their expires_at. Cheap; safe to
68
+ # call from RuleManager's scheduler in between polls.
69
+ def sweep
70
+ now = Time.now
71
+ current = @entries_ref.get
72
+ live = current.reject { |e| expired?(e, now) }
73
+ return 0 if live.length == current.length
74
+
75
+ @entries_ref.set(live.freeze)
76
+ current.length - live.length
77
+ end
78
+
79
+ # Call from Flare.after_fork. Parent's URLs aren't usable from the
80
+ # child's point of view (each child should get its own from a fresh
81
+ # /api/rules poll), so just drop them.
82
+ def after_fork
83
+ clear
84
+ end
85
+
86
+ private
87
+
88
+ def normalize(raw)
89
+ h = raw.is_a?(Hash) ? raw : nil
90
+ return nil unless h
91
+
92
+ upload_id = h[:upload_id] || h["upload_id"]
93
+ key = h[:key] || h["key"]
94
+ put_url = h[:put_url] || h["put_url"]
95
+ expires_at = h[:expires_at] || h["expires_at"]
96
+ return nil if upload_id.nil? || key.nil? || put_url.nil?
97
+
98
+ expires_at = Time.iso8601(expires_at) if expires_at.is_a?(String)
99
+ { upload_id: upload_id, key: key, put_url: put_url, expires_at: expires_at }
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def expired?(entry, now)
105
+ entry[:expires_at] && entry[:expires_at] <= now
106
+ end
107
+ end
108
+ end
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.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ module Flare
6
+ # Path 2: ActiveSupport::Notifications subscriber that fires on
7
+ # `start_processing.action_controller`, after Rails has routed to a
8
+ # controller#action. At that point the rack server span's start
9
+ # attributes don't yet carry code.namespace/code.function -- only the
10
+ # ActionPack instrumentation adds them, and Flare::Sampler's start-time
11
+ # decision (RECORD_ONLY) was already locked in.
12
+ #
13
+ # The subscriber consults the same sampler's rule set, finds any whose
14
+ # match_attributes match the now-known controller/action, applies the
15
+ # deterministic trace_id_ratio gate (CAF-1: no rate bypass on Path 2),
16
+ # and on pass calls marker.mark(trace_id, owner_span_id:, rule_id:).
17
+ # FilteringSpanProcessor then forwards every span in the trace to the
18
+ # exporter and unmarks when the owner (this rack span) finishes.
19
+ class WebMarkerSubscriber
20
+ NOTIFICATION = "start_processing.action_controller"
21
+
22
+ def initialize(sampler:, marker:)
23
+ @sampler = sampler
24
+ @marker = marker
25
+ end
26
+
27
+ def start
28
+ @subscriber = ActiveSupport::Notifications.subscribe(NOTIFICATION) do |*, payload|
29
+ handle(payload)
30
+ end
31
+ self
32
+ end
33
+
34
+ def stop
35
+ ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
36
+ @subscriber = nil
37
+ self
38
+ end
39
+
40
+ # Public for tests so they don't have to drive ActiveSupport::Notifications.
41
+ # current_span lets tests inject a context; in production it's the
42
+ # rack server span on the current thread.
43
+ def handle(payload, current_span: OpenTelemetry::Trace.current_span)
44
+ return unless current_span
45
+ ctx = current_span.context
46
+ return unless ctx && ctx.valid?
47
+
48
+ attrs = candidate_attributes(payload)
49
+ return if attrs.empty?
50
+
51
+ @sampler.rules.each do |rule|
52
+ next unless matches?(rule, attrs)
53
+ next unless @sampler.trace_id_ratio(ctx.trace_id) < rule.rate
54
+
55
+ current_span.set_attribute(Flare::Sampler::RULE_ID_ATTRIBUTE, rule.id) if current_span.respond_to?(:set_attribute)
56
+ @marker.mark(ctx.trace_id, owner_span_id: ctx.span_id, rule_id: rule.id)
57
+ break
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def candidate_attributes(payload)
64
+ controller = payload[:controller] || payload["controller"]
65
+ action = payload[:action] || payload["action"]
66
+ {
67
+ "code.namespace" => controller,
68
+ "code.function" => action
69
+ }.compact
70
+ end
71
+
72
+ def matches?(rule, attrs)
73
+ rule.match_attributes.all? { |k, v| attrs[k] == v }
74
+ end
75
+ end
76
+ end
data/lib/flare.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "flare/configuration"
5
5
 
6
6
  require "opentelemetry/sdk"
7
7
 
8
+ require_relative "flare/client_headers"
8
9
  require_relative "flare/source_location"
9
10
  require_relative "flare/metric_key"
10
11
  require_relative "flare/metric_storage"
@@ -13,6 +14,15 @@ require_relative "flare/metric_flusher"
13
14
  require_relative "flare/backoff_policy"
14
15
  require_relative "flare/metric_submitter"
15
16
 
17
+ require_relative "flare/sampler"
18
+ require_relative "flare/marker"
19
+ require_relative "flare/web_marker_subscriber"
20
+ require_relative "flare/filtering_span_processor"
21
+ require_relative "flare/upload_url_pool"
22
+ require_relative "flare/trace_exporter"
23
+ require_relative "flare/rule_manager"
24
+ require_relative "flare/trace_health_reporter"
25
+
16
26
  module Flare
17
27
  class Error < StandardError; end
18
28
 
@@ -114,15 +124,43 @@ module Flare
114
124
  @metric_flusher = flusher
115
125
  end
116
126
 
127
+ # Trace-sampling components, exposed for tests + manual after_fork wiring.
128
+ def sampler = @sampler
129
+ def marker = @marker
130
+ def upload_url_pool = @upload_url_pool
131
+ def rule_manager = @rule_manager
132
+ def trace_span_processor = @trace_span_processor
133
+ def trace_health_reporter = @trace_health_reporter
134
+
117
135
  # Manually flush metrics (useful for testing or forced flushes).
118
136
  def flush_metrics
119
137
  @metric_flusher&.flush_now || 0
120
138
  end
121
139
 
122
- # Re-initialize metric flusher after fork.
140
+ # Default project key, derived from the host Rails app's module name.
141
+ # Customers can override by configuring something else once we expose
142
+ # configuration.project; for v0.3 this matches MetricSubmitter's behavior.
143
+ def service_name_for_app
144
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
145
+ Rails.application.class.module_parent_name.underscore rescue "rails_app"
146
+ else
147
+ "app"
148
+ end
149
+ end
150
+
151
+ def rails_env_name
152
+ if defined?(Rails) && Rails.respond_to?(:env)
153
+ Rails.env.to_s
154
+ else
155
+ ENV.fetch("RACK_ENV", "development")
156
+ end
157
+ end
158
+
159
+ # Re-initialize background threads after fork.
123
160
  # Call this from Puma/Unicorn after_fork hooks.
124
161
  def after_fork
125
162
  @metric_flusher&.after_fork
163
+ @rule_manager&.after_fork
126
164
  end
127
165
 
128
166
  # Configure OpenTelemetry SDK and instrumentations. Must run before the
@@ -135,11 +173,7 @@ module Flare
135
173
  # Suppress noisy OTel INFO logs
136
174
  OpenTelemetry.logger = Logger.new(STDOUT, level: Logger::WARN)
137
175
 
138
- service_name = if defined?(Rails) && Rails.application
139
- Rails.application.class.module_parent_name.underscore rescue "rails_app"
140
- else
141
- "app"
142
- end
176
+ service_name = service_name_for_app
143
177
 
144
178
  # Require flare's bundled instrumentations
145
179
  require "opentelemetry-instrumentation-rack"
@@ -201,12 +235,98 @@ module Flare
201
235
  span_processor.shutdown
202
236
  log "Span processor flushed and stopped"
203
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
204
243
  log "Shutdown complete"
205
244
  end
206
245
 
207
246
  @otel_configured = true
208
247
  end
209
248
 
249
+ # Start the trace-rules poller. Polls GET /api/rules every
250
+ # tracing_poll_interval (default 30s) so the in-process sampler + URL
251
+ # pool stay current. Called from config.after_initialize -- after the
252
+ # user's configure block has run -- so configuration.url / .key /
253
+ # .tracing_enabled are settled.
254
+ def start_rule_manager
255
+ return unless configuration.tracing_submission_configured?
256
+
257
+ setup_tracing_components
258
+ return unless @sampler && @marker && @upload_url_pool
259
+
260
+ @rule_manager = RuleManager.new(
261
+ sampler: @sampler,
262
+ marker: @marker,
263
+ pool: @upload_url_pool,
264
+ base_url: configuration.url,
265
+ api_key: configuration.key,
266
+ project: service_name_for_app,
267
+ environment: rails_env_name,
268
+ interval: configuration.tracing_poll_interval
269
+ )
270
+ @rule_manager.start
271
+ log "Rule manager started (poll=#{configuration.tracing_poll_interval}s)"
272
+
273
+ at_exit { @rule_manager&.stop }
274
+ end
275
+
276
+ def setup_tracing_components
277
+ return if @trace_span_processor
278
+
279
+ @sampler = Sampler.new
280
+ @marker = Marker.new
281
+ @upload_url_pool = UploadUrlPool.new
282
+
283
+ # Trace sampling: server-controlled per-route capture. The sampler runs
284
+ # at span start; for routes it can't decide there (Rails web spans get
285
+ # their controller#action attributes set post-routing) the marker +
286
+ # WebMarkerSubscriber handle it. The RECORD_ONLY delegates keep children
287
+ # of unsampled local and remote parents recording so processors still see
288
+ # web requests that arrive with an unsampled traceparent header.
289
+ #
290
+ # Sampler is set on the tracer_provider AFTER SDK.configure -- the SDK's
291
+ # Configurator block doesn't expose a `sampler=`; the provider does.
292
+ OpenTelemetry.tracer_provider.sampler =
293
+ OpenTelemetry::SDK::Trace::Samplers.parent_based(
294
+ root: @sampler,
295
+ remote_parent_sampled: ALWAYS_RECORD_ONLY,
296
+ remote_parent_not_sampled: ALWAYS_RECORD_ONLY,
297
+ local_parent_not_sampled: ALWAYS_RECORD_ONLY
298
+ )
299
+
300
+ @trace_exporter = TraceExporter.new(
301
+ pool: @upload_url_pool,
302
+ notify_url: "#{configuration.url.to_s.chomp('/')}/api/traces",
303
+ api_key: configuration.key,
304
+ project: service_name_for_app,
305
+ environment: rails_env_name
306
+ )
307
+
308
+ @trace_span_processor = FilteringSpanProcessor.new(
309
+ exporter: @trace_exporter,
310
+ marker: @marker,
311
+ max_queue: configuration.tracing_max_queue
312
+ )
313
+ OpenTelemetry.tracer_provider.add_span_processor(@trace_span_processor)
314
+
315
+ @trace_health_reporter = TraceHealthReporter.new(
316
+ processor: @trace_span_processor,
317
+ pool: @upload_url_pool,
318
+ exporter: @trace_exporter
319
+ )
320
+
321
+ # Path 2 trace marking. Rails-only -- in non-Rails contexts the
322
+ # subscriber would never fire but creating it is harmless.
323
+ if defined?(ActiveSupport::Notifications)
324
+ @web_marker_subscriber = WebMarkerSubscriber.new(sampler: @sampler, marker: @marker).start
325
+ end
326
+
327
+ log "Tracing enabled (poll=#{configuration.tracing_poll_interval}s)"
328
+ end
329
+
210
330
  # Start the metrics flusher. Called from config.after_initialize so
211
331
  # user configuration (metrics_enabled, flush_interval, etc.) is applied.
212
332
  def start_metrics_flusher
@@ -229,7 +349,8 @@ module Flare
229
349
  @metric_flusher = MetricFlusher.new(
230
350
  storage: @metric_storage,
231
351
  submitter: submitter,
232
- interval: configuration.metrics_flush_interval
352
+ interval: configuration.metrics_flush_interval,
353
+ health_reporters: @trace_health_reporter ? [@trace_health_reporter] : []
233
354
  )
234
355
  @metric_flusher.start
235
356
  log "Metrics flusher started (interval=#{configuration.metrics_flush_interval}s)"
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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
@@ -196,20 +196,31 @@ files:
196
196
  - lib/flare/cli/output.rb
197
197
  - lib/flare/cli/setup_command.rb
198
198
  - lib/flare/cli/status_command.rb
199
+ - lib/flare/client_headers.rb
199
200
  - lib/flare/configuration.rb
200
201
  - lib/flare/engine.rb
202
+ - lib/flare/filtering_span_processor.rb
201
203
  - lib/flare/http_metrics_config.rb
204
+ - lib/flare/http_transport.rb
205
+ - lib/flare/marker.rb
202
206
  - lib/flare/metric_counter.rb
203
207
  - lib/flare/metric_flusher.rb
204
208
  - lib/flare/metric_key.rb
205
209
  - lib/flare/metric_span_processor.rb
206
210
  - lib/flare/metric_storage.rb
207
211
  - lib/flare/metric_submitter.rb
212
+ - lib/flare/rule_manager.rb
213
+ - lib/flare/sampler.rb
208
214
  - lib/flare/source_location.rb
209
215
  - lib/flare/sqlite_exporter.rb
210
216
  - lib/flare/storage.rb
211
217
  - lib/flare/storage/sqlite.rb
218
+ - lib/flare/trace_blob.rb
219
+ - lib/flare/trace_exporter.rb
220
+ - lib/flare/trace_health_reporter.rb
221
+ - lib/flare/upload_url_pool.rb
212
222
  - lib/flare/version.rb
223
+ - lib/flare/web_marker_subscriber.rb
213
224
  - public/flare-assets/flare.css
214
225
  - public/flare-assets/images/flipper.png
215
226
  homepage: https://github.com/jnunemaker/flare