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
data/lib/flare/storage.rb DELETED
@@ -1,54 +0,0 @@
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
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module Flare
6
- # Value object that turns a group of OTel span_data for a single trace
7
- # into the Flare-JSON wire format the server expects:
8
- #
9
- # {
10
- # "trace_id": "<hex>",
11
- # "trace_rule_id": <int|nil>,
12
- # "root_name": "<string>",
13
- # "started_at": "<iso8601>",
14
- # "duration_ms": <int>,
15
- # "spans": [
16
- # { "id", "parent_id", "name", "started_at",
17
- # "duration_ms", "attributes" }
18
- # ]
19
- # }
20
- #
21
- # The trace_rule_id is read from any span carrying the
22
- # `flare.rule_id` attribute (Path 1 sets it on the sampled root, Path 2
23
- # sets it on the rack owner span via WebMarkerSubscriber).
24
- class TraceBlob
25
- ZERO_SPAN_ID = ("\x00".b * 8).freeze
26
- ROOT_NAME_LIMIT = 255
27
-
28
- def self.build(trace_id:, spans:)
29
- return nil if spans.nil? || spans.empty?
30
- new(trace_id: trace_id, spans: spans)
31
- end
32
-
33
- def initialize(trace_id:, spans:)
34
- @trace_id = trace_id
35
- @spans = spans
36
- end
37
-
38
- def to_h
39
- root = find_root
40
- {
41
- "trace_id" => hexify(@trace_id),
42
- "trace_rule_id" => rule_id_from_spans,
43
- "root_name" => root_name(root),
44
- "started_at" => iso(root&.start_timestamp),
45
- "duration_ms" => duration_ms(root),
46
- "spans" => @spans.map { |s| span_to_h(s) }
47
- }
48
- end
49
-
50
- private
51
-
52
- def find_root
53
- @spans.find { |s| entry_span?(s) && rule_id_attribute(s) } ||
54
- @spans.find { |s| root?(s) } ||
55
- @spans.find { |s| entry_span?(s) } ||
56
- @spans.find { |s| rule_id_attribute(s) } ||
57
- @spans.first
58
- end
59
-
60
- def root?(span)
61
- pid = span.parent_span_id
62
- pid.nil? || pid.empty? || pid == ZERO_SPAN_ID
63
- end
64
-
65
- def entry_span?(span)
66
- return false unless span.respond_to?(:kind)
67
-
68
- span.kind == :server || span.kind == :consumer
69
- end
70
-
71
- def rule_id_from_spans
72
- @spans.each do |s|
73
- value = rule_id_attribute(s)
74
- return value if value
75
- end
76
- nil
77
- end
78
-
79
- def rule_id_attribute(span)
80
- attrs = span.attributes
81
- return nil unless attrs
82
-
83
- attrs[Sampler::RULE_ID_ATTRIBUTE] || attrs[Sampler::RULE_ID_ATTRIBUTE.to_sym]
84
- end
85
-
86
- def root_name(root)
87
- root&.name&.to_s&.slice(0, ROOT_NAME_LIMIT)
88
- end
89
-
90
- def span_to_h(span)
91
- {
92
- "id" => hexify(span.span_id),
93
- "parent_id" => root?(span) ? nil : hexify(span.parent_span_id),
94
- "name" => span.name,
95
- "started_at" => iso(span.start_timestamp),
96
- "duration_ms" => duration_ms(span),
97
- "attributes" => span.attributes || {}
98
- }
99
- end
100
-
101
- def hexify(bytes)
102
- return nil if bytes.nil?
103
- bytes.unpack1("H*")
104
- end
105
-
106
- def iso(nanos)
107
- return nil if nanos.nil?
108
- Time.at(nanos / 1_000_000_000.0).utc.iso8601(6)
109
- end
110
-
111
- def duration_ms(span)
112
- return nil unless span && span.start_timestamp && span.end_timestamp
113
- ((span.end_timestamp - span.start_timestamp) / 1_000_000).to_i
114
- end
115
- end
116
- end
@@ -1,182 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "zlib"
5
- require "stringio"
6
- require "logger"
7
- require "uri"
8
- require "concurrent/atomic/atomic_fixnum"
9
- require "opentelemetry/sdk"
10
-
11
- require_relative "client_headers"
12
- require_relative "sampler"
13
- require_relative "trace_blob"
14
- require_relative "http_transport"
15
- require_relative "deadline"
16
-
17
- module Flare
18
- # Custom OTel exporter. For each batch FilteringSpanProcessor hands over:
19
- #
20
- # 1. Group spans by trace_id.
21
- # 2. For each trace, build a Flare::TraceBlob and gzip-JSON-encode it.
22
- # 3. Check out a presigned R2 PUT URL from UploadUrlPool.
23
- # 4. PUT the gzipped body straight to R2 -- Flare's server is NOT in
24
- # the trace-bytes path.
25
- # 5. After R2 returns 200, POST /api/traces { key } using the
26
- # customer's push token + Flare-Project / Flare-Environment headers.
27
- # That's the self-notify hop the design swapped in for the CF Worker.
28
- #
29
- # 403 from R2 means the presigned URL expired between issue and use;
30
- # discard, check out the next URL, retry once. Pool empty -> FAILURE.
31
- # Notify-POST failure is logged + counted but doesn't fail the export
32
- # (the blob is in R2, just won't be processed; incoming/* lifecycle
33
- # cleans it up in 1hr).
34
- class TraceExporter
35
- SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
36
- FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
37
- TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
38
-
39
- PUT_HEADERS = {
40
- "Content-Type" => "application/json",
41
- "Content-Encoding" => "gzip"
42
- }.freeze
43
-
44
- attr_reader :put_failure_count, :notify_failure_count, :pool_empty_count, :exception_count
45
-
46
- def initialize(pool:, notify_url:, api_key:, project:, environment:,
47
- transport: nil, logger: nil)
48
- @pool = pool
49
- @notify_url = notify_url.to_s
50
- @api_key = api_key
51
- @project = project
52
- @environment = environment
53
- @transport = transport || HttpTransport.new
54
- @logger = logger || Logger.new($stderr, level: Logger::WARN)
55
-
56
- @put_failure_count = Concurrent::AtomicFixnum.new(0)
57
- @notify_failure_count = Concurrent::AtomicFixnum.new(0)
58
- @pool_empty_count = Concurrent::AtomicFixnum.new(0)
59
- @exception_count = Concurrent::AtomicFixnum.new(0)
60
- end
61
-
62
- def export(spans, timeout: nil)
63
- deadline = Deadline.new(timeout)
64
- grouped = spans.group_by(&:trace_id)
65
- return SUCCESS if grouped.empty?
66
-
67
- overall = SUCCESS
68
- grouped.each do |trace_id, group|
69
- return TIMEOUT if deadline.expired?
70
-
71
- result = ship(TraceBlob.build(trace_id: trace_id, spans: group), deadline: deadline)
72
- return TIMEOUT if result == TIMEOUT || deadline.expired?
73
- overall = FAILURE if result == FAILURE
74
- end
75
- overall
76
- rescue StandardError => e
77
- return TIMEOUT if deadline&.expired? || e.is_a?(HttpTransport::DeadlineExceeded)
78
-
79
- @exception_count.increment
80
- @logger.warn("[Flare::TraceExporter] export raised: #{e.class}: #{e.message}")
81
- FAILURE
82
- end
83
-
84
- def force_flush(timeout: nil)
85
- SUCCESS
86
- end
87
-
88
- def shutdown(timeout: nil)
89
- SUCCESS
90
- end
91
-
92
- private
93
-
94
- def ship(blob, deadline:, retried: false)
95
- return FAILURE if blob.nil?
96
- return TIMEOUT if deadline.expired?
97
-
98
- entry = @pool.checkout
99
- if entry.nil?
100
- @pool_empty_count.increment
101
- return FAILURE
102
- end
103
-
104
- body = gzip(JSON.generate(blob.to_h))
105
- response = transport_request(:put, entry[:put_url], body, PUT_HEADERS, timeout: deadline.remaining)
106
- return TIMEOUT if deadline.expired?
107
-
108
- case response.code
109
- when "200", "204"
110
- notify(entry[:key], deadline: deadline)
111
- when "403"
112
- # Presigned URL probably expired; try once more with the next one.
113
- retried ? record_put_failure(response) : ship(blob, deadline: deadline, retried: true)
114
- else
115
- record_put_failure(response)
116
- end
117
- end
118
-
119
- def notify(key, deadline:)
120
- return TIMEOUT if deadline.expired?
121
-
122
- response = transport_request(
123
- :post,
124
- @notify_url,
125
- JSON.generate(key: key),
126
- notify_headers,
127
- timeout: deadline.remaining
128
- )
129
- return TIMEOUT if deadline.expired?
130
- return SUCCESS if response.code == "202"
131
-
132
- @notify_failure_count.increment
133
- @logger.warn("[Flare::TraceExporter] notify failed: HTTP #{response.code}")
134
- SUCCESS
135
- rescue StandardError => e
136
- return TIMEOUT if deadline.expired? || e.is_a?(HttpTransport::DeadlineExceeded)
137
-
138
- @notify_failure_count.increment
139
- @logger.warn("[Flare::TraceExporter] notify exception: #{e.class}: #{e.message}")
140
- SUCCESS
141
- end
142
-
143
- def transport_request(method, *args, timeout:)
144
- parameters = @transport.method(method).parameters
145
- accepts_timeout = parameters.any? do |type, name|
146
- type == :keyrest || ([:key, :keyreq].include?(type) && name == :timeout)
147
- end
148
-
149
- if accepts_timeout
150
- @transport.public_send(method, *args, timeout: timeout)
151
- else
152
- @transport.public_send(method, *args)
153
- end
154
- end
155
-
156
- # Identifies the client on the Flare-API notify POST. The presigned R2
157
- # PUT in #ship deliberately uses PUT_HEADERS only -- adding these there
158
- # could invalidate the signed-header set.
159
- def notify_headers
160
- ClientHeaders.to_h.merge(
161
- "Content-Type" => "application/json",
162
- "Authorization" => "Bearer #{@api_key}",
163
- "Flare-Project" => @project,
164
- "Flare-Environment" => @environment
165
- )
166
- end
167
-
168
- def record_put_failure(response)
169
- @put_failure_count.increment
170
- @logger.warn("[Flare::TraceExporter] PUT failed: HTTP #{response.code}")
171
- FAILURE
172
- end
173
-
174
- def gzip(body)
175
- io = StringIO.new
176
- gz = Zlib::GzipWriter.new(io)
177
- gz.write(body)
178
- gz.close
179
- io.string
180
- end
181
- end
182
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "metric_key"
4
-
5
- module Flare
6
- # Records client-side tracing health into MetricStorage so flare-web can
7
- # warn when local buffering, URL exhaustion, or export errors reduce trace
8
- # fidelity.
9
- class TraceHealthReporter
10
- NAMESPACE = "sdk"
11
- SERVICE = "flare-ruby"
12
- TARGET = "tracing"
13
-
14
- def initialize(processor:, pool:, exporter:)
15
- @processor = processor
16
- @pool = pool
17
- @exporter = exporter
18
- @last = {}
19
- @mutex = Mutex.new
20
- end
21
-
22
- def record(storage, bucket: Time.now.utc)
23
- @mutex.synchronize do
24
- record_counter(storage, bucket, "dropped_spans", @processor.dropped_count.value)
25
- record_counter(storage, bucket, "export_failures", @processor.failed_export_count.value)
26
- record_counter(storage, bucket, "processor_exceptions", @processor.exception_count.value)
27
-
28
- record_counter(storage, bucket, "upload_url_pool_empty", @pool.empty_count.value)
29
- record_counter(storage, bucket, "upload_url_expired", @pool.expired_count.value)
30
-
31
- record_counter(storage, bucket, "r2_put_failures", @exporter.put_failure_count.value)
32
- record_counter(storage, bucket, "notify_failures", @exporter.notify_failure_count.value)
33
- record_counter(storage, bucket, "trace_pool_empty", @exporter.pool_empty_count.value)
34
- record_counter(storage, bucket, "trace_export_exceptions", @exporter.exception_count.value)
35
-
36
- buffer_size = @processor.buffer_size
37
- buffer_high_watermark = @processor.buffer_high_watermark.value
38
- record_gauge(storage, bucket, "buffer_size", buffer_size)
39
- record_gauge(storage, bucket, "buffer_high_watermark", buffer_high_watermark)
40
- record_gauge(storage, bucket, "buffer_limit", @processor.max_queue) if buffer_size.positive? || buffer_high_watermark.positive?
41
- @processor.reset_buffer_high_watermark
42
- end
43
- end
44
-
45
- private
46
-
47
- def record_counter(storage, bucket, operation, current)
48
- previous = @last.fetch(operation, 0)
49
- @last[operation] = current
50
- delta = current - previous
51
- return unless delta.positive?
52
-
53
- storage.add(key(bucket, operation), count: delta, sum_ms: 0, error_count: 0)
54
- end
55
-
56
- def record_gauge(storage, bucket, operation, value)
57
- storage.add(key(bucket, operation), count: 1, sum_ms: value, error_count: 0)
58
- end
59
-
60
- def key(bucket, operation)
61
- MetricKey.new(
62
- bucket: bucket_time(bucket),
63
- namespace: NAMESPACE,
64
- service: SERVICE,
65
- target: TARGET,
66
- operation: operation
67
- )
68
- end
69
-
70
- def bucket_time(time)
71
- Time.utc(time.year, time.month, time.day, time.hour, time.min, 0)
72
- end
73
- end
74
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "concurrent/atomic/atomic_reference"
4
-
5
- module Flare
6
- # Thread-safe pool of presigned R2 PUT URLs the RuleManager fills from
7
- # the /api/rules response. TraceExporter checks one out before each
8
- # upload; if the pool is empty (no active rules, no fresh URLs) it
9
- # returns nil and the exporter gives up on that batch -- caller decides
10
- # what to do.
11
- #
12
- # Each entry is a Hash: { upload_id:, key:, put_url:, expires_at: }.
13
- # expires_at is a Time; entries past their expiry are skipped on checkout.
14
- #
15
- # Fork-safe: after_fork clears the pool so child processes don't reuse
16
- # parent URLs (each child polls its own copy from /api/rules anyway).
17
- class UploadUrlPool
18
- attr_reader :checkouts, :empty_count, :expired_count
19
-
20
- def initialize
21
- @entries_ref = Concurrent::AtomicReference.new([].freeze)
22
- @checkouts = Concurrent::AtomicFixnum.new(0)
23
- @empty_count = Concurrent::AtomicFixnum.new(0)
24
- @expired_count = Concurrent::AtomicFixnum.new(0)
25
- end
26
-
27
- def replace(entries)
28
- normalized = (entries || []).filter_map { |raw| normalize(raw) }
29
- @entries_ref.set(normalized.freeze)
30
- end
31
-
32
- def checkout
33
- now = Time.now
34
- loop do
35
- current = @entries_ref.get
36
- if current.empty?
37
- @empty_count.increment
38
- return nil
39
- end
40
-
41
- candidate, *rest = current
42
- next_state = rest.freeze
43
- next unless @entries_ref.compare_and_set(current, next_state)
44
-
45
- if expired?(candidate, now)
46
- @expired_count.increment
47
- next # try the next one
48
- end
49
-
50
- @checkouts.increment
51
- return candidate
52
- end
53
- end
54
-
55
- def size
56
- @entries_ref.get.length
57
- end
58
-
59
- def empty?
60
- size.zero?
61
- end
62
-
63
- def clear
64
- @entries_ref.set([].freeze)
65
- end
66
-
67
- # Drop URLs that have already passed their expires_at. Cheap; safe to
68
- # call from RuleManager's scheduler in between polls.
69
- def sweep
70
- now = Time.now
71
- current = @entries_ref.get
72
- live = current.reject { |e| expired?(e, now) }
73
- return 0 if live.length == current.length
74
-
75
- @entries_ref.set(live.freeze)
76
- current.length - live.length
77
- end
78
-
79
- # Call from Flare.after_fork. Parent's URLs aren't usable from the
80
- # child's point of view (each child should get its own from a fresh
81
- # /api/rules poll), so just drop them.
82
- def after_fork
83
- clear
84
- end
85
-
86
- private
87
-
88
- def normalize(raw)
89
- h = raw.is_a?(Hash) ? raw : nil
90
- return nil unless h
91
-
92
- upload_id = h[:upload_id] || h["upload_id"]
93
- key = h[:key] || h["key"]
94
- put_url = h[:put_url] || h["put_url"]
95
- expires_at = h[:expires_at] || h["expires_at"]
96
- return nil if upload_id.nil? || key.nil? || put_url.nil?
97
-
98
- expires_at = Time.iso8601(expires_at) if expires_at.is_a?(String)
99
- { upload_id: upload_id, key: key, put_url: put_url, expires_at: expires_at }
100
- rescue StandardError
101
- nil
102
- end
103
-
104
- def expired?(entry, now)
105
- entry[:expires_at] && entry[:expires_at] <= now
106
- end
107
- end
108
- end
data/lib/flare/version.rb DELETED
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Flare
4
- VERSION = "0.4.0"
5
- end
@@ -1,76 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "opentelemetry/sdk"
4
-
5
- module Flare
6
- # Path 2: ActiveSupport::Notifications subscriber that fires on
7
- # `start_processing.action_controller`, after Rails has routed to a
8
- # controller#action. At that point the rack server span's start
9
- # attributes don't yet carry code.namespace/code.function -- only the
10
- # ActionPack instrumentation adds them, and Flare::Sampler's start-time
11
- # decision (RECORD_ONLY) was already locked in.
12
- #
13
- # The subscriber consults the same sampler's rule set, finds any whose
14
- # match_attributes match the now-known controller/action, applies the
15
- # deterministic trace_id_ratio gate (CAF-1: no rate bypass on Path 2),
16
- # and on pass calls marker.mark(trace_id, owner_span_id:, rule_id:).
17
- # FilteringSpanProcessor then forwards every span in the trace to the
18
- # exporter and unmarks when the owner (this rack span) finishes.
19
- class WebMarkerSubscriber
20
- NOTIFICATION = "start_processing.action_controller"
21
-
22
- def initialize(sampler:, marker:)
23
- @sampler = sampler
24
- @marker = marker
25
- end
26
-
27
- def start
28
- @subscriber = ActiveSupport::Notifications.subscribe(NOTIFICATION) do |*, payload|
29
- handle(payload)
30
- end
31
- self
32
- end
33
-
34
- def stop
35
- ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
36
- @subscriber = nil
37
- self
38
- end
39
-
40
- # Public for tests so they don't have to drive ActiveSupport::Notifications.
41
- # current_span lets tests inject a context; in production it's the
42
- # rack server span on the current thread.
43
- def handle(payload, current_span: OpenTelemetry::Trace.current_span)
44
- return unless current_span
45
- ctx = current_span.context
46
- return unless ctx && ctx.valid?
47
-
48
- attrs = candidate_attributes(payload)
49
- return if attrs.empty?
50
-
51
- @sampler.rules.each do |rule|
52
- next unless matches?(rule, attrs)
53
- next unless @sampler.trace_id_ratio(ctx.trace_id) < rule.rate
54
-
55
- current_span.set_attribute(Flare::Sampler::RULE_ID_ATTRIBUTE, rule.id) if current_span.respond_to?(:set_attribute)
56
- @marker.mark(ctx.trace_id, owner_span_id: ctx.span_id, rule_id: rule.id)
57
- break
58
- end
59
- end
60
-
61
- private
62
-
63
- def candidate_attributes(payload)
64
- controller = payload[:controller] || payload["controller"]
65
- action = payload[:action] || payload["action"]
66
- {
67
- "code.namespace" => controller,
68
- "code.function" => action
69
- }.compact
70
- end
71
-
72
- def matches?(rule, attrs)
73
- rule.match_attributes.all? { |k, v| attrs[k] == v }
74
- end
75
- end
76
- end