Module: Flare

Defined in:
lib/flare.rb,
lib/flare/cli.rb,
lib/flare/engine.rb,
lib/flare/marker.rb,
lib/flare/sampler.rb,
lib/flare/storage.rb,
lib/flare/version.rb,
lib/flare/cli/output.rb,
lib/flare/metric_key.rb,
lib/flare/trace_blob.rb,
lib/flare/rule_manager.rb,
lib/flare/configuration.rb,
lib/flare/backoff_policy.rb,
lib/flare/http_transport.rb,
lib/flare/metric_counter.rb,
lib/flare/metric_flusher.rb,
lib/flare/metric_storage.rb,
lib/flare/storage/sqlite.rb,
lib/flare/trace_exporter.rb,
lib/flare/source_location.rb,
lib/flare/sqlite_exporter.rb,
lib/flare/upload_url_pool.rb,
lib/flare/metric_submitter.rb,
lib/flare/cli/setup_command.rb,
lib/flare/cli/doctor_command.rb,
lib/flare/cli/status_command.rb,
lib/flare/http_metrics_config.rb,
lib/flare/metric_span_processor.rb,
lib/flare/trace_health_reporter.rb,
lib/flare/web_marker_subscriber.rb,
lib/flare/filtering_span_processor.rb,
app/helpers/flare/application_helper.rb,
app/controllers/flare/jobs_controller.rb,
app/controllers/flare/spans_controller.rb,
app/controllers/flare/requests_controller.rb,
app/controllers/flare/application_controller.rb

Defined Under Namespace

Modules: ApplicationHelper, CLI, SourceLocation, Storage Classes: AlwaysRecordOnly, ApplicationController, BackoffPolicy, Configuration, DoctorCommand, Engine, Error, FilteringSpanProcessor, HttpMetricsConfig, HttpTransport, JobsController, Marker, MetricCounter, MetricFlusher, MetricKey, MetricSpanProcessor, MetricStorage, MetricSubmitter, RequestsController, RuleManager, SQLiteExporter, Sampler, SetupCommand, SpansController, StatusCommand, TraceBlob, TraceExporter, TraceHealthReporter, UploadUrlPool, WebMarkerSubscriber

Constant Summary collapse

MISSING_PARENT_ID =
"0000000000000000"
TRANSACTION_NAME_ATTRIBUTE =
"flare.transaction_name"
NOTIFICATION_TRANSFORMERS =

Payload transformers for different notification types

{
  "sql.active_record" => ->(payload) {
    attrs = {}
    attrs["db.system"] = payload[:connection]&.adapter_name&.downcase rescue nil
    attrs["db.statement"] = payload[:sql] if payload[:sql]
    attrs["name"] = payload[:name] if payload[:name]
    attrs["db.name"] = payload[:connection]&.pool&.db_config&.name rescue nil
    # Capture source location (app code that triggered this query)
    SourceLocation.add_to_attributes(attrs)
    attrs
  },
  "instantiation.active_record" => ->(payload) {
    attrs = {}
    attrs["record_count"] = payload[:record_count] if payload[:record_count]
    attrs["class_name"] = payload[:class_name] if payload[:class_name]
    attrs
  },
  "cache_read.active_support" => ->(payload) {
    store = payload[:store]
    store_name = store.is_a?(String) ? store : store&.class&.name
    { "key" => payload[:key]&.to_s, "hit" => payload[:hit], "store" => store_name }
  },
  "cache_write.active_support" => ->(payload) {
    store = payload[:store]
    store_name = store.is_a?(String) ? store : store&.class&.name
    { "key" => payload[:key]&.to_s, "store" => store_name }
  },
  "cache_delete.active_support" => ->(payload) {
    store = payload[:store]
    store_name = store.is_a?(String) ? store : store&.class&.name
    { "key" => payload[:key]&.to_s, "store" => store_name }
  },
  "cache_exist?.active_support" => ->(payload) {
    store = payload[:store]
    store_name = store.is_a?(String) ? store : store&.class&.name
    { "key" => payload[:key]&.to_s, "exist" => payload[:exist], "store" => store_name }
  },
  "cache_fetch_hit.active_support" => ->(payload) {
    store = payload[:store]
    store_name = store.is_a?(String) ? store : store&.class&.name
    { "key" => payload[:key]&.to_s, "store" => store_name }
  },
  "deliver.action_mailer" => ->(payload) {
    attrs = {}
    attrs["mailer"] = payload[:mailer] if payload[:mailer]
    attrs["message_id"] = payload[:message_id] if payload[:message_id]
    attrs["to"] = Array(payload[:to]).join(", ") if payload[:to]
    attrs["subject"] = payload[:subject] if payload[:subject]
    attrs
  },
  "process.action_mailer" => ->(payload) {
    attrs = {}
    attrs["mailer"] = payload[:mailer] if payload[:mailer]
    attrs["action"] = payload[:action] if payload[:action]
    attrs
  }
}.freeze
ALWAYS_RECORD_ONLY =
AlwaysRecordOnly.new
VERSION =
"0.3.0"

Class Method Summary collapse

Class Method Details

.after_forkObject

Re-initialize background threads after fork. Call this from Puma/Unicorn after_fork hooks.



160
161
162
163
# File 'lib/flare.rb', line 160

def after_fork
  @metric_flusher&.after_fork
  @rule_manager&.after_fork
end

.configurationObject



33
34
35
# File 'lib/flare.rb', line 33

def configuration
  @configuration ||= Configuration.new
end

.configure {|configuration| ... } ⇒ Object

Yields:



37
38
39
# File 'lib/flare.rb', line 37

def configure
  yield(configuration) if block_given?
end

.configure_opentelemetryObject

Configure OpenTelemetry SDK and instrumentations. Must run before the middleware stack is built so Rack/ActionPack can insert their middleware. Note: metrics flusher is started separately via start_metrics_flusher after user initializers have run.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/flare.rb', line 169

def configure_opentelemetry
  return if @otel_configured

  # Suppress noisy OTel INFO logs
  OpenTelemetry.logger = Logger.new(STDOUT, level: Logger::WARN)

  service_name = service_name_for_app

  # Require flare's bundled instrumentations
  require "opentelemetry-instrumentation-rack"
  require "opentelemetry-instrumentation-net_http"
  require "opentelemetry-instrumentation-active_support"
  require "opentelemetry/instrumentation/active_support/span_subscriber"
  require "opentelemetry-instrumentation-action_pack" if defined?(ActionController)
  require "opentelemetry-instrumentation-action_view" if defined?(ActionView)
  require "opentelemetry-instrumentation-active_job" if defined?(ActiveJob)

  # Tell the SDK not to try configuring OTLP from env vars.
  # Flare manages its own exporters (SQLite for spans, HTTP for metrics).
  ENV["OTEL_TRACES_EXPORTER"] ||= "none"

  log "Configuring OpenTelemetry (service=#{service_name})"

  OpenTelemetry::SDK.configure do |c|
    c.service_name = service_name

    # Spans: detailed trace data stored in SQLite
    if configuration.spans_enabled && exporter
      c.add_span_processor(span_processor)
      log "Spans enabled (database=#{configuration.database_path})"
    end

    # Auto-detect and install all OTel instrumentation gems in the bundle.
    # Apps can add gems like opentelemetry-instrumentation-sidekiq to their
    # Gemfile and they'll be picked up automatically.
    c.use_all(
      "OpenTelemetry::Instrumentation::Rack" => {
        untraced_requests: ->(env) {
          request = Rack::Request.new(env)
          return true if request.path.start_with?("/flare")

          configuration.ignore_request.call(request)
        }
      },
      # Name Sidekiq job spans after the worker class (e.g. "MyWorker
      # process") instead of the upstream default of the queue name
      # ("default process"), matching how ActiveJob spans are named.
      "OpenTelemetry::Instrumentation::Sidekiq" => {
        span_naming: :job_class,
      }
    )
  end

  # Subscribe to common ActiveSupport notification patterns
  # This captures SQL, cache, mailer, and custom notifications.
  # Required for both spans (detailed traces) and metrics (aggregated counters)
  # because DB, cache, and mailer data flows through ActiveSupport notifications.
  if configuration.spans_enabled || configuration.metrics_enabled
    subscribe_to_notifications
  end

  at_exit do
    log "Shutting down..."
    if configuration.spans_enabled && @span_processor
      span_processor.force_flush
      span_processor.shutdown
      log "Span processor flushed and stopped"
    end
    if @trace_span_processor
      @trace_span_processor.force_flush
      @trace_span_processor.shutdown
      log "Trace span processor flushed and stopped"
    end
    log "Shutdown complete"
  end

  @otel_configured = true
end

.enabled?Boolean

Returns:

  • (Boolean)


41
42
43
# File 'lib/flare.rb', line 41

def enabled?
  configuration.enabled
end

.exporterObject



74
75
76
77
78
79
80
81
82
83
# File 'lib/flare.rb', line 74

def exporter
  @exporter ||= begin
    require_relative "flare/sqlite_exporter"
    SQLiteExporter.new(configuration.database_path)
  rescue LoadError
    warn "[Flare] sqlite3 gem not found. Spans are disabled. Add `gem 'sqlite3'` to your Gemfile to enable the development dashboard."
    configuration.spans_enabled = false
    nil
  end
end

.exporter=(exporter) ⇒ Object



85
86
87
# File 'lib/flare.rb', line 85

def exporter=(exporter)
  @exporter = exporter
end

.flush_metricsObject

Manually flush metrics (useful for testing or forced flushes).



135
136
137
# File 'lib/flare.rb', line 135

def flush_metrics
  @metric_flusher&.flush_now || 0
end

.instrument(name, attributes = {}) { ... } ⇒ Object

Instrument a block of code, creating a span that shows up in Flare

NOTE: This method only works when Flare is loaded (typically development). For instrumentation that works in all environments, use ActiveSupport::Notifications directly and subscribe with Flare.subscribe in your initializer.

Examples:

Basic usage (dev only)

Flare.instrument("geocoding.lookup") do
  geocoder.lookup(address)
end

For all environments, use ActiveSupport::Notifications instead:

# In your app code (works everywhere):
ActiveSupport::Notifications.instrument("myapp.geocoding", address: addr) do
  geocoder.lookup(addr)
end

# In config/initializers/flare.rb (only loaded in dev):
Flare.subscribe("myapp.geocoding")

Parameters:

  • name (String)

    The name of the span (e.g., “my_service.call”, “external_api.fetch”)

  • attributes (Hash) (defaults to: {})

    Optional attributes to add to the span

Yields:

  • The block to instrument

Returns:

  • The return value of the block



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
# File 'lib/flare.rb', line 496

def instrument(name, attributes = {}, &block)
  return yield unless enabled?

  # Add source location
  location = SourceLocation.find
  if location
    attributes["code.filepath"] = location[:filepath]
    attributes["code.lineno"] = location[:lineno]
    attributes["code.function"] = location[:function] if location[:function]
  end

  tracer.in_span(name, attributes: attributes, kind: :internal) do |span|
    yield span
  end
end

.log(message) ⇒ Object



68
69
70
71
72
# File 'lib/flare.rb', line 68

def log(message)
  return unless configuration.debug

  logger.info("[Flare] #{message}")
end

.loggerObject



60
61
62
# File 'lib/flare.rb', line 60

def logger
  @logger ||= Logger.new(STDOUT)
end

.logger=(logger) ⇒ Object



64
65
66
# File 'lib/flare.rb', line 64

def logger=(logger)
  @logger = logger
end

.markerObject



128
# File 'lib/flare.rb', line 128

def marker          = @marker

.metric_flusherObject



118
119
120
# File 'lib/flare.rb', line 118

def metric_flusher
  @metric_flusher
end

.metric_flusher=(flusher) ⇒ Object



122
123
124
# File 'lib/flare.rb', line 122

def metric_flusher=(flusher)
  @metric_flusher = flusher
end

.metric_storageObject



110
111
112
# File 'lib/flare.rb', line 110

def metric_storage
  @metric_storage
end

.metric_storage=(storage) ⇒ Object



114
115
116
# File 'lib/flare.rb', line 114

def metric_storage=(storage)
  @metric_storage = storage
end

.rails_env_nameObject



150
151
152
153
154
155
156
# File 'lib/flare.rb', line 150

def rails_env_name
  if defined?(Rails) && Rails.respond_to?(:env)
    Rails.env.to_s
  else
    ENV.fetch("RACK_ENV", "development")
  end
end

.reset!Object



527
528
529
530
531
532
533
534
535
536
537
# File 'lib/flare.rb', line 527

def reset!
  @configuration = nil
  @exporter = nil
  @span_processor = nil
  @tracer = nil
  @storage = nil
  @metric_flusher&.stop
  @metric_flusher = nil
  @metric_storage = nil
  @otel_configured = false
end

.reset_storage!Object



523
524
525
# File 'lib/flare.rb', line 523

def reset_storage!
  @storage = nil
end

.rule_managerObject



130
# File 'lib/flare.rb', line 130

def rule_manager    = @rule_manager

.samplerObject

Trace-sampling components, exposed for tests + manual after_fork wiring.



127
# File 'lib/flare.rb', line 127

def sampler         = @sampler

.service_name_for_appObject

Default project key, derived from the host Rails app’s module name. Customers can override by configuring something else once we expose configuration.project; for v0.3 this matches MetricSubmitter’s behavior.



142
143
144
145
146
147
148
# File 'lib/flare.rb', line 142

def service_name_for_app
  if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
    Rails.application.class.module_parent_name.underscore rescue "rails_app"
  else
    "app"
  end
end

.setup_tracing_componentsObject



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/flare.rb', line 275

def setup_tracing_components
  return if @trace_span_processor

  @sampler         = Sampler.new
  @marker          = Marker.new
  @upload_url_pool = UploadUrlPool.new

  # Trace sampling: server-controlled per-route capture. The sampler runs
  # at span start; for routes it can't decide there (Rails web spans get
  # their controller#action attributes set post-routing) the marker +
  # WebMarkerSubscriber handle it. The RECORD_ONLY delegates keep children
  # of unsampled local and remote parents recording so processors still see
  # web requests that arrive with an unsampled traceparent header.
  #
  # Sampler is set on the tracer_provider AFTER SDK.configure -- the SDK's
  # Configurator block doesn't expose a `sampler=`; the provider does.
  OpenTelemetry.tracer_provider.sampler =
    OpenTelemetry::SDK::Trace::Samplers.parent_based(
      root: @sampler,
      remote_parent_sampled: ALWAYS_RECORD_ONLY,
      remote_parent_not_sampled: ALWAYS_RECORD_ONLY,
      local_parent_not_sampled: ALWAYS_RECORD_ONLY
    )

  @trace_exporter = TraceExporter.new(
    pool:        @upload_url_pool,
    notify_url:  "#{configuration.url.to_s.chomp('/')}/api/traces",
    api_key:     configuration.key,
    project:     service_name_for_app,
    environment: rails_env_name
  )

  @trace_span_processor = FilteringSpanProcessor.new(
    exporter: @trace_exporter,
    marker: @marker,
    max_queue: configuration.tracing_max_queue
  )
  OpenTelemetry.tracer_provider.add_span_processor(@trace_span_processor)

  @trace_health_reporter = TraceHealthReporter.new(
    processor: @trace_span_processor,
    pool: @upload_url_pool,
    exporter: @trace_exporter
  )

  # Path 2 trace marking. Rails-only -- in non-Rails contexts the
  # subscriber would never fire but creating it is harmless.
  if defined?(ActiveSupport::Notifications)
    @web_marker_subscriber = WebMarkerSubscriber.new(sampler: @sampler, marker: @marker).start
  end

  log "Tracing enabled (poll=#{configuration.tracing_poll_interval}s)"
end

.span_processorObject



89
90
91
92
93
94
95
96
# File 'lib/flare.rb', line 89

def span_processor
  @span_processor ||= OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
    exporter,
    max_queue_size: 1000,
    max_export_batch_size: 100,
    schedule_delay: 1000 # 1 second
  )
end

.span_processor=(span_processor) ⇒ Object



98
99
100
# File 'lib/flare.rb', line 98

def span_processor=(span_processor)
  @span_processor = span_processor
end

.start_metrics_flusherObject

Start the metrics flusher. Called from config.after_initialize so user configuration (metrics_enabled, flush_interval, etc.) is applied.



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/flare.rb', line 331

def start_metrics_flusher
  return unless configuration.metrics_enabled

  @metric_storage ||= MetricStorage.new
  metric_processor = MetricSpanProcessor.new(
    storage: @metric_storage,
    http_metrics_config: configuration.http_metrics_config
  )
  OpenTelemetry.tracer_provider.add_span_processor(metric_processor)

  log "Metrics enabled (endpoint=#{configuration.url} key=#{configuration.key ? 'present' : 'missing'})"

  if configuration.metrics_submission_configured?
    submitter = MetricSubmitter.new(
      endpoint: configuration.url,
      api_key: configuration.key
    )
    @metric_flusher = MetricFlusher.new(
      storage: @metric_storage,
      submitter: submitter,
      interval: configuration.metrics_flush_interval,
      health_reporters: @trace_health_reporter ? [@trace_health_reporter] : []
    )
    @metric_flusher.start
    log "Metrics flusher started (interval=#{configuration.metrics_flush_interval}s)"

    at_exit { @metric_flusher&.stop }
  else
    log "Metrics submission not configured (missing url or key)"
  end
end

.start_rule_managerObject

Start the trace-rules poller. Polls GET /api/rules every tracing_poll_interval (default 30s) so the in-process sampler + URL pool stay current. Called from config.after_initialize – after the user’s configure block has run – so configuration.url / .key / .tracing_enabled are settled.



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/flare.rb', line 253

def start_rule_manager
  return unless configuration.tracing_submission_configured?

  setup_tracing_components
  return unless @sampler && @marker && @upload_url_pool

  @rule_manager = RuleManager.new(
    sampler:     @sampler,
    marker:      @marker,
    pool:        @upload_url_pool,
    base_url:    configuration.url,
    api_key:     configuration.key,
    project:     service_name_for_app,
    environment: rails_env_name,
    interval:    configuration.tracing_poll_interval
  )
  @rule_manager.start
  log "Rule manager started (poll=#{configuration.tracing_poll_interval}s)"

  at_exit { @rule_manager&.stop }
end

.storageObject



512
513
514
515
516
517
518
519
520
521
# File 'lib/flare.rb', line 512

def storage
  @storage ||= begin
    require_relative "flare/storage/sqlite"
    Storage::SQLite.new(configuration.database_path)
  rescue LoadError
    warn "[Flare] sqlite3 gem not found. Dashboard is disabled. Add `gem 'sqlite3'` to your Gemfile to enable it."
    configuration.spans_enabled = false
    nil
  end
end

.subscribe(pattern, &transformer) ⇒ Object

Subscribe to any ActiveSupport::Notification and create spans for it

Examples:

Subscribe to a custom notification

Flare.subscribe("my_service.call")

Subscribe with custom attribute transformer

Flare.subscribe("stripe.charge") do |payload|
  { "charge_id" => payload[:id], "amount" => payload[:amount] }
end

Parameters:

  • pattern (String, Regexp)

    The notification pattern to subscribe to

  • transformer (Proc, nil)

    Optional proc to transform payload into span attributes If nil, all payload keys become span attributes



463
464
465
466
467
468
469
# File 'lib/flare.rb', line 463

def subscribe(pattern, &transformer)
  transformer ||= ->(payload) {
    # Default: convert all payload keys to string attributes
    payload.transform_keys(&:to_s).transform_values(&:to_s)
  }
  OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, transformer)
end

.subscribe_to_custom_patternsObject



434
435
436
437
438
439
440
441
442
443
444
445
446
447
# File 'lib/flare.rb', line 434

def subscribe_to_custom_patterns
  configuration.subscribe_patterns.each do |prefix|
    # Subscribe to all notifications starting with this prefix
    pattern = /\A#{Regexp.escape(prefix)}/
    default_transformer = ->(payload) {
      attrs = payload.transform_keys(&:to_s).select { |_, v|
        v.is_a?(String) || v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass)
      }
      SourceLocation.add_to_attributes(attrs)
      attrs
    }
    OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, default_transformer)
  end
end

.subscribe_to_notificationsObject



422
423
424
425
426
427
428
429
430
431
432
# File 'lib/flare.rb', line 422

def subscribe_to_notifications
  NOTIFICATION_TRANSFORMERS.each do |pattern, transformer|
    OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, transformer)
  rescue
    # Ignore errors for patterns that don't exist
  end

  # Auto-subscribe to custom patterns (default: "app.*")
  # This lets users just do: ActiveSupport::Notifications.instrument("app.whatever") { }
  subscribe_to_custom_patterns
end

.trace_health_reporterObject



132
# File 'lib/flare.rb', line 132

def trace_health_reporter = @trace_health_reporter

.trace_span_processorObject



131
# File 'lib/flare.rb', line 131

def trace_span_processor = @trace_span_processor

.tracerObject



102
103
104
# File 'lib/flare.rb', line 102

def tracer
  @tracer ||= OpenTelemetry.tracer_provider.tracer("Flare", Flare::VERSION)
end

.transaction_name(name) ⇒ Object

Set the transaction name for the current span. This overrides the default name derived from Rails controller/action or job class.

Useful for Rack middleware, mounted apps, or any request that doesn’t go through the Rails router.

Flare.transaction_name("RestApi::Routes::Audits#get")


53
54
55
56
57
58
# File 'lib/flare.rb', line 53

def transaction_name(name)
  span = OpenTelemetry::Trace.current_span
  return unless span.respond_to?(:set_attribute)

  span.set_attribute(TRANSACTION_NAME_ATTRIBUTE, name)
end

.untraced(&block) ⇒ Object



106
107
108
# File 'lib/flare.rb', line 106

def untraced(&block)
  OpenTelemetry::Common::Utilities.untraced(&block)
end

.upload_url_poolObject



129
# File 'lib/flare.rb', line 129

def upload_url_pool = @upload_url_pool