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,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "metric_key"
4
+ require_relative "http_metrics_config"
5
+
6
+ module Flare
7
+ TRANSACTION_NAME_ATTRIBUTE = "flare.transaction_name" unless const_defined?(:TRANSACTION_NAME_ATTRIBUTE)
8
+
9
+ # OpenTelemetry SpanProcessor that extracts metrics from spans.
10
+ # Aggregates counts, durations, and error rates by namespace/service/target/operation.
11
+ class MetricSpanProcessor
12
+ # Standard OTel span kind symbols
13
+ SERVER = :server
14
+ CLIENT = :client
15
+ CONSUMER = :consumer
16
+
17
+ # Cache store class name patterns mapped to short service names
18
+ CACHE_STORE_MAP = {
19
+ "redis" => "redis",
20
+ "mem_cache" => "memcache",
21
+ "memcache" => "memcache",
22
+ "dalli" => "memcache",
23
+ "file" => "file",
24
+ "memory" => "memory",
25
+ "null" => "null",
26
+ "solid_cache" => "solid_cache"
27
+ }.freeze
28
+
29
+ def initialize(storage:, http_metrics_config: nil)
30
+ @storage = storage
31
+ @http_metrics_config = http_metrics_config || HttpMetricsConfig::DEFAULT
32
+ @pid = $$
33
+ end
34
+
35
+ # Called when a span starts - no-op for metrics
36
+ def on_start(span, parent_context); end
37
+
38
+ # Called when a span ends - extract and record metrics.
39
+ # OTel SDK 1.10+ calls on_finish; older versions call on_end.
40
+ def on_finish(span)
41
+ return unless span.end_timestamp && span.start_timestamp
42
+
43
+ detect_forking
44
+
45
+ if web_request?(span)
46
+ record_web_metric(span)
47
+ elsif background_job?(span)
48
+ record_background_metric(span)
49
+ elsif database_span?(span)
50
+ record_db_metric(span)
51
+ elsif http_client_span?(span)
52
+ record_http_metric(span)
53
+ elsif cache_span?(span)
54
+ record_cache_metric(span)
55
+ elsif view_span?(span)
56
+ record_view_metric(span)
57
+ end
58
+ end
59
+
60
+ def force_flush(timeout: nil)
61
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
62
+ end
63
+
64
+ alias_method :on_end, :on_finish
65
+
66
+ def shutdown(timeout: nil)
67
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
68
+ end
69
+
70
+ private
71
+
72
+ # Detect forking (Puma workers, Passenger, etc.) and restart the
73
+ # metrics flusher whose timer thread died during fork.
74
+ # Called on every span end — the same pattern Flipper uses in record().
75
+ def detect_forking
76
+ if @pid != $$
77
+ Flare.log "Fork detected (was=#{@pid} now=#{$$}), restarting metrics flusher"
78
+ @pid = $$
79
+ Flare.after_fork
80
+ end
81
+ end
82
+
83
+ # Web requests: any server span (entry point for this service).
84
+ # Server spans may have a remote parent from distributed trace propagation
85
+ # (e.g., traceparent header), but they still represent the web request
86
+ # handled by this application.
87
+ def web_request?(span)
88
+ span.kind == SERVER
89
+ end
90
+
91
+ # Background jobs: root consumer spans (ActiveJob, Sidekiq)
92
+ def background_job?(span)
93
+ span.kind == CONSUMER && root_span?(span)
94
+ end
95
+
96
+ # Database spans: any span with db.system attribute
97
+ def database_span?(span)
98
+ span.attributes["db.system"]
99
+ end
100
+
101
+ # HTTP client spans: client spans with http.method or http.request.method
102
+ def http_client_span?(span)
103
+ span.kind == CLIENT &&
104
+ (span.attributes["http.method"] || span.attributes["http.request.method"])
105
+ end
106
+
107
+ # Cache spans: ActiveSupport cache notification spans
108
+ def cache_span?(span)
109
+ name = span.name.to_s
110
+ name.start_with?("cache_") && name.end_with?(".active_support")
111
+ end
112
+
113
+ # View spans: ActionView render notification spans
114
+ def view_span?(span)
115
+ name = span.name.to_s
116
+ name.start_with?("render_") && name.end_with?(".action_view")
117
+ end
118
+
119
+ def root_span?(span)
120
+ span.parent_span_id.nil? ||
121
+ span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
122
+ end
123
+
124
+ def record_web_metric(span)
125
+ target = span.attributes[Flare::TRANSACTION_NAME_ATTRIBUTE]
126
+
127
+ unless target
128
+ # Skip requests that never hit a Rails controller (assets, favicon, bot probes, etc.)
129
+ # These have no code.namespace set by ActionPack instrumentation.
130
+ return unless span.attributes["code.namespace"]
131
+
132
+ controller = span.attributes["code.namespace"]
133
+ action = span.attributes["code.function"]
134
+ target = action ? "#{controller}##{action}" : controller
135
+ end
136
+
137
+ key = MetricKey.new(
138
+ bucket: bucket_time(span),
139
+ namespace: "web",
140
+ service: "rails",
141
+ target: target,
142
+ operation: http_status_class(span)
143
+ )
144
+
145
+ @storage.increment(
146
+ key,
147
+ duration_ms: duration_ms(span),
148
+ error: http_error?(span)
149
+ )
150
+ end
151
+
152
+ def record_background_metric(span)
153
+ transaction_name = span.attributes[Flare::TRANSACTION_NAME_ATTRIBUTE]
154
+
155
+ key = MetricKey.new(
156
+ bucket: bucket_time(span),
157
+ namespace: "job",
158
+ service: extract_job_system(span),
159
+ target: transaction_name || span.attributes["code.namespace"] || span.attributes["messaging.destination"] || "unknown",
160
+ operation: transaction_name ? "perform" : (span.attributes["code.function"] || span.name)
161
+ )
162
+
163
+ @storage.increment(
164
+ key,
165
+ duration_ms: duration_ms(span),
166
+ error: span_error?(span)
167
+ )
168
+ end
169
+
170
+ def record_db_metric(span)
171
+ db_system = span.attributes["db.system"].to_s
172
+
173
+ key = MetricKey.new(
174
+ bucket: bucket_time(span),
175
+ namespace: "db",
176
+ service: db_system,
177
+ target: extract_db_target(span, db_system),
178
+ operation: extract_db_operation(span, db_system)
179
+ )
180
+
181
+ @storage.increment(
182
+ key,
183
+ duration_ms: duration_ms(span),
184
+ error: span_error?(span)
185
+ )
186
+ end
187
+
188
+ def record_http_metric(span)
189
+ host = span.attributes["http.host"] ||
190
+ span.attributes["server.address"] ||
191
+ span.attributes["net.peer.name"] ||
192
+ extract_host_from_url(span) ||
193
+ "unknown"
194
+
195
+ method = span.attributes["http.method"] ||
196
+ span.attributes["http.request.method"] ||
197
+ "UNKNOWN"
198
+
199
+ path = resolve_http_path(span, host.to_s.downcase)
200
+
201
+ key = MetricKey.new(
202
+ bucket: bucket_time(span),
203
+ namespace: "http",
204
+ service: host.to_s.downcase,
205
+ target: "#{method.to_s.upcase} #{path}",
206
+ operation: http_status_class(span)
207
+ )
208
+
209
+ @storage.increment(
210
+ key,
211
+ duration_ms: duration_ms(span),
212
+ error: http_error?(span)
213
+ )
214
+ end
215
+
216
+ def record_cache_metric(span)
217
+ operation = extract_cache_operation(span)
218
+
219
+ key = MetricKey.new(
220
+ bucket: bucket_time(span),
221
+ namespace: "cache",
222
+ service: extract_cache_store(span),
223
+ target: operation,
224
+ operation: operation
225
+ )
226
+
227
+ @storage.increment(
228
+ key,
229
+ duration_ms: duration_ms(span),
230
+ error: span_error?(span)
231
+ )
232
+ end
233
+
234
+ def record_view_metric(span)
235
+ key = MetricKey.new(
236
+ bucket: bucket_time(span),
237
+ namespace: "view",
238
+ service: "actionview",
239
+ target: extract_view_template(span),
240
+ operation: extract_view_operation(span)
241
+ )
242
+
243
+ @storage.increment(
244
+ key,
245
+ duration_ms: duration_ms(span),
246
+ error: span_error?(span)
247
+ )
248
+ end
249
+
250
+ def bucket_time(span)
251
+ # Use span start time, truncated to minute (in UTC for consistent bucketing)
252
+ time = Time.at(span.start_timestamp / 1_000_000_000.0).utc
253
+ Time.utc(time.year, time.month, time.day, time.hour, time.min, 0)
254
+ end
255
+
256
+ def duration_ms(span)
257
+ ((span.end_timestamp - span.start_timestamp) / 1_000_000.0).round
258
+ end
259
+
260
+ def http_error?(span)
261
+ status = span.attributes["http.status_code"] || span.attributes["http.response.status_code"]
262
+ status.to_i >= 500
263
+ end
264
+
265
+ def http_status_class(span)
266
+ status = span.attributes["http.status_code"] || span.attributes["http.response.status_code"]
267
+ code = status.to_i
268
+ if code >= 500 then "5xx"
269
+ elsif code >= 400 then "4xx"
270
+ elsif code >= 300 then "3xx"
271
+ elsif code >= 200 then "2xx"
272
+ elsif code >= 100 then "1xx"
273
+ else "unknown"
274
+ end
275
+ end
276
+
277
+ def span_error?(span)
278
+ span.status&.code == OpenTelemetry::Trace::Status::ERROR
279
+ end
280
+
281
+ def extract_job_system(span)
282
+ # Detect job system from span attributes
283
+ if span.attributes["messaging.system"]
284
+ span.attributes["messaging.system"].to_s
285
+ elsif span.name.to_s.include?("sidekiq")
286
+ "sidekiq"
287
+ elsif span.name.to_s.include?("ActiveJob")
288
+ "activejob"
289
+ else
290
+ "background"
291
+ end
292
+ end
293
+
294
+ def extract_db_target(span, db_system)
295
+ case db_system
296
+ when "redis"
297
+ # For Redis, use db.redis.namespace if available, or the database index
298
+ span.attributes["db.redis.database_index"]&.to_s ||
299
+ span.attributes["db.name"] ||
300
+ "default"
301
+ else
302
+ # For SQL databases, prefer table name from attributes or parsed from SQL
303
+ span.attributes["db.sql.table"] ||
304
+ extract_sql_table(span) ||
305
+ span.attributes["db.name"] ||
306
+ "unknown"
307
+ end
308
+ end
309
+
310
+ def extract_db_operation(span, db_system)
311
+ case db_system
312
+ when "redis"
313
+ span.attributes["db.operation"]&.to_s&.downcase ||
314
+ span.name.to_s.split.last&.downcase ||
315
+ "command"
316
+ else
317
+ span.attributes["db.operation"]&.to_s&.upcase ||
318
+ extract_sql_operation(span) ||
319
+ "query"
320
+ end
321
+ end
322
+
323
+ def extract_sql_operation(span)
324
+ statement = span.attributes["db.statement"]
325
+ return nil unless statement
326
+
327
+ # Extract first word (SELECT, INSERT, UPDATE, DELETE, etc.)
328
+ statement.to_s.strip.split(/\s+/).first&.upcase
329
+ end
330
+
331
+ def extract_sql_table(span)
332
+ statement = span.attributes["db.statement"]
333
+ return nil unless statement
334
+
335
+ sql = statement.to_s.strip
336
+
337
+ case sql
338
+ when /\bFROM\s+[`"]?(\w+)[`"]?/i
339
+ $1
340
+ when /\bINTO\s+[`"]?(\w+)[`"]?/i
341
+ $1
342
+ when /\bUPDATE\s+[`"]?(\w+)[`"]?/i
343
+ $1
344
+ end
345
+ end
346
+
347
+ def extract_host_from_url(span)
348
+ url = span.attributes["http.url"] || span.attributes["url.full"]
349
+ return nil unless url
350
+
351
+ URI.parse(url.to_s).host
352
+ rescue URI::InvalidURIError
353
+ nil
354
+ end
355
+
356
+ # Resolve the HTTP path using the http_metrics_config.
357
+ # Returns the resolved path string (could be "*", a custom string, or normalized path).
358
+ def resolve_http_path(span, host)
359
+ raw_path = raw_http_path(span)
360
+ result = @http_metrics_config.resolve(host, raw_path)
361
+
362
+ if result == "*"
363
+ "*"
364
+ elsif result.is_a?(String)
365
+ result
366
+ else
367
+ # nil means use normalize_path
368
+ normalize_path(raw_path)
369
+ end
370
+ end
371
+
372
+ def raw_http_path(span)
373
+ path = span.attributes["http.target"] ||
374
+ span.attributes["url.path"] ||
375
+ extract_path_from_url(span)
376
+ path&.to_s&.split("?")&.first || "/"
377
+ end
378
+
379
+ # Normalize URL paths to prevent cardinality explosion.
380
+ # Replaces numeric IDs, UUIDs, and other high-cardinality segments with placeholders.
381
+ def normalize_path(path)
382
+ return "/" if path.nil? || path.empty?
383
+
384
+ segments = path.split("/")
385
+ normalized = segments.map do |segment|
386
+ next segment if segment.empty?
387
+
388
+ case segment
389
+ when /\A\d+\z/
390
+ # Pure numeric ID: /users/123 -> /users/:id
391
+ ":id"
392
+ when /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
393
+ # UUID: /posts/550e8400-e29b-41d4-a716-446655440000 -> /posts/:uuid
394
+ ":uuid"
395
+ when /\A[0-9a-f]{24}\z/i
396
+ # MongoDB ObjectId: /items/507f1f77bcf86cd799439011 -> /items/:id
397
+ ":id"
398
+ when /\A[0-9a-f]{32,}\z/i
399
+ # Long hex strings (tokens, hashes): -> :token
400
+ ":token"
401
+ else
402
+ segment
403
+ end
404
+ end
405
+
406
+ result = normalized.join("/")
407
+ result.empty? ? "/" : result
408
+ end
409
+
410
+ def extract_path_from_url(span)
411
+ url = span.attributes["http.url"] || span.attributes["url.full"]
412
+ return nil unless url
413
+
414
+ URI.parse(url.to_s).path
415
+ rescue URI::InvalidURIError
416
+ nil
417
+ end
418
+
419
+ def extract_cache_store(span)
420
+ store = span.attributes["store"].to_s
421
+ return "unknown" if store.empty?
422
+
423
+ downcased = store.downcase
424
+ CACHE_STORE_MAP.each do |pattern, name|
425
+ return name if downcased.include?(pattern)
426
+ end
427
+
428
+ # Fallback: last segment, strip Store/Cache suffixes
429
+ short = store.split("::").last
430
+ .gsub(/CacheStore$|Store$|Cache$/, "")
431
+ .downcase
432
+ short.empty? ? "unknown" : short
433
+ end
434
+
435
+ def extract_cache_operation(span)
436
+ base_op = span.name.to_s
437
+ .delete_prefix("cache_")
438
+ .delete_suffix(".active_support")
439
+
440
+ case base_op
441
+ when "read"
442
+ span.attributes["hit"] == true ? "read.hit" : "read.miss"
443
+ when "exist?"
444
+ span.attributes["exist"] == true ? "exist.hit" : "exist.miss"
445
+ else
446
+ base_op
447
+ end
448
+ end
449
+
450
+ def extract_view_template(span)
451
+ identifier = span.attributes["identifier"]
452
+ return span.attributes["code.filepath"] || "unknown" unless identifier
453
+
454
+ path = identifier.to_s
455
+ if (idx = path.index("app/views/"))
456
+ path[(idx + "app/views/".length)..]
457
+ elsif (idx = path.index("app/"))
458
+ path[(idx + "app/".length)..]
459
+ else
460
+ File.basename(path)
461
+ end
462
+ end
463
+
464
+ def extract_view_operation(span)
465
+ span.name.to_s
466
+ .delete_prefix("render_")
467
+ .delete_suffix(".action_view")
468
+ end
469
+ end
470
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+ require_relative "metric_counter"
5
+
6
+ module Flare
7
+ # Thread-safe storage for metric aggregation.
8
+ # Uses Concurrent::Map for lock-free reads and writes.
9
+ class MetricStorage
10
+ def initialize
11
+ @storage = Concurrent::Map.new
12
+ end
13
+
14
+ def increment(key, duration_ms:, error: false)
15
+ counter = @storage.compute_if_absent(key) { MetricCounter.new }
16
+ counter.increment(duration_ms: duration_ms, error: error)
17
+ end
18
+
19
+ # Atomically retrieves and clears all metrics.
20
+ # Returns a frozen hash of MetricKey => counter data.
21
+ def drain
22
+ result = {}
23
+ @storage.keys.each do |key|
24
+ counter = @storage.delete(key)
25
+ result[key] = counter.to_h if counter
26
+ end
27
+ result.freeze
28
+ end
29
+
30
+ def size
31
+ @storage.size
32
+ end
33
+
34
+ def empty?
35
+ @storage.empty?
36
+ end
37
+
38
+ def [](key)
39
+ @storage[key]
40
+ end
41
+ end
42
+ end