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/client_headers.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, ClientHeaders, 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.1"

Class Method Summary collapse

Class Method Details

.after_forkObject

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



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

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

.configurationObject



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

def configuration
  @configuration ||= Configuration.new
end

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

Yields:



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

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.



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
247
# File 'lib/flare.rb', line 170

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)


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

def enabled?
  configuration.enabled
end

.exporterObject



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

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



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

def exporter=(exporter)
  @exporter = exporter
end

.flush_metricsObject

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



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

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



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

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



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

def log(message)
  return unless configuration.debug

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

.loggerObject



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

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

.logger=(logger) ⇒ Object



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

def logger=(logger)
  @logger = logger
end

.markerObject



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

def marker          = @marker

.metric_flusherObject



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

def metric_flusher
  @metric_flusher
end

.metric_flusher=(flusher) ⇒ Object



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

def metric_flusher=(flusher)
  @metric_flusher = flusher
end

.metric_storageObject



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

def metric_storage
  @metric_storage
end

.metric_storage=(storage) ⇒ Object



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

def metric_storage=(storage)
  @metric_storage = storage
end

.rails_env_nameObject



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

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



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

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



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

def reset_storage!
  @storage = nil
end

.rule_managerObject



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

def rule_manager    = @rule_manager

.samplerObject

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



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

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.



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

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



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
328
# File 'lib/flare.rb', line 276

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



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

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



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

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.



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
362
# File 'lib/flare.rb', line 332

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.



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

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



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

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



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

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



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

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



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

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



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

def trace_health_reporter = @trace_health_reporter

.trace_span_processorObject



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

def trace_span_processor = @trace_span_processor

.tracerObject



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

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")


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

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



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

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

.upload_url_poolObject



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

def upload_url_pool = @upload_url_pool