flare 0.1.0

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module Storage
5
+ class Base
6
+ def save_case(attributes)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def save_clues(clues)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def find_case(uuid)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def list_cases(type: nil, status: nil, method: nil, name: nil, origin: nil, limit: 50, offset: 0)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def clues_for_case(case_uuid)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def list_clues(type: nil, search: nil, limit: 50, offset: 0)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def find_clue(id)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def prune(retention_hours:, max_cases:)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def clear_all
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def count_cases(type: nil, status: nil, method: nil, name: nil, origin: nil)
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def count_clues(type: nil, search: nil)
47
+ raise NotImplementedError
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ # storage/sqlite is loaded on demand when spans are enabled
54
+ # to avoid requiring sqlite3 in production environments
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flare.rb ADDED
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flare/version"
4
+ require_relative "flare/configuration"
5
+
6
+ require "opentelemetry/sdk"
7
+
8
+ require_relative "flare/source_location"
9
+ require_relative "flare/metric_key"
10
+ require_relative "flare/metric_storage"
11
+ require_relative "flare/metric_span_processor"
12
+ require_relative "flare/metric_flusher"
13
+ require_relative "flare/backoff_policy"
14
+ require_relative "flare/metric_submitter"
15
+
16
+ module Flare
17
+ class Error < StandardError; end
18
+
19
+ MISSING_PARENT_ID = "0000000000000000"
20
+ TRANSACTION_NAME_ATTRIBUTE = "flare.transaction_name" unless const_defined?(:TRANSACTION_NAME_ATTRIBUTE)
21
+
22
+ module_function
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield(configuration) if block_given?
30
+ end
31
+
32
+ def enabled?
33
+ configuration.enabled
34
+ end
35
+
36
+ # Set the transaction name for the current span. This overrides the
37
+ # default name derived from Rails controller/action or job class.
38
+ #
39
+ # Useful for Rack middleware, mounted apps, or any request that
40
+ # doesn't go through the Rails router.
41
+ #
42
+ # Flare.transaction_name("RestApi::Routes::Audits#get")
43
+ #
44
+ def transaction_name(name)
45
+ span = OpenTelemetry::Trace.current_span
46
+ return unless span.respond_to?(:set_attribute)
47
+
48
+ span.set_attribute(TRANSACTION_NAME_ATTRIBUTE, name)
49
+ end
50
+
51
+ def logger
52
+ @logger ||= Logger.new(STDOUT)
53
+ end
54
+
55
+ def logger=(logger)
56
+ @logger = logger
57
+ end
58
+
59
+ def log(message)
60
+ return unless configuration.debug
61
+
62
+ logger.info("[Flare] #{message}")
63
+ end
64
+
65
+ def exporter
66
+ @exporter ||= begin
67
+ require_relative "flare/sqlite_exporter"
68
+ SQLiteExporter.new(configuration.database_path)
69
+ rescue LoadError
70
+ raise LoadError, "Flare spans require the sqlite3 gem. Add `gem 'sqlite3'` to your Gemfile."
71
+ end
72
+ end
73
+
74
+ def exporter=(exporter)
75
+ @exporter = exporter
76
+ end
77
+
78
+ def span_processor
79
+ @span_processor ||= OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
80
+ exporter,
81
+ max_queue_size: 1000,
82
+ max_export_batch_size: 100,
83
+ schedule_delay: 1000 # 1 second
84
+ )
85
+ end
86
+
87
+ def span_processor=(span_processor)
88
+ @span_processor = span_processor
89
+ end
90
+
91
+ def tracer
92
+ @tracer ||= OpenTelemetry.tracer_provider.tracer("Flare", Flare::VERSION)
93
+ end
94
+
95
+ def untraced(&block)
96
+ OpenTelemetry::Common::Utilities.untraced(&block)
97
+ end
98
+
99
+ def metric_storage
100
+ @metric_storage
101
+ end
102
+
103
+ def metric_storage=(storage)
104
+ @metric_storage = storage
105
+ end
106
+
107
+ def metric_flusher
108
+ @metric_flusher
109
+ end
110
+
111
+ def metric_flusher=(flusher)
112
+ @metric_flusher = flusher
113
+ end
114
+
115
+ # Manually flush metrics (useful for testing or forced flushes).
116
+ def flush_metrics
117
+ @metric_flusher&.flush_now || 0
118
+ end
119
+
120
+ # Re-initialize metric flusher after fork.
121
+ # Call this from Puma/Unicorn after_fork hooks.
122
+ def after_fork
123
+ @metric_flusher&.after_fork
124
+ end
125
+
126
+ # Configure OpenTelemetry SDK and instrumentations. Must run before the
127
+ # middleware stack is built so Rack/ActionPack can insert their middleware.
128
+ # Note: metrics flusher is started separately via start_metrics_flusher
129
+ # after user initializers have run.
130
+ def configure_opentelemetry
131
+ return if @otel_configured
132
+
133
+ # Suppress noisy OTel INFO logs
134
+ OpenTelemetry.logger = Logger.new(STDOUT, level: Logger::WARN)
135
+
136
+ service_name = if defined?(Rails) && Rails.application
137
+ Rails.application.class.module_parent_name.underscore rescue "rails_app"
138
+ else
139
+ "app"
140
+ end
141
+
142
+ # Require only the instrumentations we want
143
+ require "opentelemetry-instrumentation-rack"
144
+ require "opentelemetry-instrumentation-net_http"
145
+ require "opentelemetry-instrumentation-active_support"
146
+ require "opentelemetry/instrumentation/active_support/span_subscriber"
147
+ require "opentelemetry-instrumentation-action_pack" if defined?(ActionController)
148
+ require "opentelemetry-instrumentation-action_view" if defined?(ActionView)
149
+ require "opentelemetry-instrumentation-active_job" if defined?(ActiveJob)
150
+
151
+ # Tell the SDK not to try configuring OTLP from env vars.
152
+ # Flare manages its own exporters (SQLite for spans, HTTP for metrics).
153
+ ENV["OTEL_TRACES_EXPORTER"] ||= "none"
154
+
155
+ log "Configuring OpenTelemetry (service=#{service_name})"
156
+
157
+ OpenTelemetry::SDK.configure do |c|
158
+ c.service_name = service_name
159
+
160
+ # Spans: detailed trace data stored in SQLite
161
+ if configuration.spans_enabled
162
+ c.add_span_processor(span_processor)
163
+ log "Spans enabled (database=#{configuration.database_path})"
164
+ end
165
+
166
+ # Configure specific instrumentations
167
+ c.use "OpenTelemetry::Instrumentation::Rack",
168
+ untraced_requests: ->(env) {
169
+ request = Rack::Request.new(env)
170
+ return true if request.path.start_with?("/flare")
171
+
172
+ configuration.ignore_request.call(request)
173
+ }
174
+ c.use "OpenTelemetry::Instrumentation::Net::HTTP"
175
+ c.use "OpenTelemetry::Instrumentation::ActiveSupport"
176
+ c.use "OpenTelemetry::Instrumentation::ActionPack" if defined?(ActionController)
177
+ c.use "OpenTelemetry::Instrumentation::ActionView" if defined?(ActionView)
178
+ c.use "OpenTelemetry::Instrumentation::ActiveJob" if defined?(ActiveJob)
179
+ end
180
+
181
+ # Subscribe to common ActiveSupport notification patterns
182
+ # This captures SQL, cache, mailer, and custom notifications.
183
+ # Required for both spans (detailed traces) and metrics (aggregated counters)
184
+ # because DB, cache, and mailer data flows through ActiveSupport notifications.
185
+ if configuration.spans_enabled || configuration.metrics_enabled
186
+ subscribe_to_notifications
187
+ end
188
+
189
+ at_exit do
190
+ log "Shutting down..."
191
+ if configuration.spans_enabled
192
+ span_processor.force_flush
193
+ span_processor.shutdown
194
+ log "Span processor flushed and stopped"
195
+ end
196
+ log "Shutdown complete"
197
+ end
198
+
199
+ @otel_configured = true
200
+ end
201
+
202
+ # Start the metrics flusher. Called from config.after_initialize so
203
+ # user configuration (metrics_enabled, flush_interval, etc.) is applied.
204
+ def start_metrics_flusher
205
+ return unless configuration.metrics_enabled
206
+
207
+ @metric_storage ||= MetricStorage.new
208
+ metric_processor = MetricSpanProcessor.new(
209
+ storage: @metric_storage,
210
+ http_metrics_config: configuration.http_metrics_config
211
+ )
212
+ OpenTelemetry.tracer_provider.add_span_processor(metric_processor)
213
+
214
+ log "Metrics enabled (endpoint=#{configuration.url} key=#{configuration.key ? 'present' : 'missing'})"
215
+
216
+ if configuration.metrics_submission_configured?
217
+ submitter = MetricSubmitter.new(
218
+ endpoint: configuration.url,
219
+ api_key: configuration.key
220
+ )
221
+ @metric_flusher = MetricFlusher.new(
222
+ storage: @metric_storage,
223
+ submitter: submitter,
224
+ interval: configuration.metrics_flush_interval
225
+ )
226
+ @metric_flusher.start
227
+ log "Metrics flusher started (interval=#{configuration.metrics_flush_interval}s)"
228
+
229
+ at_exit { @metric_flusher&.stop }
230
+ else
231
+ log "Metrics submission not configured (missing url or key)"
232
+ end
233
+ end
234
+
235
+ # Payload transformers for different notification types
236
+ NOTIFICATION_TRANSFORMERS = {
237
+ "sql.active_record" => ->(payload) {
238
+ attrs = {}
239
+ attrs["db.system"] = payload[:connection]&.adapter_name&.downcase rescue nil
240
+ attrs["db.statement"] = payload[:sql] if payload[:sql]
241
+ attrs["name"] = payload[:name] if payload[:name]
242
+ attrs["db.name"] = payload[:connection]&.pool&.db_config&.name rescue nil
243
+ # Capture source location (app code that triggered this query)
244
+ SourceLocation.add_to_attributes(attrs)
245
+ attrs
246
+ },
247
+ "instantiation.active_record" => ->(payload) {
248
+ attrs = {}
249
+ attrs["record_count"] = payload[:record_count] if payload[:record_count]
250
+ attrs["class_name"] = payload[:class_name] if payload[:class_name]
251
+ attrs
252
+ },
253
+ "cache_read.active_support" => ->(payload) {
254
+ store = payload[:store]
255
+ store_name = store.is_a?(String) ? store : store&.class&.name
256
+ { "key" => payload[:key]&.to_s, "hit" => payload[:hit], "store" => store_name }
257
+ },
258
+ "cache_write.active_support" => ->(payload) {
259
+ store = payload[:store]
260
+ store_name = store.is_a?(String) ? store : store&.class&.name
261
+ { "key" => payload[:key]&.to_s, "store" => store_name }
262
+ },
263
+ "cache_delete.active_support" => ->(payload) {
264
+ store = payload[:store]
265
+ store_name = store.is_a?(String) ? store : store&.class&.name
266
+ { "key" => payload[:key]&.to_s, "store" => store_name }
267
+ },
268
+ "cache_exist?.active_support" => ->(payload) {
269
+ store = payload[:store]
270
+ store_name = store.is_a?(String) ? store : store&.class&.name
271
+ { "key" => payload[:key]&.to_s, "exist" => payload[:exist], "store" => store_name }
272
+ },
273
+ "cache_fetch_hit.active_support" => ->(payload) {
274
+ store = payload[:store]
275
+ store_name = store.is_a?(String) ? store : store&.class&.name
276
+ { "key" => payload[:key]&.to_s, "store" => store_name }
277
+ },
278
+ "deliver.action_mailer" => ->(payload) {
279
+ attrs = {}
280
+ attrs["mailer"] = payload[:mailer] if payload[:mailer]
281
+ attrs["message_id"] = payload[:message_id] if payload[:message_id]
282
+ attrs["to"] = Array(payload[:to]).join(", ") if payload[:to]
283
+ attrs["subject"] = payload[:subject] if payload[:subject]
284
+ attrs
285
+ },
286
+ "process.action_mailer" => ->(payload) {
287
+ attrs = {}
288
+ attrs["mailer"] = payload[:mailer] if payload[:mailer]
289
+ attrs["action"] = payload[:action] if payload[:action]
290
+ attrs
291
+ }
292
+ }.freeze
293
+
294
+ def subscribe_to_notifications
295
+ NOTIFICATION_TRANSFORMERS.each do |pattern, transformer|
296
+ OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, transformer)
297
+ rescue
298
+ # Ignore errors for patterns that don't exist
299
+ end
300
+
301
+ # Auto-subscribe to custom patterns (default: "app.*")
302
+ # This lets users just do: ActiveSupport::Notifications.instrument("app.whatever") { }
303
+ subscribe_to_custom_patterns
304
+ end
305
+
306
+ def subscribe_to_custom_patterns
307
+ configuration.subscribe_patterns.each do |prefix|
308
+ # Subscribe to all notifications starting with this prefix
309
+ pattern = /\A#{Regexp.escape(prefix)}/
310
+ default_transformer = ->(payload) {
311
+ attrs = payload.transform_keys(&:to_s).select { |_, v|
312
+ v.is_a?(String) || v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass)
313
+ }
314
+ SourceLocation.add_to_attributes(attrs)
315
+ attrs
316
+ }
317
+ OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, default_transformer)
318
+ end
319
+ end
320
+
321
+ # Subscribe to any ActiveSupport::Notification and create spans for it
322
+ #
323
+ # @param pattern [String, Regexp] The notification pattern to subscribe to
324
+ # @param transformer [Proc, nil] Optional proc to transform payload into span attributes
325
+ # If nil, all payload keys become span attributes
326
+ #
327
+ # @example Subscribe to a custom notification
328
+ # Flare.subscribe("my_service.call")
329
+ #
330
+ # @example Subscribe with custom attribute transformer
331
+ # Flare.subscribe("stripe.charge") do |payload|
332
+ # { "charge_id" => payload[:id], "amount" => payload[:amount] }
333
+ # end
334
+ #
335
+ def subscribe(pattern, &transformer)
336
+ transformer ||= ->(payload) {
337
+ # Default: convert all payload keys to string attributes
338
+ payload.transform_keys(&:to_s).transform_values(&:to_s)
339
+ }
340
+ OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, transformer)
341
+ end
342
+
343
+ # Instrument a block of code, creating a span that shows up in Flare
344
+ #
345
+ # NOTE: This method only works when Flare is loaded (typically development).
346
+ # For instrumentation that works in all environments, use ActiveSupport::Notifications
347
+ # directly and subscribe with Flare.subscribe in your initializer.
348
+ #
349
+ # @param name [String] The name of the span (e.g., "my_service.call", "external_api.fetch")
350
+ # @param attributes [Hash] Optional attributes to add to the span
351
+ # @yield The block to instrument
352
+ # @return The return value of the block
353
+ #
354
+ # @example Basic usage (dev only)
355
+ # Flare.instrument("geocoding.lookup") do
356
+ # geocoder.lookup(address)
357
+ # end
358
+ #
359
+ # @example For all environments, use ActiveSupport::Notifications instead:
360
+ # # In your app code (works everywhere):
361
+ # ActiveSupport::Notifications.instrument("myapp.geocoding", address: addr) do
362
+ # geocoder.lookup(addr)
363
+ # end
364
+ #
365
+ # # In config/initializers/flare.rb (only loaded in dev):
366
+ # Flare.subscribe("myapp.geocoding")
367
+ #
368
+ def instrument(name, attributes = {}, &block)
369
+ return yield unless enabled?
370
+
371
+ # Add source location
372
+ location = SourceLocation.find
373
+ if location
374
+ attributes["code.filepath"] = location[:filepath]
375
+ attributes["code.lineno"] = location[:lineno]
376
+ attributes["code.function"] = location[:function] if location[:function]
377
+ end
378
+
379
+ tracer.in_span(name, attributes: attributes, kind: :internal) do |span|
380
+ yield span
381
+ end
382
+ end
383
+
384
+ def storage
385
+ @storage ||= begin
386
+ require_relative "flare/storage/sqlite"
387
+ Storage::SQLite.new(configuration.database_path)
388
+ rescue LoadError
389
+ raise LoadError, "Flare spans require the sqlite3 gem. Add `gem 'sqlite3'` to your Gemfile."
390
+ end
391
+ end
392
+
393
+ def reset_storage!
394
+ @storage = nil
395
+ end
396
+
397
+ def reset!
398
+ @configuration = nil
399
+ @exporter = nil
400
+ @span_processor = nil
401
+ @tracer = nil
402
+ @storage = nil
403
+ @metric_flusher&.stop
404
+ @metric_flusher = nil
405
+ @metric_storage = nil
406
+ @otel_configured = false
407
+ end
408
+ end
409
+
410
+ require_relative "flare/storage"
411
+ require_relative "flare/engine" if defined?(Rails)