rails_mind 0.1.0 → 0.3.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.
@@ -0,0 +1,195 @@
1
+ require "active_support/notifications"
2
+ require "securerandom"
3
+
4
+ module RailsMind
5
+ module Integrations
6
+ # Wrap the public delivery entry points without touching arguments or mail
7
+ # contents. The stored class/action names are the only MessageDelivery state
8
+ # read here; reading .message would prematurely render the lazy message.
9
+ module MailerDeliveryContext
10
+ def deliver_now
11
+ MailerNotifications.observe_delivery(self, forced: false) { super }
12
+ end
13
+
14
+ def deliver_now!
15
+ MailerNotifications.observe_delivery(self, forced: true) { super }
16
+ end
17
+ end
18
+
19
+ module MailerCallbacks
20
+ def self.included(base)
21
+ base.around_deliver do |mailer, block|
22
+ MailerNotifications.record_delivery_options(mailer)
23
+ block.call
24
+ end
25
+ end
26
+ end
27
+
28
+ # Mail#deliver! bypasses ActionMailer's notification handler as well as its
29
+ # delivery flags. Observe that public method only inside a known forced
30
+ # ActionMailer invocation, preserving its return value and exceptions.
31
+ module ForcedMailerTransport
32
+ def deliver!(...)
33
+ MailerNotifications.observe_forced_transport(self) { super }
34
+ end
35
+ end
36
+
37
+ module MailerNotifications
38
+ class << self
39
+ def install!
40
+ return if @subscriptions
41
+ @subscriptions = []
42
+ subscribe("process.action_mailer") do |event|
43
+ payload = event.payload
44
+ properties = metadata(payload[:mailer], payload[:action]).merge(operation: "process")
45
+ properties[:attempt_id] = attempt[:attempt_id] if attempt
46
+ emit(event, properties, status: event_error(event) ? "error" : "observed")
47
+ end
48
+ subscribe("deliver.action_mailer") do |event|
49
+ current = attempt
50
+ current[:observed] = true if current
51
+ properties = metadata(event.payload[:mailer], current&.dig(:action)).merge(
52
+ operation: "delivery_attempt_completed", attempt_id: current&.dig(:attempt_id) || SecureRandom.uuid)
53
+ properties.merge!(delivery_options(current[:message])) if current
54
+ properties[:forced] = current[:forced] if current
55
+ properties[:perform_deliveries] = event.payload[:perform_deliveries] if [ true, false ].include?(event.payload[:perform_deliveries])
56
+ status = if event_error(event) then "error"
57
+ elsif properties[:perform_deliveries] == false && properties[:forced] == false then "disabled"
58
+ else "observed"
59
+ end
60
+ emit(event, properties, status: status)
61
+ end
62
+ end
63
+
64
+ def uninstall!
65
+ @subscriptions&.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
66
+ @subscriptions = nil
67
+ end
68
+
69
+ def observe_delivery(delivery, forced:)
70
+ current = new_attempt(delivery, forced: forced)
71
+ return yield unless current
72
+
73
+ previous = attempt
74
+ Thread.current[:rails_mind_mailer_attempt] = current
75
+ Context.with_trace do
76
+ result = nil
77
+ failure = nil
78
+ begin
79
+ result = yield
80
+ rescue StandardError => error
81
+ failure = error
82
+ raise
83
+ ensure
84
+ finish_unobserved(current, result, failure)
85
+ RailsMind.capture_exception(failure, source: "action_mailer") if failure
86
+ end
87
+ end
88
+ ensure
89
+ Thread.current[:rails_mind_mailer_attempt] = previous if current
90
+ end
91
+
92
+ # Configuration describes the attempted transport, never its recipients,
93
+ # credentials, provider responses, or eventual delivery outcome.
94
+ def record_delivery_options(mailer)
95
+ return unless attempt
96
+ attempt[:message] = mailer.message
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ def observe_forced_transport(message)
102
+ current = attempt
103
+ # Callbacks/interceptors can send an unrelated raw Mail::Message while
104
+ # this attempt is active. Only attribute the ActionMailer message that
105
+ # our delivery callback recorded, never another message on the stack.
106
+ return yield unless current && current[:forced] && current[:message].equal?(message)
107
+ current[:observed] = true
108
+ started_at = Time.now
109
+ clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ failure = nil
111
+ begin
112
+ yield
113
+ rescue StandardError => error
114
+ failure = error
115
+ raise
116
+ ensure
117
+ record_forced_transport(current, message, started_at, clock, failure)
118
+ end
119
+ end
120
+
121
+ private
122
+ def attempt = Thread.current[:rails_mind_mailer_attempt]
123
+
124
+ def new_attempt(delivery, forced:)
125
+ { mailer: delivery.instance_variable_get(:@mailer_class).name,
126
+ action: delivery.instance_variable_get(:@action).to_s,
127
+ forced: forced, attempt_id: SecureRandom.uuid, observed: false,
128
+ started_at: Time.now, started_clock: Process.clock_gettime(Process::CLOCK_MONOTONIC) }
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ def metadata(mailer, action)
134
+ { instrumentation: "action_mailer", schema_version: 1, mailer: mailer, action: action }.reject { |_, value| value.nil? }
135
+ end
136
+
137
+ def delivery_options(message)
138
+ return {} unless message.is_a?(::Mail::Message)
139
+ { perform_deliveries: message.perform_deliveries, raise_delivery_errors: message.raise_delivery_errors,
140
+ delivery_method: message.delivery_method.class.name }.reject { |_, value| value.nil? }
141
+ rescue StandardError
142
+ {}
143
+ end
144
+
145
+ def record_forced_transport(current, message, started_at, clock, failure)
146
+ properties = metadata(current[:mailer], current[:action]).merge(delivery_options(message)).merge(
147
+ operation: "delivery_attempt_completed", attempt_id: current[:attempt_id], forced: true)
148
+ properties[:exception_class] = failure.class.name if failure
149
+ RailsMind.client.emit(:span, "#{current[:mailer]}##{current[:action]} delivery_attempt_completed",
150
+ occurred_at: started_at, duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000,
151
+ status: failure ? "error" : "observed", properties: properties)
152
+ RailsMind.capture_exception(failure, source: "action_mailer") if failure
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def finish_unobserved(current, result, failure)
158
+ return if current[:observed]
159
+ properties = metadata(current[:mailer], current[:action]).merge(delivery_options(current[:message])).merge(
160
+ operation: "delivery_not_observed", attempt_id: current[:attempt_id], forced: current[:forced])
161
+ properties[:exception_class] = failure.class.name if failure
162
+ status = failure ? "error" : (result == false ? "aborted" : "unknown")
163
+ RailsMind.client.emit(:span, "#{current[:mailer]}##{current[:action]} delivery_not_observed",
164
+ occurred_at: current[:started_at], duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - current[:started_clock]) * 1000,
165
+ status: status, properties: properties)
166
+ rescue StandardError
167
+ nil
168
+ end
169
+
170
+ def event_error(event)
171
+ event.payload[:exception_object] || event.payload[:error]
172
+ end
173
+
174
+ def emit(event, properties, status:)
175
+ error = event_error(event)
176
+ properties[:exception_class] = error.class.name if error
177
+ Context.with_trace do
178
+ label = [ properties[:mailer], properties[:action] ].compact.join("#")
179
+ RailsMind.client.emit(:span, "#{label} #{properties[:operation]}",
180
+ occurred_at: Time.now - event.duration / 1000, duration_ms: event.duration, status: status, properties: properties)
181
+ RailsMind.capture_exception(error, source: "action_mailer") if error
182
+ end
183
+ end
184
+
185
+ def subscribe(name, &callback)
186
+ @subscriptions << ActiveSupport::Notifications.subscribe(name) do |event|
187
+ callback.call(event) unless Context.suppressed?
188
+ rescue StandardError
189
+ nil
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -14,17 +14,18 @@ module RailsMind
14
14
 
15
15
  def call(env)
16
16
  Context.with(request_id: env["action_dispatch.request_id"] || SecureRandom.uuid) do
17
- previous = Thread.current[:rails_mind_queries]
18
- Thread.current[:rails_mind_queries] = { count: 0, fingerprints: Hash.new(0) }
19
- @app.call(env)
20
- rescue StandardError => error
21
- # Preserve identity before this request boundary unwinds. The Rails
22
- # reporter may observe the same exception later; object dedup prevents
23
- # a second RailsMind event while other reporters remain unaffected.
24
- RailsMind.capture_exception(error, source: "rack")
25
- raise
26
- ensure
27
- Thread.current[:rails_mind_queries] = previous
17
+ Context.with_trace do
18
+ previous = Thread.current[:rails_mind_queries]
19
+ Thread.current[:rails_mind_queries] = { count: 0, fingerprints: Hash.new(0) }
20
+ @app.call(env)
21
+ rescue StandardError => error
22
+ # Capture identity before this request boundary unwinds. Object
23
+ # dedup still leaves other error reporters unaffected.
24
+ RailsMind.capture_exception(error, source: "rack")
25
+ raise
26
+ ensure
27
+ Thread.current[:rails_mind_queries] = previous
28
+ end
28
29
  end
29
30
  end
30
31
  end
@@ -32,16 +33,37 @@ module RailsMind
32
33
  module JobContext
33
34
  def self.included(base)
34
35
  base.around_enqueue do |job, block|
35
- job.rails_mind_context = Context.snapshot.transform_keys(&:to_s)
36
- block.call
36
+ Context.with_trace do
37
+ job.rails_mind_context = Context.effective_snapshot.transform_keys(&:to_s)
38
+ block.call
39
+ end
37
40
  end
38
41
  base.around_perform do |job, block|
39
- job.rails_mind_started_at = Time.now
40
- Context.with((job.rails_mind_context || {}).merge("job_id" => job.job_id)) { block.call }
42
+ job.rails_mind_started_at ||= Time.now
43
+ Context.with((job.rails_mind_context || {}).merge("job_id" => job.job_id)) do
44
+ Context.with_trace do
45
+ job.rails_mind_context = Context.effective_snapshot.transform_keys(&:to_s)
46
+ block.call
47
+ end
48
+ end
41
49
  end
42
50
  end
43
51
 
44
- attr_accessor :rails_mind_context, :rails_mind_started_at
52
+ attr_accessor :rails_mind_context, :rails_mind_started_at, :rails_mind_execution_timing
53
+
54
+ # ActiveJob runs retry/discard handlers after around_perform has unwound.
55
+ # Keep the restored boundary around the entire execution so those handlers
56
+ # and their newly serialized attempts retain the originating trace.
57
+ def perform_now(...)
58
+ self.rails_mind_started_at = nil
59
+ self.rails_mind_execution_timing = nil
60
+ Context.with((rails_mind_context || {}).merge("job_id" => job_id)) do
61
+ Context.with_trace do
62
+ self.rails_mind_context = Context.effective_snapshot.transform_keys(&:to_s)
63
+ super
64
+ end
65
+ end
66
+ end
45
67
 
46
68
  def serialize = super.merge("rails_mind_context" => rails_mind_context || {})
47
69
 
@@ -76,17 +98,38 @@ module RailsMind
76
98
  fingerprint = Digest::SHA256.hexdigest(normalized)[0, 24]
77
99
  queries[:fingerprints][fingerprint] += 1 if queries[:fingerprints].key?(fingerprint) || queries[:fingerprints].size < 100
78
100
  end
79
- %w[perform.active_job enqueue_retry.active_job retry_stopped.active_job discard.active_job].each do |name|
101
+ %w[enqueue.active_job enqueue_at.active_job perform_start.active_job perform.active_job enqueue_retry.active_job retry_stopped.active_job discard.active_job].each do |name|
80
102
  subscribe(name) do |event|
81
103
  job = event.payload[:job]
82
104
  next unless job
83
- error = event.payload[:exception_object] || event.payload[:error]
84
- Context.with((job.rails_mind_context || {}).merge("job_id" => job.job_id)) do
85
- RailsMind.client.emit(:job, job.class.name, duration_ms: event.duration,
86
- status: error ? "error" : (name == "perform.active_job" ? "ok" : name.split(".").first),
87
- properties: { operation: name.split(".").first, queue: job.queue_name, executions: job.executions,
88
- queue_delay_ms: job.enqueued_at && job.rails_mind_started_at ? [ (job.rails_mind_started_at.to_f - job.enqueued_at.to_f) * 1000, 0 ].max : nil })
89
- RailsMind.capture_exception(error, source: "active_job") if error
105
+ operation = name.split(".").first
106
+ enqueue = %w[enqueue enqueue_at].include?(operation)
107
+ error = event.payload[:exception_object] || event.payload[:error] || (job.enqueue_error if enqueue)
108
+ if operation == "perform_start"
109
+ job.rails_mind_started_at = Time.now
110
+ job.rails_mind_execution_timing = { enqueued_at: job.enqueued_at, scheduled_at: job.scheduled_at }
111
+ end
112
+ # A callback may abort before the SDK's around_enqueue is entered.
113
+ values = job.rails_mind_context || Context.effective_snapshot
114
+ Context.with(values.merge("job_id" => job.job_id)) do
115
+ Context.with_trace do
116
+ job.rails_mind_context = Context.effective_snapshot.transform_keys(&:to_s)
117
+ status = if error
118
+ "error"
119
+ elsif event.payload[:aborted]
120
+ "aborted"
121
+ elsif enqueue
122
+ "enqueued"
123
+ elsif operation == "perform_start"
124
+ "started"
125
+ else
126
+ operation == "perform" ? "ok" : operation
127
+ end
128
+ kind = enqueue || operation == "perform_start" ? :span : :job
129
+ RailsMind.client.emit(kind, job.class.name, duration_ms: event.duration, status: status,
130
+ properties: job_properties(job, operation))
131
+ RailsMind.capture_exception(error, source: "active_job") if error
132
+ end
90
133
  end
91
134
  end
92
135
  end
@@ -107,6 +150,29 @@ module RailsMind
107
150
 
108
151
  private
109
152
 
153
+ def job_properties(job, operation)
154
+ timing = if %w[perform_start perform].include?(operation)
155
+ job.rails_mind_execution_timing || {}
156
+ else
157
+ { enqueued_at: job.enqueued_at, scheduled_at: job.scheduled_at }
158
+ end
159
+ enqueued_at, scheduled_at = timing.values_at(:enqueued_at, :scheduled_at)
160
+ execution_timing = job.rails_mind_execution_timing || {}
161
+ queued = execution_timing[:enqueued_at]
162
+ scheduled = execution_timing[:scheduled_at]
163
+ started = job.rails_mind_started_at
164
+ properties = { instrumentation: "active_job", schema_version: 1, operation: operation,
165
+ job_class: job.class.name, queue: job.queue_name, executions: job.executions,
166
+ enqueued_at: enqueued_at&.iso8601(6), scheduled_at: scheduled_at&.iso8601(6) }
167
+ unless %w[enqueue enqueue_at].include?(operation)
168
+ properties[:queue_delay_ms] = [ (started.to_f - queued.to_f) * 1000, 0 ].max if started && queued
169
+ if started && queued && scheduled
170
+ properties[:eligible_queue_delay_ms] = [ (started.to_f - [ queued.to_f, scheduled.to_f ].max) * 1000, 0 ].max
171
+ end
172
+ end
173
+ properties.compact
174
+ end
175
+
110
176
  def subscribe(name, &callback)
111
177
  @subscriptions << ActiveSupport::Notifications.subscribe(name) do |event|
112
178
  callback.call(event) unless Context.suppressed?
@@ -1,4 +1,5 @@
1
1
  require_relative "integrations/rails"
2
+ require_relative "integrations/action_mailer"
2
3
 
3
4
  module RailsMind
4
5
  class Railtie < Rails::Railtie
@@ -8,10 +9,18 @@ module RailsMind
8
9
 
9
10
  config.after_initialize do
10
11
  Integrations::RailsNotifications.install!
12
+ Integrations::MailerNotifications.install!
11
13
  Rails.error.subscribe(Integrations::ErrorSubscriber.new)
12
14
  ActiveSupport.on_load(:active_job) do
13
15
  include Integrations::JobContext unless included_modules.include?(Integrations::JobContext)
14
16
  end
17
+ ActiveSupport.on_load(:action_mailer) do
18
+ include Integrations::MailerCallbacks unless included_modules.include?(Integrations::MailerCallbacks)
19
+ unless ActionMailer::MessageDelivery.ancestors.include?(Integrations::MailerDeliveryContext)
20
+ ActionMailer::MessageDelivery.prepend(Integrations::MailerDeliveryContext)
21
+ end
22
+ Mail::Message.prepend(Integrations::ForcedMailerTransport) unless Mail::Message.ancestors.include?(Integrations::ForcedMailerTransport)
23
+ end
15
24
  at_exit { RailsMind.shutdown }
16
25
  end
17
26
 
@@ -0,0 +1,211 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "time"
4
+ require "digest"
5
+
6
+ module RailsMind
7
+ # Called under Collector's mutex. Decisions and queue-loss accounting share
8
+ # the same critical section as report rotation; reports are immutable once cut.
9
+ class Sampling
10
+ REPORT_INTERVAL = 60
11
+ POLICY_INTERVAL = 60
12
+ MAX_POLICY_TTL = 600
13
+ MAX_ENDPOINTS = 100
14
+ MAX_PENDING = 5
15
+ MAX_COUNT = 1_000_000_000
16
+ MAX_REPORT_AGE = 7 * 86_400
17
+ ENDPOINT = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*#[A-Za-z_][A-Za-z0-9_!?]*\z/
18
+ UUID = /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/i
19
+
20
+ def initialize(config, clock: -> { Time.now.utc }, monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
21
+ @config, @clock, @monotonic = config, clock, monotonic
22
+ @counts, @reports = {}, []
23
+ @from = @clock.call
24
+ @last_rotation = @monotonic.call
25
+ @next_policy_at = @next_report_at = 0
26
+ @policy = nil
27
+ @activated = false
28
+ @limited = false
29
+ @policy_ids = []
30
+ @stats = { observed: 0, retained: 0, sampled_out: 0, queue_dropped: 0, unreported: 0, reports_lost: 0 }
31
+ end
32
+
33
+ def consider(event)
34
+ return [ true, nil ] unless event["kind"] == "request"
35
+ @activated = true
36
+ rate, state, policy_id = effective_policy
37
+ name = event["name"]
38
+ unless eligible?(event) && name.is_a?(String) && name.bytesize <= 200 && name.match?(ENDPOINT)
39
+ annotate(event, 1.0, "protected", policy_id)
40
+ return [ true, nil ]
41
+ end
42
+ if !@counts.key?(name) && @counts.size >= MAX_ENDPOINTS
43
+ @stats[:unreported] += 1
44
+ @limited = true
45
+ annotate(event, 1.0, "unavailable", policy_id)
46
+ return [ true, nil ]
47
+ end
48
+ count = @counts[name] ||= { "name" => name, "observed" => 0, "retained" => 0, "sampled_out" => 0, "queue_dropped" => 0 }
49
+ if count["observed"] >= MAX_COUNT
50
+ @stats[:unreported] += 1
51
+ @limited = true
52
+ annotate(event, 1.0, "unavailable", policy_id)
53
+ return [ true, nil ]
54
+ end
55
+ rate = @policy[:endpoints].fetch(name, rate) if state == "active"
56
+ selected = rate >= 1 || (rate.positive? && fraction(event["id"]) < rate)
57
+ count["observed"] += 1
58
+ count[selected ? "retained" : "sampled_out"] += 1
59
+ @stats[:observed] += 1
60
+ @stats[selected ? :retained : :sampled_out] += 1
61
+ @policy_ids << (policy_id || state) unless @policy_ids.include?(policy_id || state) || @policy_ids.size >= 2
62
+ annotate(event, rate, state, policy_id) if selected
63
+ [ selected, count ]
64
+ end
65
+
66
+ def queue_dropped(count)
67
+ return unless count
68
+ count["queue_dropped"] += 1
69
+ @stats[:queue_dropped] += 1
70
+ end
71
+
72
+ def apply_policy(value)
73
+ return false unless value.is_a?(Hash) && value["version"] == 1 && boolean?(value["enabled"])
74
+ id = value["id"]
75
+ return false unless id.is_a?(String) && id.bytesize.between?(1, 200) && !id.match?(/\p{Cc}/)
76
+ issued = parse_time(value["issued_at"])
77
+ expires = parse_time(value["expires_at"])
78
+ now = @clock.call
79
+ return false unless issued && expires && issued <= now + 300 && expires > now && expires > issued && expires - issued <= MAX_POLICY_TTL
80
+ return false if @policy && issued < @policy[:issued_at]
81
+ rate = value["default_rate"]
82
+ rows = value["endpoints"]
83
+ return false unless valid_rate?(rate) && rows.is_a?(Array) && rows.size <= MAX_ENDPOINTS
84
+ endpoints = {}
85
+ rows.each do |row|
86
+ return false unless row.is_a?(Hash) && row["name"].is_a?(String) && row["name"].bytesize.between?(1, 800) && row["name"].length <= 200 && !row["name"].match?(/\p{Cc}/) && valid_rate?(row["rate"])
87
+ return false if endpoints.key?(row["name"])
88
+ endpoints[row["name"]] = row["rate"].to_f
89
+ end
90
+ @policy = { id: id, issued_at: issued, expires_at: expires, deadline: @monotonic.call + [ expires - now, MAX_POLICY_TTL ].min,
91
+ enabled: value["enabled"], default_rate: rate.to_f, endpoints: endpoints }
92
+ true
93
+ rescue ArgumentError, EncodingError
94
+ false
95
+ end
96
+
97
+ def policy_due?
98
+ @activated && @monotonic.call >= @next_policy_at
99
+ end
100
+
101
+ def policy_requested!
102
+ @next_policy_at = @monotonic.call + POLICY_INTERVAL
103
+ end
104
+
105
+ def rotate(force: false, byte_budget:)
106
+ return unless supported?
107
+ now = @clock.call
108
+ while @reports.first && Time.iso8601(@reports.first.fetch("from")) < now - MAX_REPORT_AGE
109
+ @reports.shift
110
+ @stats[:reports_lost] += 1
111
+ @limited = true
112
+ end
113
+ return if @counts.empty? || (!force && @monotonic.call - @last_rotation < REPORT_INTERVAL)
114
+ if now < @from || now - @from > 86_400
115
+ @stats[:unreported] += @counts.values.sum { |entry| entry["observed"] }
116
+ @limited = true
117
+ else
118
+ rows = []
119
+ template = { "version" => 1, "limited" => @limited, "id" => SecureRandom.uuid, "from" => @from.iso8601(6), "to" => now.iso8601(6),
120
+ "enabled" => @config.automatic_sampling, "policy_id" => @policy_ids.size == 1 ? @policy_ids.first : "mixed", "endpoints" => rows }
121
+ @counts.each_value do |entry|
122
+ candidate = template.merge("endpoints" => rows + [ entry ])
123
+ if JSON.generate(candidate).bytesize > byte_budget
124
+ store_report(template.merge("endpoints" => rows)) if rows.any?
125
+ rows = []
126
+ template = template.merge("id" => SecureRandom.uuid)
127
+ end
128
+ if JSON.generate(template.merge("endpoints" => [ entry ])).bytesize <= byte_budget
129
+ rows << entry.dup
130
+ else
131
+ @stats[:unreported] += entry["observed"]
132
+ @limited = true
133
+ end
134
+ end
135
+ store_report(template.merge("endpoints" => rows)) if rows.any?
136
+ end
137
+ @counts, @policy_ids = {}, []
138
+ @from, @last_rotation = now, @monotonic.call
139
+ end
140
+
141
+ def report(force: false)
142
+ @reports.first if force || @monotonic.call >= @next_report_at
143
+ end
144
+
145
+ def report_sent!
146
+ @next_report_at = @monotonic.call + REPORT_INTERVAL
147
+ end
148
+
149
+ def acknowledge(id)
150
+ return unless id.is_a?(String) && id.match?(UUID)
151
+ removed = @reports.reject! { |report| report["id"] == id }
152
+ @next_report_at = 0 if removed && @reports.any?
153
+ end
154
+
155
+ def discard!
156
+ @stats[:reports_lost] += @reports.size
157
+ @stats[:unreported] += @counts.values.sum { |entry| entry["observed"] }
158
+ @reports, @counts = [], {}
159
+ end
160
+
161
+ def supported? = !@policy.nil?
162
+ def pending? = supported? && (@reports.any? || @counts.any?)
163
+ def stats = @stats.merge(limited: @limited, pending_reports: @reports.size, policy_state: effective_policy[1], policy_id: @policy&.fetch(:id))
164
+
165
+ private
166
+
167
+ def eligible?(event)
168
+ status, duration = event["status"], event["duration_ms"]
169
+ repeated = event.fetch("properties", {})["repeated_query_count"]
170
+ status.is_a?(String) && status.match?(/\A[23][0-9]{2}\z/) && duration.is_a?(Numeric) && duration.finite? && duration >= 0 && duration < 1000 && !(repeated.is_a?(Numeric) && repeated >= 3)
171
+ end
172
+
173
+ def effective_policy
174
+ return [ 1.0, "disabled", @policy&.fetch(:id) ] unless @config.automatic_sampling
175
+ return [ 1.0, "unavailable", nil ] unless @policy
176
+ return [ 1.0, "expired", @policy[:id] ] if @monotonic.call >= @policy[:deadline] || @clock.call >= @policy[:expires_at]
177
+ return [ 1.0, "disabled", @policy[:id] ] unless @policy[:enabled]
178
+ [ @policy[:default_rate], "active", @policy[:id] ]
179
+ end
180
+
181
+ def annotate(event, rate, state, policy_id)
182
+ event["properties"] = event.fetch("properties", {}).merge("rails_mind_sampling" => {
183
+ "version" => 1, "policy_id" => policy_id, "rate" => rate, "state" => state })
184
+ end
185
+
186
+ def store_report(report)
187
+ if @reports.size >= MAX_PENDING
188
+ @reports.shift
189
+ @stats[:reports_lost] += 1
190
+ @limited = true
191
+ end
192
+ report["limited"] ||= @limited
193
+ @reports << deep_freeze(JSON.parse(JSON.generate(report)))
194
+ end
195
+
196
+ def deep_freeze(value)
197
+ case value
198
+ when Hash then value.each { |key, child| deep_freeze(key); deep_freeze(child) }
199
+ when Array then value.each { |child| deep_freeze(child) }
200
+ end
201
+ value.freeze
202
+ end
203
+
204
+ def fraction(id) = Digest::SHA256.digest("rails_mind:sampling:v1:#{id.to_s.downcase}").unpack1("Q>") / (2.0**64)
205
+ def boolean?(value) = value == true || value == false
206
+ def valid_rate?(value) = (value.is_a?(Float) || value.is_a?(Integer)) && value.finite? && value.between?(0, 1)
207
+ def parse_time(value)
208
+ Time.iso8601(value) if value.is_a?(String) && value.bytesize <= 40
209
+ end
210
+ end
211
+ end
@@ -3,7 +3,8 @@ require "json"
3
3
 
4
4
  module RailsMind
5
5
  class Transport
6
- Result = Struct.new(:success, :retryable, :retry_after, keyword_init: true)
6
+ MAX_RESPONSE_BYTES = 64 * 1024
7
+ Result = Struct.new(:success, :retryable, :retry_after, :sampling_policy, :sampling_report_ack, keyword_init: true)
7
8
  TRANSIENT_ERRORS = [ Timeout::Error, IOError, EOFError, SocketError, Errno::ECONNRESET,
8
9
  Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE, OpenSSL::SSL::SSLError ].freeze
9
10
 
@@ -15,28 +16,65 @@ module RailsMind
15
16
  end
16
17
 
17
18
  def send_batch(json)
19
+ request = Net::HTTP::Post.new(@uri)
20
+ request.body = json
21
+ perform(request)
22
+ end
23
+
24
+ def fetch_sampling_policy
25
+ uri = @uri.dup
26
+ uri.path = uri.path.sub(%r{/events$}, "/sampling_policy")
27
+ perform(Net::HTTP::Get.new(uri))
28
+ end
29
+
30
+ private
31
+
32
+ def perform(request)
18
33
  Context.suppress do
19
- request = Net::HTTP::Post.new(@uri)
20
34
  request["Authorization"] = "Bearer #{@config.token}"
21
35
  request["Content-Type"] = "application/json"
22
36
  request["User-Agent"] = "rails_mind/#{VERSION}"
23
- request.body = json
37
+ request["X-RailsMind-Automatic-Sampling"] = @config.automatic_sampling ? "enabled" : "disabled"
24
38
  http = @http_factory.call(@uri)
25
39
  http.use_ssl = @uri.scheme == "https"
26
40
  http.open_timeout = @config.open_timeout
27
41
  http.read_timeout = @config.read_timeout
28
42
  http.write_timeout = @config.read_timeout
29
43
  http.max_retries = 0
30
- response = http.start { |connection| connection.request(request) }
44
+ body, streamed, oversized = +"", false, false
45
+ response = http.start do |connection|
46
+ connection.request(request) do |reply|
47
+ streamed = true
48
+ reply.read_body do |chunk|
49
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
50
+ oversized = true
51
+ elsif !oversized
52
+ body << chunk
53
+ end
54
+ end
55
+ end
56
+ end
57
+ body = response.body.to_s if !streamed && response.respond_to?(:body)
31
58
  code = response.code.to_i
59
+ control = if !oversized && body.bytesize <= MAX_RESPONSE_BYTES && ((200..299).cover?(code) || code == 429)
60
+ parse_control(body)
61
+ else
62
+ {}
63
+ end
32
64
  Result.new(success: (200..299).cover?(code), retryable: code == 429 || code >= 500,
33
- retry_after: retry_after(response["Retry-After"]))
65
+ retry_after: retry_after(response["Retry-After"]), sampling_policy: control["sampling_policy"],
66
+ sampling_report_ack: control["sampling_report_ack"])
34
67
  end
35
68
  rescue *TRANSIENT_ERRORS
36
69
  Result.new(success: false, retryable: true)
37
70
  end
38
71
 
39
- private
72
+ def parse_control(body)
73
+ value = JSON.parse(body)
74
+ value.is_a?(Hash) ? value : {}
75
+ rescue JSON::ParserError, EncodingError
76
+ {}
77
+ end
40
78
 
41
79
  def retry_after(value)
42
80
  return nil unless value
@@ -1,3 +1,3 @@
1
1
  module RailsMind
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/rails_mind.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "rails_mind/configuration"
3
3
  require_relative "rails_mind/context"
4
4
  require_relative "rails_mind/redactor"
5
5
  require_relative "rails_mind/transport"
6
+ require_relative "rails_mind/sampling"
6
7
  require_relative "rails_mind/collector"
7
8
  require_relative "rails_mind/client"
8
9