rails_mind 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/CONTRIBUTING.md +81 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/docs/compatibility.md +66 -0
- data/docs/sdk.md +225 -0
- data/javascript/rails_mind.js +49 -0
- data/lib/generators/rails_mind/install/install_generator.rb +37 -0
- data/lib/generators/rails_mind/install/templates/initializer.rb.tt +20 -0
- data/lib/rails_mind/client.rb +123 -0
- data/lib/rails_mind/collector.rb +153 -0
- data/lib/rails_mind/configuration.rb +81 -0
- data/lib/rails_mind/context.rb +35 -0
- data/lib/rails_mind/diagnostic.rb +39 -0
- data/lib/rails_mind/installation_check.rb +38 -0
- data/lib/rails_mind/integrations/ahoy.rb +68 -0
- data/lib/rails_mind/integrations/open_telemetry.rb +77 -0
- data/lib/rails_mind/integrations/rails.rb +120 -0
- data/lib/rails_mind/railtie.rb +20 -0
- data/lib/rails_mind/redactor.rb +32 -0
- data/lib/rails_mind/transport.rb +49 -0
- data/lib/rails_mind/version.rb +3 -0
- data/lib/rails_mind.rb +34 -0
- data/lib/tasks/rails_mind.rake +15 -0
- metadata +108 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "time"
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailsMind
|
|
6
|
+
class Client
|
|
7
|
+
KINDS = %w[request span error event visit flag job check].freeze
|
|
8
|
+
FIELDS = %i[id kind occurred_at name duration_ms status release trace_id span_id parent_span_id request_id job_id visitor_id user_id account_id properties].freeze
|
|
9
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
10
|
+
attr_reader :config, :collector
|
|
11
|
+
|
|
12
|
+
def initialize(config, collector: nil)
|
|
13
|
+
@config = config.validate!
|
|
14
|
+
@redactor = Redactor.new
|
|
15
|
+
@collector = collector || (Collector.new(config) if config.enabled?)
|
|
16
|
+
@error_mutex = Mutex.new
|
|
17
|
+
@reported_errors = ObjectSpace::WeakMap.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit(kind, name, properties: {}, **attributes)
|
|
21
|
+
return false if !collector || Context.suppressed? || !enabled_signal?(kind)
|
|
22
|
+
event = Context.snapshot.merge(otel_context).merge(attributes.slice(*FIELDS))
|
|
23
|
+
event.merge!(kind: kind.to_s, name: name.to_s, properties: properties,
|
|
24
|
+
id: stable_id(attributes[:id]), occurred_at: timestamp(attributes[:occurred_at]), release: config.release)
|
|
25
|
+
event = event.reject { |_, value| value.nil? }
|
|
26
|
+
check_id = event[:id] if kind.to_s == "check"
|
|
27
|
+
event = @redactor.call(event)
|
|
28
|
+
event = config.before_send.call(event) if config.before_send
|
|
29
|
+
return false unless event.is_a?(Hash)
|
|
30
|
+
# Hooks cannot add protocol keys or bypass the final redaction pass.
|
|
31
|
+
event = @redactor.call(event.transform_keys(&:to_s).slice(*FIELDS.map(&:to_s)))
|
|
32
|
+
if check_id
|
|
33
|
+
return false unless event["kind"] == "check" && event["id"] == check_id
|
|
34
|
+
event = event.slice("id", "kind", "occurred_at", "name", "properties")
|
|
35
|
+
event["properties"] = event["properties"].slice("sdk_version") if event["properties"].is_a?(Hash)
|
|
36
|
+
end
|
|
37
|
+
return false unless valid_event?(event)
|
|
38
|
+
collector.push(event)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def capture_exception(error, handled: false, severity: :error, context: {}, source: nil, **_options)
|
|
44
|
+
return false unless config.errors
|
|
45
|
+
# Rails reports an exception through more than one boundary. A weak key map
|
|
46
|
+
# avoids retaining exceptions; identity dedup applies only to this object.
|
|
47
|
+
@error_mutex.synchronize do
|
|
48
|
+
return false if @reported_errors.key?(error)
|
|
49
|
+
@reported_errors[error] = true
|
|
50
|
+
end
|
|
51
|
+
message = config.capture_exception_message ? @redactor.scrub(error.message.to_s) : "[Message omitted]"
|
|
52
|
+
frames = Array(error.backtrace).first(40).map { |frame| safe_frame(frame) }
|
|
53
|
+
Context.with(context.slice(*Context::KEYS)) do
|
|
54
|
+
emit(:error, error.class.name, status: "error", properties: {
|
|
55
|
+
exception_class: error.class.name, message: message, backtrace: frames,
|
|
56
|
+
handled: handled, severity: severity.to_s, source: source
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stable_id(value = nil)
|
|
64
|
+
return SecureRandom.uuid unless value
|
|
65
|
+
return value.to_s.downcase if value.to_s.match?(UUID)
|
|
66
|
+
# Deterministic UUID-shaped ID for existing Ahoy installations using ULIDs.
|
|
67
|
+
hex = Digest::SHA256.hexdigest("rails_mind:#{value}")[0, 32]
|
|
68
|
+
hex[12] = "8"
|
|
69
|
+
hex[16] = "8"
|
|
70
|
+
[ hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12] ].join("-")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def valid_event?(event)
|
|
76
|
+
return false unless KINDS.include?(event["kind"]) && event["id"].to_s.match?(UUID) && event["properties"].is_a?(Hash)
|
|
77
|
+
return false unless event["name"].is_a?(String) && !event["name"].empty?
|
|
78
|
+
# One malformed field must not poison a whole batch at the hosted boundary.
|
|
79
|
+
(FIELDS - %i[duration_ms properties]).each do |field|
|
|
80
|
+
key = field.to_s
|
|
81
|
+
next if event[key].nil?
|
|
82
|
+
return false unless event[key].is_a?(String)
|
|
83
|
+
event[key] = event[key].byteslice(0, 500).scrub("")
|
|
84
|
+
end
|
|
85
|
+
event["name"] = event["name"][0, 200]
|
|
86
|
+
Time.iso8601(event["occurred_at"])
|
|
87
|
+
duration = event["duration_ms"]
|
|
88
|
+
duration.nil? || ((duration.is_a?(Integer) || duration.is_a?(Float)) && duration.finite? && duration >= 0 && duration < 86_400_000)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enabled_signal?(kind)
|
|
92
|
+
case kind.to_s
|
|
93
|
+
when "check" then true
|
|
94
|
+
when "request", "span", "job" then config.apm
|
|
95
|
+
when "error" then config.errors
|
|
96
|
+
when "event", "visit" then config.analytics
|
|
97
|
+
when "flag" then config.flags
|
|
98
|
+
else false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def timestamp(value)
|
|
103
|
+
time = value.respond_to?(:iso8601) ? value : (value ? Time.iso8601(value.to_s) : Time.now)
|
|
104
|
+
time.iso8601(6)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def safe_frame(frame)
|
|
108
|
+
frame = frame.to_s
|
|
109
|
+
frame = frame.delete_prefix("#{Rails.root}/") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
110
|
+
# Remove machine/user home paths, retaining source filename + line + method.
|
|
111
|
+
frame = frame.sub(%r{\A.*?/(app|lib|config|test|spec)/}, '\\1/')
|
|
112
|
+
frame = frame.sub(%r{\A/(?:[^/:]+/)*([^/:]+:\d+)}, '\\1')
|
|
113
|
+
@redactor.scrub(frame)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def otel_context
|
|
117
|
+
return {} unless defined?(::OpenTelemetry::Trace)
|
|
118
|
+
context = ::OpenTelemetry::Trace.current_span.context
|
|
119
|
+
return {} unless context.valid?
|
|
120
|
+
{ trace_id: context.hex_trace_id, span_id: context.hex_span_id }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module RailsMind
|
|
2
|
+
class Collector
|
|
3
|
+
def initialize(config, transport: Transport.new(config), autostart: true)
|
|
4
|
+
@config, @transport, @autostart = config, transport, autostart
|
|
5
|
+
reset_process
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def push(event)
|
|
9
|
+
check_process
|
|
10
|
+
json = JSON.generate(event)
|
|
11
|
+
@mutex.synchronize do
|
|
12
|
+
reason = if @closed then :closed
|
|
13
|
+
elsif json.bytesize > @config.max_event_bytes || json.bytesize + 13 > @config.max_batch_bytes then :oversize
|
|
14
|
+
elsif @queue.size + @in_flight >= @config.queue_capacity then :overflow
|
|
15
|
+
end
|
|
16
|
+
if reason
|
|
17
|
+
drop(event[:kind] || event["kind"], reason)
|
|
18
|
+
return false
|
|
19
|
+
end
|
|
20
|
+
@queue << [ json.freeze, event[:kind] || event["kind"] ]
|
|
21
|
+
@stats[:enqueued] += 1
|
|
22
|
+
start_worker if @autostart
|
|
23
|
+
@condition.broadcast if @queue.size >= @config.batch_size
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stats
|
|
29
|
+
check_process
|
|
30
|
+
@mutex.synchronize { @stats.merge(queued: @queue.size, in_flight: @in_flight, losses: @losses.dup) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flush(timeout: @config.shutdown_timeout)
|
|
34
|
+
check_process
|
|
35
|
+
deadline = monotonic + timeout
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
start_worker
|
|
38
|
+
@force_flush = true
|
|
39
|
+
@condition.broadcast
|
|
40
|
+
while @queue.any? || @in_flight.positive?
|
|
41
|
+
remaining = deadline - monotonic
|
|
42
|
+
return false if remaining <= 0
|
|
43
|
+
@condition.wait(@mutex, remaining)
|
|
44
|
+
end
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shutdown(timeout: @config.shutdown_timeout)
|
|
50
|
+
check_process
|
|
51
|
+
@mutex.synchronize { @closed = true }
|
|
52
|
+
success = flush(timeout: timeout)
|
|
53
|
+
@mutex.synchronize { @stopping = true; @condition.broadcast }
|
|
54
|
+
@worker&.join(0.05)
|
|
55
|
+
unless success
|
|
56
|
+
@worker&.kill
|
|
57
|
+
@worker&.join
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@queue.each { |_, kind| drop(kind, :shutdown) }
|
|
60
|
+
@queue.clear
|
|
61
|
+
@active_batch.to_a.each { |_, kind| drop(kind, :shutdown) }
|
|
62
|
+
@active_batch = nil
|
|
63
|
+
@in_flight = 0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
success
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def reset_process
|
|
72
|
+
@pid = Process.pid
|
|
73
|
+
@mutex = Mutex.new
|
|
74
|
+
@condition = ConditionVariable.new
|
|
75
|
+
@queue, @losses = [], Hash.new(0)
|
|
76
|
+
@stats = { enqueued: 0, delivered: 0, dropped: 0, retried: 0 }
|
|
77
|
+
@in_flight = 0
|
|
78
|
+
@worker = @active_batch = nil
|
|
79
|
+
@closed = @stopping = @force_flush = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Never resend a parent's inherited queue after Puma/worker forks.
|
|
83
|
+
def check_process
|
|
84
|
+
reset_process if @pid != Process.pid
|
|
85
|
+
end
|
|
86
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
|
|
88
|
+
def start_worker
|
|
89
|
+
return if @worker&.alive?
|
|
90
|
+
@worker = Thread.new { run }
|
|
91
|
+
@worker.name = "rails_mind"
|
|
92
|
+
@worker.report_on_exception = false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def run
|
|
96
|
+
loop do
|
|
97
|
+
batch = @mutex.synchronize do
|
|
98
|
+
@condition.wait(@mutex, @config.flush_interval) if @queue.size < @config.batch_size && !@force_flush && !@stopping
|
|
99
|
+
return if @stopping && @queue.empty?
|
|
100
|
+
take_batch
|
|
101
|
+
end
|
|
102
|
+
next if batch.empty?
|
|
103
|
+
result = deliver(batch)
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
if result
|
|
106
|
+
@stats[:delivered] += batch.size
|
|
107
|
+
else
|
|
108
|
+
batch.each { |_, kind| drop(kind, :delivery) }
|
|
109
|
+
end
|
|
110
|
+
@in_flight = 0
|
|
111
|
+
@active_batch = nil
|
|
112
|
+
@force_flush = false if @queue.empty?
|
|
113
|
+
@condition.broadcast
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def take_batch
|
|
119
|
+
batch = []
|
|
120
|
+
bytes = 13
|
|
121
|
+
while batch.size < @config.batch_size && @queue.any?
|
|
122
|
+
entry = @queue.first
|
|
123
|
+
break if bytes + entry.first.bytesize + (batch.empty? ? 0 : 1) > @config.max_batch_bytes
|
|
124
|
+
bytes += entry.first.bytesize + (batch.empty? ? 0 : 1)
|
|
125
|
+
batch << @queue.shift
|
|
126
|
+
end
|
|
127
|
+
@in_flight = batch.size
|
|
128
|
+
@active_batch = batch
|
|
129
|
+
batch
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def deliver(batch)
|
|
133
|
+
body = '{"events":[' + batch.map(&:first).join(",") + ']}'
|
|
134
|
+
attempts = 0
|
|
135
|
+
loop do
|
|
136
|
+
result = @transport.send_batch(body)
|
|
137
|
+
return true if result.success
|
|
138
|
+
return false unless result.retryable && attempts < @config.max_retries
|
|
139
|
+
delay = result.retry_after || @config.retry_base * (2**attempts) * (0.75 + rand * 0.5)
|
|
140
|
+
attempts += 1
|
|
141
|
+
@mutex.synchronize { @stats[:retried] += 1 }
|
|
142
|
+
sleep(delay) if delay.positive?
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError
|
|
145
|
+
false # Telemetry errors cannot escape into the customer app.
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def drop(kind, reason)
|
|
149
|
+
@stats[:dropped] += 1
|
|
150
|
+
@losses["#{kind}.#{reason}"] += 1
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module RailsMind
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_ENDPOINT = "https://railsmind.com".freeze
|
|
6
|
+
RELEASE_ENV_VARS = %w[RAILS_MIND_RELEASE GIT_REVISION RENDER_GIT_COMMIT
|
|
7
|
+
RAILWAY_GIT_COMMIT_SHA HEROKU_BUILD_COMMIT HEROKU_SLUG_COMMIT KAMAL_VERSION].freeze
|
|
8
|
+
|
|
9
|
+
attr_accessor :endpoint, :token, :release, :apm, :errors, :analytics, :flags,
|
|
10
|
+
:queue_capacity, :batch_size, :max_batch_bytes, :max_event_bytes, :flush_interval,
|
|
11
|
+
:max_retries, :retry_base, :open_timeout, :read_timeout, :shutdown_timeout,
|
|
12
|
+
:before_send, :capture_exception_message
|
|
13
|
+
|
|
14
|
+
def initialize(env: ENV, root: nil)
|
|
15
|
+
@endpoint = nonblank(env["RAILS_MIND_ENDPOINT"]) || DEFAULT_ENDPOINT
|
|
16
|
+
@token = env["RAILS_MIND_KEY"]
|
|
17
|
+
@release = release_from_env(env) || release_from_file(root)
|
|
18
|
+
@apm = @errors = @analytics = @flags = true
|
|
19
|
+
@queue_capacity = 1_000
|
|
20
|
+
@batch_size = 100
|
|
21
|
+
@max_batch_bytes = 256 * 1024
|
|
22
|
+
@max_event_bytes = 16 * 1024
|
|
23
|
+
@flush_interval = 2.0
|
|
24
|
+
@max_retries = 3
|
|
25
|
+
@retry_base = 0.25
|
|
26
|
+
@open_timeout = 1.0
|
|
27
|
+
@read_timeout = 2.0
|
|
28
|
+
@shutdown_timeout = 2.0
|
|
29
|
+
@capture_exception_message = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled? = !token.to_s.strip.empty? && !endpoint.to_s.strip.empty?
|
|
33
|
+
|
|
34
|
+
def validate!
|
|
35
|
+
return self unless enabled?
|
|
36
|
+
uri = URI(endpoint)
|
|
37
|
+
loopback = %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
38
|
+
raise ArgumentError, "endpoint must use HTTPS (HTTP only on loopback)" unless uri.is_a?(URI::HTTPS) || (uri.is_a?(URI::HTTP) && loopback)
|
|
39
|
+
raise ArgumentError, "endpoint must not contain credentials, query, or fragment" if uri.userinfo || uri.query || uri.fragment
|
|
40
|
+
raise ArgumentError, "batch_size must be 1..100" unless (1..100).cover?(batch_size)
|
|
41
|
+
raise ArgumentError, "max_batch_bytes must be 1024..262144" unless (1024..262_144).cover?(max_batch_bytes)
|
|
42
|
+
raise ArgumentError, "queue_capacity must be positive" unless queue_capacity.positive?
|
|
43
|
+
raise ArgumentError, "max_retries must be 0..5" unless (0..5).cover?(max_retries)
|
|
44
|
+
[ flush_interval, open_timeout, read_timeout, shutdown_timeout ].each do |value|
|
|
45
|
+
raise ArgumentError, "timeouts must be positive" unless value.positive?
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def nonblank(value)
|
|
53
|
+
value = value.to_s.strip
|
|
54
|
+
value unless value.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def release_from_env(env)
|
|
58
|
+
RELEASE_ENV_VARS.each do |name|
|
|
59
|
+
value = nonblank(env[name])
|
|
60
|
+
next unless value
|
|
61
|
+
# Kamal permits arbitrary version labels; Assist needs a Git revision.
|
|
62
|
+
next if name == "KAMAL_VERSION" && !value.match?(/\A(?:[a-f0-9]{40}|[a-f0-9]{64})\z/i)
|
|
63
|
+
return value
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def release_from_file(root)
|
|
69
|
+
root ||= Rails.root if defined?(Rails) && Rails.respond_to?(:root)
|
|
70
|
+
return unless root
|
|
71
|
+
path = File.join(root, "REVISION")
|
|
72
|
+
return unless File.file?(path)
|
|
73
|
+
# Hatchbox/Capistrano deployments may have no Git checkout at runtime.
|
|
74
|
+
value = File.read(path, 501).force_encoding(Encoding::UTF_8)
|
|
75
|
+
return if value.bytesize > 500 || !value.valid_encoding?
|
|
76
|
+
nonblank(value)
|
|
77
|
+
rescue SystemCallError, IOError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module RailsMind
|
|
2
|
+
module Context
|
|
3
|
+
KEYS = %i[request_id job_id trace_id span_id parent_span_id visitor_id user_id account_id].freeze
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# Thread#[] is fiber-local. Every request/job boundary restores its previous context.
|
|
7
|
+
def current = Thread.current[:rails_mind_context] ||= {}
|
|
8
|
+
def snapshot = current.select { |key, _| KEYS.include?(key) }.dup
|
|
9
|
+
|
|
10
|
+
def with(values)
|
|
11
|
+
previous = current
|
|
12
|
+
identifiers = values.transform_keys(&:to_sym).slice(*KEYS).transform_values do |value|
|
|
13
|
+
value.to_s if value.is_a?(String) || value.is_a?(Integer) || value.is_a?(Symbol)
|
|
14
|
+
end
|
|
15
|
+
Thread.current[:rails_mind_context] = previous.merge(identifiers)
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
Thread.current[:rails_mind_context] = previous
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def identify(user_id: nil, account_id: nil, visitor_id: nil)
|
|
22
|
+
current.merge!(user_id: user_id&.to_s, account_id: account_id&.to_s, visitor_id: visitor_id&.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def suppressed? = Thread.current[:rails_mind_suppressed] == true
|
|
26
|
+
|
|
27
|
+
def suppress
|
|
28
|
+
previous = Thread.current[:rails_mind_suppressed]
|
|
29
|
+
Thread.current[:rails_mind_suppressed] = true
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
Thread.current[:rails_mind_suppressed] = previous
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "bundler"
|
|
3
|
+
|
|
4
|
+
module RailsMind
|
|
5
|
+
class Diagnostic
|
|
6
|
+
GEMS = %w[rails ahoy_matey flipper opentelemetry-sdk opentelemetry-instrumentation-rails
|
|
7
|
+
opentelemetry-instrumentation-pg opentelemetry-instrumentation-net_http good_job sidekiq
|
|
8
|
+
solid_queue delayed_job sentry-rails honeybadger bugsnag rollbar bullet prosopite field_test].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(root: Dir.pwd, config: RailsMind.configuration)
|
|
11
|
+
@root, @config = root, config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report
|
|
15
|
+
lockfile = File.join(@root, "Gemfile.lock")
|
|
16
|
+
versions = File.exist?(lockfile) ? Bundler::LockfileParser.new(File.read(lockfile)).specs.to_h { |spec| [ spec.name, spec.version.to_s ] } : {}
|
|
17
|
+
detected = GEMS.to_h { |name| [ name, versions[name] ] }
|
|
18
|
+
{
|
|
19
|
+
sdk: VERSION, ruby: RUBY_VERSION, detected: detected.compact,
|
|
20
|
+
credential_present: !@config.token.to_s.strip.empty?,
|
|
21
|
+
endpoint_present: !@config.endpoint.to_s.strip.empty?,
|
|
22
|
+
release_present: !@config.release.to_s.strip.empty?,
|
|
23
|
+
enabled: @config.enabled?,
|
|
24
|
+
plan: [
|
|
25
|
+
"Add one initializer. No database migrations or Gemfile changes.",
|
|
26
|
+
"Requests and jobs: unsampled measurements via Rails notifications; errors via Rails.error.",
|
|
27
|
+
detected["ahoy_matey"] ? "Ahoy detected: keep current store; explicitly opt into mirror mode after review." : "Analytics: optionally add ahoy_matey; choose hosted-only store or existing database store.",
|
|
28
|
+
detected["opentelemetry-sdk"] ? "OpenTelemetry detected: attach RailsMind exporter after existing SDK setup; preserve sampler and exporters." : "APM traces: optionally add selected OpenTelemetry libraries and explicitly configure one provider.",
|
|
29
|
+
detected["flipper"] ? "Flipper detected: observe notifications only; preserve adapter, groups, targeting and cache." : "Flags: Flipper is optional. No flag configuration is created.",
|
|
30
|
+
"Existing error reporters stay installed. RailsMind deduplicates its own collector only.",
|
|
31
|
+
"Browser: optional Ahoy adapter; enable only after checking existing pageview handlers.",
|
|
32
|
+
"Business/request/error signals are not sampled; bounded queue losses remain possible. Inspect RailsMind.stats."
|
|
33
|
+
],
|
|
34
|
+
limitations: [ "Compatibility applies to tested versions only; this is not a historical import.",
|
|
35
|
+
"No network check is performed and credential values are never printed." ]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "../rails_mind"
|
|
2
|
+
|
|
3
|
+
module RailsMind
|
|
4
|
+
class InstallationCheck
|
|
5
|
+
def initialize(config = RailsMind.configuration, transport: nil)
|
|
6
|
+
@config, @transport = config, transport
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run(timeout: 10)
|
|
10
|
+
return result(false, "Set RAILS_MIND_KEY in your app's environment, then try again. The endpoint defaults to https://railsmind.com; if you override it, provide a nonblank endpoint.") unless @config.enabled?
|
|
11
|
+
@config.validate!
|
|
12
|
+
# A dedicated collector gives this command an exact result without flushing
|
|
13
|
+
# or changing the running application's collector or its existing counters.
|
|
14
|
+
collector = Collector.new(@config, transport: @transport || Transport.new(@config), autostart: false)
|
|
15
|
+
client = Client.new(@config, collector: collector)
|
|
16
|
+
id = SecureRandom.uuid
|
|
17
|
+
unless client.emit(:check, "RailsMind installation check", id: id, properties: { sdk_version: VERSION })
|
|
18
|
+
return result(false, "The SDK did not enqueue the check. Review before_send and collector settings.", id)
|
|
19
|
+
end
|
|
20
|
+
unless collector.flush(timeout: timeout)
|
|
21
|
+
return result(false, "Delivery was not confirmed before the deadline. Check outbound HTTPS access and the endpoint, then look for this ID in Setup before retrying.", id)
|
|
22
|
+
end
|
|
23
|
+
if collector.stats[:delivered] == 1 && collector.stats[:dropped].zero?
|
|
24
|
+
result(true, "Check delivered. Open RailsMind Setup for your app and environment and match this ID. Installation is verified when it says Check processed.", id)
|
|
25
|
+
else
|
|
26
|
+
result(false, "Delivery failed. Check the endpoint, active ingest key, daily quota, and server support for installation checks. Run rails_mind:doctor for local settings.", id)
|
|
27
|
+
end
|
|
28
|
+
ensure
|
|
29
|
+
collector&.shutdown(timeout: 0.05)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def result(success, message, id = nil)
|
|
35
|
+
{ success: success, check_id: id, message: message }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "ahoy"
|
|
2
|
+
|
|
3
|
+
module RailsMind
|
|
4
|
+
module Integrations
|
|
5
|
+
module AhoyDelivery
|
|
6
|
+
def rails_mind_visit(data)
|
|
7
|
+
RailsMind.client.emit(:visit, "$visit", id: data[:visit_token], occurred_at: data[:started_at],
|
|
8
|
+
visitor_id: data[:visitor_token], user_id: data[:user_id]&.to_s,
|
|
9
|
+
properties: data.slice(:referring_domain, :utm_source, :utm_medium, :utm_campaign, :browser, :os, :device_type))
|
|
10
|
+
rescue StandardError
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rails_mind_event(data)
|
|
15
|
+
RailsMind.client.emit(:event, data[:name], id: data[:event_id], occurred_at: data[:time],
|
|
16
|
+
visitor_id: ahoy&.visitor_token, user_id: data[:user_id]&.to_s,
|
|
17
|
+
properties: data.fetch(:properties, {}).merge(visit_id: data[:visit_token]))
|
|
18
|
+
rescue StandardError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rails_mind_authenticate(data)
|
|
23
|
+
RailsMind.client.emit(:event, "$identify", visitor_id: ahoy&.visitor_token,
|
|
24
|
+
user_id: data[:user_id]&.to_s, properties: { visit_id: data[:visit_token] })
|
|
25
|
+
rescue StandardError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Explicit hosted-only option. Set `class Ahoy::Store < ...` in the app.
|
|
31
|
+
# This intentionally has no visit ActiveRecord model or geocoding persistence.
|
|
32
|
+
class AhoyStore < ::Ahoy::BaseStore
|
|
33
|
+
include AhoyDelivery
|
|
34
|
+
|
|
35
|
+
def track_visit(data) = rails_mind_visit(data)
|
|
36
|
+
def track_event(data) = rails_mind_event(data)
|
|
37
|
+
def authenticate(data) = rails_mind_authenticate(data)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Explicit coexistence option. Prepend once to the customer's existing Store.
|
|
41
|
+
# The existing store runs first; its arguments, exceptions and return value survive.
|
|
42
|
+
module AhoyMirror
|
|
43
|
+
include AhoyDelivery
|
|
44
|
+
|
|
45
|
+
def track_visit(data)
|
|
46
|
+
result = super
|
|
47
|
+
rails_mind_visit(data)
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def track_event(data)
|
|
52
|
+
result = super
|
|
53
|
+
rails_mind_event(data)
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def authenticate(data)
|
|
58
|
+
result = super
|
|
59
|
+
rails_mind_authenticate(data)
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.mirror_ahoy!(store = ::Ahoy::Store)
|
|
65
|
+
store.prepend(AhoyMirror) unless store.ancestors.include?(AhoyMirror)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "opentelemetry/sdk"
|
|
2
|
+
|
|
3
|
+
module RailsMind
|
|
4
|
+
module Integrations
|
|
5
|
+
module OpenTelemetry
|
|
6
|
+
SAFE_ATTRIBUTES = %w[http.request.method http.method http.response.status_code http.status_code http.route
|
|
7
|
+
db.system db.system.name db.operation db.operation.name server.address server.port
|
|
8
|
+
code.function code.namespace code.filepath code.lineno messaging.system messaging.destination.name].freeze
|
|
9
|
+
|
|
10
|
+
# A local exporter: export only enqueues sanitized data in the shared bounded
|
|
11
|
+
# collector. SimpleSpanProcessor therefore performs no application-thread I/O.
|
|
12
|
+
class Exporter
|
|
13
|
+
def export(spans, timeout: nil)
|
|
14
|
+
return success if Context.suppressed?
|
|
15
|
+
spans.each do |span|
|
|
16
|
+
attributes = span.attributes || {}
|
|
17
|
+
safe = attributes.slice(*SAFE_ATTRIBUTES)
|
|
18
|
+
context = Context::KEYS.each_with_object({}) do |key, values|
|
|
19
|
+
value = attributes["rails_mind.#{key}"]
|
|
20
|
+
values[key] = value if value
|
|
21
|
+
end
|
|
22
|
+
RailsMind.client.emit(:span, safe_name(span, attributes), occurred_at: Time.at(Rational(span.start_timestamp, 1_000_000_000)).utc,
|
|
23
|
+
duration_ms: (span.end_timestamp - span.start_timestamp) / 1_000_000.0,
|
|
24
|
+
status: span.status.code == ::OpenTelemetry::Trace::Status::ERROR ? "error" : "ok",
|
|
25
|
+
trace_id: span.trace_id.unpack1("H*"), span_id: span.span_id.unpack1("H*"),
|
|
26
|
+
parent_span_id: span.parent_span_id.unpack1("H*"), properties: safe, **context.except(:trace_id, :span_id, :parent_span_id))
|
|
27
|
+
end
|
|
28
|
+
success
|
|
29
|
+
rescue StandardError
|
|
30
|
+
::OpenTelemetry::SDK::Trace::Export::FAILURE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def force_flush(timeout: nil)
|
|
34
|
+
RailsMind.flush(timeout: timeout || RailsMind.configuration.shutdown_timeout) ? success : ::OpenTelemetry::SDK::Trace::Export::TIMEOUT
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The SDK owns the collector lifetime; never shut down another provider.
|
|
38
|
+
def shutdown(timeout: nil) = force_flush(timeout: timeout)
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def success = ::OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
43
|
+
|
|
44
|
+
def safe_name(span, attributes)
|
|
45
|
+
if attributes["db.system"] || attributes["db.system.name"]
|
|
46
|
+
"db.#{attributes['db.operation'] || attributes['db.operation.name'] || 'query'}"
|
|
47
|
+
elsif attributes["http.route"]
|
|
48
|
+
"#{attributes['http.request.method'] || attributes['http.method']} #{attributes['http.route']}"
|
|
49
|
+
elsif attributes["http.method"] || attributes["http.request.method"]
|
|
50
|
+
"http.#{attributes['http.request.method'] || attributes['http.method']}"
|
|
51
|
+
else
|
|
52
|
+
# Span names can contain URLs, SQL, or user strings. Scope is bounded
|
|
53
|
+
# library metadata; customers can add reviewed code attributes instead.
|
|
54
|
+
span.instrumentation_scope&.name || "span"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ContextProcessor < ::OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor
|
|
60
|
+
def on_start(span, parent_context)
|
|
61
|
+
Context.snapshot.each { |key, value| span.set_attribute("rails_mind.#{key}", value.to_s) if value }
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.attach!(provider: ::OpenTelemetry.tracer_provider)
|
|
68
|
+
raise ArgumentError, "Configure an OpenTelemetry SDK provider first; RailsMind never replaces an existing provider" unless provider.respond_to?(:add_span_processor)
|
|
69
|
+
return provider if provider.instance_variable_defined?(:@rails_mind_processor)
|
|
70
|
+
processor = ContextProcessor.new(Exporter.new)
|
|
71
|
+
provider.add_span_processor(processor)
|
|
72
|
+
provider.instance_variable_set(:@rails_mind_processor, processor)
|
|
73
|
+
provider
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|