Module: Flare

Defined in:
lib/flare.rb,
lib/flare/cli.rb,
lib/flare/engine.rb,
lib/flare/storage.rb,
lib/flare/version.rb,
lib/flare/cli/output.rb,
lib/flare/metric_key.rb,
lib/flare/configuration.rb,
lib/flare/backoff_policy.rb,
lib/flare/metric_counter.rb,
lib/flare/metric_flusher.rb,
lib/flare/metric_storage.rb,
lib/flare/storage/sqlite.rb,
lib/flare/source_location.rb,
lib/flare/sqlite_exporter.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,
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: ApplicationController, BackoffPolicy, Configuration, DoctorCommand, Engine, Error, HttpMetricsConfig, JobsController, MetricCounter, MetricFlusher, MetricKey, MetricSpanProcessor, MetricStorage, MetricSubmitter, RequestsController, SQLiteExporter, SetupCommand, SpansController, StatusCommand

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
VERSION =
"0.2.0"

Class Method Summary collapse

Class Method Details

.after_forkObject

Re-initialize metric flusher after fork. Call this from Puma/Unicorn after_fork hooks.



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

def after_fork
  @metric_flusher&.after_fork
end

.configurationObject



24
25
26
# File 'lib/flare.rb', line 24

def configuration
  @configuration ||= Configuration.new
end

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

Yields:



28
29
30
# File 'lib/flare.rb', line 28

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.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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
# File 'lib/flare.rb', line 132

def configure_opentelemetry
  return if @otel_configured

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

  service_name = if defined?(Rails) && Rails.application
    Rails.application.class.module_parent_name.underscore rescue "rails_app"
  else
    "app"
  end

  # 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
    log "Shutdown complete"
  end

  @otel_configured = true
end

.enabled?Boolean

Returns:

  • (Boolean)


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

def enabled?
  configuration.enabled
end

.exporterObject



65
66
67
68
69
70
71
72
73
74
# File 'lib/flare.rb', line 65

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



76
77
78
# File 'lib/flare.rb', line 76

def exporter=(exporter)
  @exporter = exporter
end

.flush_metricsObject

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



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

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



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/flare.rb', line 376

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



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

def log(message)
  return unless configuration.debug

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

.loggerObject



51
52
53
# File 'lib/flare.rb', line 51

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

.logger=(logger) ⇒ Object



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

def logger=(logger)
  @logger = logger
end

.metric_flusherObject



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

def metric_flusher
  @metric_flusher
end

.metric_flusher=(flusher) ⇒ Object



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

def metric_flusher=(flusher)
  @metric_flusher = flusher
end

.metric_storageObject



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

def metric_storage
  @metric_storage
end

.metric_storage=(storage) ⇒ Object



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

def metric_storage=(storage)
  @metric_storage = storage
end

.reset!Object



407
408
409
410
411
412
413
414
415
416
417
# File 'lib/flare.rb', line 407

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



403
404
405
# File 'lib/flare.rb', line 403

def reset_storage!
  @storage = nil
end

.span_processorObject



80
81
82
83
84
85
86
87
# File 'lib/flare.rb', line 80

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



89
90
91
# File 'lib/flare.rb', line 89

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.



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

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

.storageObject



392
393
394
395
396
397
398
399
400
401
# File 'lib/flare.rb', line 392

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



343
344
345
346
347
348
349
# File 'lib/flare.rb', line 343

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



314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/flare.rb', line 314

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



302
303
304
305
306
307
308
309
310
311
312
# File 'lib/flare.rb', line 302

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

.tracerObject



93
94
95
# File 'lib/flare.rb', line 93

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


44
45
46
47
48
49
# File 'lib/flare.rb', line 44

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



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

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