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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +15 -4
- data/docs/agents.md +212 -0
- data/docs/compatibility.md +48 -2
- data/docs/sdk.md +133 -9
- data/lib/generators/rails_mind/agent/agent_generator.rb +155 -0
- data/lib/generators/rails_mind/agent/templates/headers.rb.tt +6 -0
- data/lib/generators/rails_mind/install/install_generator.rb +4 -0
- data/lib/generators/rails_mind/install/templates/initializer.rb.tt +3 -0
- data/lib/rails_mind/agent_headers.rb +62 -0
- data/lib/rails_mind/client.rb +1 -4
- data/lib/rails_mind/collector.rb +72 -26
- data/lib/rails_mind/configuration.rb +3 -1
- data/lib/rails_mind/context.rb +21 -0
- data/lib/rails_mind/diagnostic.rb +3 -2
- data/lib/rails_mind/integrations/action_mailer.rb +195 -0
- data/lib/rails_mind/integrations/rails.rb +90 -24
- data/lib/rails_mind/railtie.rb +9 -0
- data/lib/rails_mind/sampling.rb +211 -0
- data/lib/rails_mind/transport.rb +44 -6
- data/lib/rails_mind/version.rb +1 -1
- data/lib/rails_mind.rb +1 -0
- metadata +8 -2
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
Context.with((job.rails_mind_context || {}).merge("job_id" => job.job_id))
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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?
|
data/lib/rails_mind/railtie.rb
CHANGED
|
@@ -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
|
data/lib/rails_mind/transport.rb
CHANGED
|
@@ -3,7 +3,8 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module RailsMind
|
|
5
5
|
class Transport
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/rails_mind/version.rb
CHANGED
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
|
|