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.
@@ -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