flare 0.4.0 → 1.0.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.

Potentially problematic release.


This version of flare might be problematic. Click here for more details.

Files changed (70) hide show
  1. data/.document +5 -0
  2. data/.gitignore +22 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +18 -0
  5. data/Rakefile +55 -0
  6. data/VERSION +1 -0
  7. data/flare.gemspec +66 -0
  8. data/lib/flare/active_record.rb +102 -0
  9. data/lib/flare/collection.rb +47 -0
  10. data/lib/flare/configuration.rb +64 -123
  11. data/lib/flare/index_builder.rb +24 -0
  12. data/lib/flare/session.rb +142 -0
  13. data/lib/flare/tasks.rb +18 -0
  14. data/lib/flare.rb +19 -537
  15. data/test/helper.rb +10 -0
  16. data/test/test_flare.rb +7 -0
  17. metadata +89 -242
  18. checksums.yaml +0 -7
  19. data/CHANGELOG.md +0 -3
  20. data/LICENSE.txt +0 -21
  21. data/README.md +0 -148
  22. data/app/controllers/flare/application_controller.rb +0 -30
  23. data/app/controllers/flare/jobs_controller.rb +0 -55
  24. data/app/controllers/flare/requests_controller.rb +0 -73
  25. data/app/controllers/flare/spans_controller.rb +0 -101
  26. data/app/helpers/flare/application_helper.rb +0 -168
  27. data/app/views/flare/jobs/index.html.erb +0 -69
  28. data/app/views/flare/jobs/show.html.erb +0 -323
  29. data/app/views/flare/requests/index.html.erb +0 -120
  30. data/app/views/flare/requests/show.html.erb +0 -498
  31. data/app/views/flare/spans/index.html.erb +0 -112
  32. data/app/views/flare/spans/show.html.erb +0 -184
  33. data/app/views/layouts/flare/application.html.erb +0 -126
  34. data/config/routes.rb +0 -20
  35. data/exe/flare +0 -9
  36. data/lib/flare/backoff_policy.rb +0 -73
  37. data/lib/flare/cli/doctor_command.rb +0 -129
  38. data/lib/flare/cli/output.rb +0 -45
  39. data/lib/flare/cli/setup_command.rb +0 -404
  40. data/lib/flare/cli/status_command.rb +0 -47
  41. data/lib/flare/cli.rb +0 -50
  42. data/lib/flare/client_headers.rb +0 -39
  43. data/lib/flare/deadline.rb +0 -27
  44. data/lib/flare/engine.rb +0 -45
  45. data/lib/flare/filtering_span_processor.rb +0 -429
  46. data/lib/flare/http_metrics_config.rb +0 -101
  47. data/lib/flare/http_transport.rb +0 -76
  48. data/lib/flare/lifecycle.rb +0 -39
  49. data/lib/flare/marker.rb +0 -106
  50. data/lib/flare/metric_counter.rb +0 -51
  51. data/lib/flare/metric_flusher.rb +0 -279
  52. data/lib/flare/metric_key.rb +0 -42
  53. data/lib/flare/metric_span_processor.rb +0 -470
  54. data/lib/flare/metric_storage.rb +0 -67
  55. data/lib/flare/metric_submitter.rb +0 -239
  56. data/lib/flare/recording_batch_span_processor.rb +0 -238
  57. data/lib/flare/rule_manager.rb +0 -141
  58. data/lib/flare/sampler.rb +0 -130
  59. data/lib/flare/source_location.rb +0 -113
  60. data/lib/flare/sqlite_exporter.rb +0 -334
  61. data/lib/flare/storage/sqlite.rb +0 -794
  62. data/lib/flare/storage.rb +0 -54
  63. data/lib/flare/trace_blob.rb +0 -116
  64. data/lib/flare/trace_exporter.rb +0 -182
  65. data/lib/flare/trace_health_reporter.rb +0 -74
  66. data/lib/flare/upload_url_pool.rb +0 -108
  67. data/lib/flare/version.rb +0 -5
  68. data/lib/flare/web_marker_subscriber.rb +0 -76
  69. data/public/flare-assets/flare.css +0 -1245
  70. data/public/flare-assets/images/flipper.png +0 -0
@@ -1,470 +0,0 @@
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.sidekiq.job_class"] || 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
@@ -1,67 +0,0 @@
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
- @pid = $$
13
- end
14
-
15
- def increment(key, duration_ms:, error: false)
16
- detect_forking
17
- counter = @storage.compute_if_absent(key) { MetricCounter.new }
18
- counter.increment(duration_ms: duration_ms, error: error)
19
- end
20
-
21
- def add(key, count:, sum_ms:, error_count: 0)
22
- detect_forking
23
- counter = @storage.compute_if_absent(key) { MetricCounter.new }
24
- counter.add(count: count, sum_ms: sum_ms, error_count: error_count)
25
- end
26
-
27
- # Atomically retrieves and clears all metrics.
28
- # Returns a frozen hash of MetricKey => counter data.
29
- def drain
30
- detect_forking
31
- result = {}
32
- @storage.keys.each do |key|
33
- counter = @storage.delete(key)
34
- result[key] = counter.to_h if counter
35
- end
36
- result.freeze
37
- end
38
-
39
- def size
40
- detect_forking
41
- @storage.size
42
- end
43
-
44
- def empty?
45
- detect_forking
46
- @storage.empty?
47
- end
48
-
49
- def [](key)
50
- detect_forking
51
- @storage[key]
52
- end
53
-
54
- def after_fork
55
- return if @pid == $$
56
-
57
- @pid = $$
58
- @storage = Concurrent::Map.new
59
- end
60
-
61
- private
62
-
63
- def detect_forking
64
- after_fork
65
- end
66
- end
67
- end