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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class HttpMetricsConfig
5
+ class HostConfig
6
+ attr_reader :rules
7
+
8
+ def initialize
9
+ @rules = []
10
+ @all = false
11
+ end
12
+
13
+ def initialize_copy(source)
14
+ @rules = source.rules.dup
15
+ end
16
+
17
+ def all?
18
+ @all
19
+ end
20
+
21
+ # Track all paths for this host (normalized via normalize_path)
22
+ def all
23
+ @all = true
24
+ end
25
+
26
+ # Track paths matching this regex (normalized via normalize_path)
27
+ def allow(pattern)
28
+ @rules << {pattern: pattern, replacement: nil}
29
+ end
30
+
31
+ # Track paths matching this regex with a custom replacement string
32
+ def map(pattern, replacement)
33
+ @rules << {pattern: pattern, replacement: replacement}
34
+ end
35
+
36
+ # Returns the resolved path for a given raw path, or "*" if no match.
37
+ # If :all, returns nil to signal "use normalize_path".
38
+ # If a rule matches with a replacement, returns the replacement.
39
+ # If a rule matches without a replacement, returns nil to signal "use normalize_path".
40
+ # If no rules match, returns "*".
41
+ def resolve(path)
42
+ return nil if @all
43
+
44
+ @rules.each do |rule|
45
+ if rule[:pattern].match?(path)
46
+ return rule[:replacement] # nil means use normalize_path
47
+ end
48
+ end
49
+
50
+ "*"
51
+ end
52
+ end
53
+
54
+ def initialize
55
+ @hosts = {}
56
+ end
57
+
58
+ def initialize_copy(source)
59
+ @hosts = source.instance_variable_get(:@hosts).transform_values(&:dup)
60
+ end
61
+
62
+ def host(hostname, mode = nil, &block)
63
+ config = @hosts[hostname] ||= HostConfig.new
64
+
65
+ if mode == :all
66
+ config.all
67
+ elsif block
68
+ yield config
69
+ end
70
+ end
71
+
72
+ # Resolve a host+path to the target path for metrics.
73
+ # Returns "*" for unknown hosts or unmatched paths.
74
+ # Returns nil to signal "use normalize_path".
75
+ # Returns a string for custom replacements.
76
+ def resolve(hostname, path)
77
+ host_config = @hosts[hostname]
78
+ return "*" unless host_config
79
+
80
+ host_config.resolve(path)
81
+ end
82
+
83
+ DEFAULT = new.tap do |config|
84
+ config.host "flare.am" do |h|
85
+ h.allow %r{/api/metrics}
86
+ end
87
+
88
+ config.host "www.flippercloud.io" do |h|
89
+ h.map %r{/adapter/features/[^/]+/(boolean|actors|groups|percentage_of_actors|percentage_of_time|expression|clear)}, "/adapter/features/:name/:gate"
90
+ h.map %r{/adapter/features/[^/]+}, "/adapter/features/:name"
91
+ h.map %r{/adapter/actors/[^/]+}, "/adapter/actors/:id"
92
+ h.allow %r{/adapter/features}
93
+ h.allow %r{/adapter/import}
94
+ h.allow %r{/adapter/telemetry/summary}
95
+ h.allow %r{/adapter/telemetry}
96
+ h.allow %r{/adapter/events}
97
+ h.allow %r{/adapter/audits}
98
+ end
99
+ end.freeze
100
+ end
101
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_fixnum"
4
+
5
+ module Flare
6
+ # Thread-safe counter for metric aggregation.
7
+ # Uses atomic operations for lock-free increments.
8
+ #
9
+ # Note: Durations are stored as integer milliseconds. Sub-millisecond
10
+ # durations are truncated to 0. For very fast operations (e.g., cache hits),
11
+ # the sum_ms may undercount actual time spent.
12
+ class MetricCounter
13
+ def initialize
14
+ @count = Concurrent::AtomicFixnum.new(0)
15
+ @sum_ms = Concurrent::AtomicFixnum.new(0)
16
+ @error_count = Concurrent::AtomicFixnum.new(0)
17
+ end
18
+
19
+ def increment(duration_ms:, error: false)
20
+ @count.increment
21
+ @sum_ms.increment(duration_ms.to_i)
22
+ @error_count.increment if error
23
+ end
24
+
25
+ def count
26
+ @count.value
27
+ end
28
+
29
+ def sum_ms
30
+ @sum_ms.value
31
+ end
32
+
33
+ def error_count
34
+ @error_count.value
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ count: @count.value,
40
+ sum_ms: @sum_ms.value,
41
+ error_count: @error_count.value
42
+ }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/timer_task"
4
+ require "concurrent/executor/fixed_thread_pool"
5
+
6
+ module Flare
7
+ # Background threads that periodically drain in-memory metrics and submit
8
+ # them via HTTP. Uses concurrent-ruby TimerTask + FixedThreadPool, matching
9
+ # the pattern in Flipper's telemetry.
10
+ #
11
+ # Fork-safe: detects forked processes and restarts automatically.
12
+ class MetricFlusher
13
+ DEFAULT_INTERVAL = 60 # seconds
14
+ DEFAULT_SHUTDOWN_TIMEOUT = 5 # seconds
15
+
16
+ attr_reader :interval, :shutdown_timeout
17
+
18
+ def initialize(storage:, submitter:, interval: DEFAULT_INTERVAL, shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT)
19
+ @storage = storage
20
+ @submitter = submitter
21
+ @interval = interval
22
+ @shutdown_timeout = shutdown_timeout
23
+ @pid = $$
24
+ @stopped = false
25
+ end
26
+
27
+ def start
28
+ @stopped = false
29
+
30
+ @pool = Concurrent::FixedThreadPool.new(1, {
31
+ max_queue: 20,
32
+ fallback_policy: :discard,
33
+ name: "flare-metrics-submit-pool".freeze,
34
+ })
35
+
36
+ @timer = Concurrent::TimerTask.execute({
37
+ execution_interval: @interval,
38
+ name: "flare-metrics-drain-timer".freeze,
39
+ }) { post_to_pool }
40
+ end
41
+
42
+ def stop
43
+ return if @stopped
44
+
45
+ @stopped = true
46
+
47
+ Flare.log "Shutting down metrics flusher, draining remaining metrics..."
48
+
49
+ if @timer
50
+ @timer.shutdown
51
+ @timer.wait_for_termination(1)
52
+ @timer.kill unless @timer.shutdown?
53
+ end
54
+
55
+ if @pool
56
+ post_to_pool # one last drain
57
+ @pool.shutdown
58
+ pool_terminated = @pool.wait_for_termination(@shutdown_timeout)
59
+ @pool.kill unless pool_terminated
60
+ end
61
+
62
+ Flare.log "Metrics flusher stopped"
63
+ end
64
+
65
+ def restart
66
+ @stopped = false
67
+ stop
68
+ start
69
+ end
70
+
71
+ # Manually trigger a flush (useful for testing or forced flushes).
72
+ def flush_now
73
+ return 0 unless @storage && @submitter
74
+
75
+ drained = @storage.drain
76
+ return 0 if drained.empty?
77
+
78
+ count, error = @submitter.submit(drained)
79
+ if error
80
+ warn "[Flare] Metric submission error: #{error.message}"
81
+ end
82
+ count
83
+ rescue => e
84
+ warn "[Flare] Metric flush error: #{e.message}"
85
+ 0
86
+ end
87
+
88
+ def running?
89
+ @timer&.running? || false
90
+ end
91
+
92
+ # Re-initialize after fork. Called automatically by MetricSpanProcessor
93
+ # on first span in the new process, or manually from Puma/Unicorn
94
+ # after_fork hooks.
95
+ def after_fork
96
+ @pid = $$
97
+ restart
98
+ end
99
+
100
+ private
101
+
102
+ def post_to_pool
103
+ drained = @storage.drain
104
+ if drained.empty?
105
+ Flare.log "No metrics to flush"
106
+ return
107
+ end
108
+
109
+ Flare.log "Drained #{drained.size} metric keys for submission"
110
+ @pool.post { submit_to_cloud(drained) }
111
+ rescue => e
112
+ warn "[Flare] Metric drain error: #{e.message}"
113
+ end
114
+
115
+ def submit_to_cloud(drained)
116
+ _response, error = @submitter.submit(drained)
117
+ if error
118
+ warn "[Flare] Metric submission error: #{error.message}"
119
+ end
120
+ rescue => e
121
+ warn "[Flare] Metric submission error: #{e.message}"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # Identifies a unique metric for aggregation.
5
+ # Immutable and hashable for use as Concurrent::Map keys.
6
+ class MetricKey
7
+ attr_reader :bucket, :namespace, :service, :target, :operation
8
+
9
+ def initialize(bucket:, namespace:, service:, target:, operation:)
10
+ @bucket = bucket
11
+ @namespace = namespace.to_s.freeze
12
+ @service = service.to_s.freeze
13
+ @target = target&.to_s&.freeze
14
+ @operation = operation.to_s.freeze
15
+ freeze
16
+ end
17
+
18
+ def eql?(other)
19
+ self.class.eql?(other.class) &&
20
+ bucket == other.bucket &&
21
+ namespace == other.namespace &&
22
+ service == other.service &&
23
+ target == other.target &&
24
+ operation == other.operation
25
+ end
26
+ alias == eql?
27
+
28
+ def hash
29
+ [self.class, bucket, namespace, service, target, operation].hash
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ bucket: bucket,
35
+ namespace: namespace,
36
+ service: service,
37
+ target: target,
38
+ operation: operation
39
+ }
40
+ end
41
+ end
42
+ end