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,120 @@
1
+ require "active_support/notifications"
2
+ require "digest"
3
+
4
+ module RailsMind
5
+ module Integrations
6
+ class ErrorSubscriber
7
+ def report(error, handled:, severity:, context:, source: nil, **options)
8
+ RailsMind.capture_exception(error, handled: handled, severity: severity, context: context, source: source)
9
+ end
10
+ end
11
+
12
+ class RequestContext
13
+ def initialize(app) = @app = app
14
+
15
+ def call(env)
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
28
+ end
29
+ end
30
+ end
31
+
32
+ module JobContext
33
+ def self.included(base)
34
+ base.around_enqueue do |job, block|
35
+ job.rails_mind_context = Context.snapshot.transform_keys(&:to_s)
36
+ block.call
37
+ end
38
+ 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 }
41
+ end
42
+ end
43
+
44
+ attr_accessor :rails_mind_context, :rails_mind_started_at
45
+
46
+ def serialize = super.merge("rails_mind_context" => rails_mind_context || {})
47
+
48
+ def deserialize(job_data)
49
+ super
50
+ self.rails_mind_context = (job_data["rails_mind_context"] || {}).slice(*Context::KEYS.map(&:to_s))
51
+ end
52
+ end
53
+
54
+ module RailsNotifications
55
+ class << self
56
+ def install!
57
+ return if @subscriptions
58
+ @subscriptions = []
59
+ subscribe("process_action.action_controller") do |event|
60
+ payload = event.payload
61
+ queries = Thread.current[:rails_mind_queries]
62
+ counts = queries&.fetch(:fingerprints, {}) || {}
63
+ status = payload[:status] || (payload[:exception] ? 500 : 200)
64
+ RailsMind.client.emit(:request, "#{payload[:controller]}##{payload[:action]}", duration_ms: event.duration,
65
+ status: status.to_s, properties: { controller: payload[:controller], action: payload[:action],
66
+ method: payload[:method], view_runtime: payload[:view_runtime], db_runtime: payload[:db_runtime],
67
+ query_count: queries&.fetch(:count), repeated_query_count: counts.values.sum { |n| [ n - 1, 0 ].max },
68
+ query_fingerprints: counts.sort_by { |_, count| -count }.first(10).to_h })
69
+ end
70
+ subscribe("sql.active_record") do |event|
71
+ queries = Thread.current[:rails_mind_queries]
72
+ next unless queries && !event.payload[:cached] && !%w[SCHEMA TRANSACTION].include?(event.payload[:name])
73
+ queries[:count] += 1
74
+ # Normalize locally, transmit only one-way hashes; never SQL or binds.
75
+ normalized = event.payload[:sql].to_s[0, 16_384].gsub(/'(?:[^']|'')*'/, "?").gsub(/\b\d+\b/, "?").gsub(/\s+/, " ")
76
+ fingerprint = Digest::SHA256.hexdigest(normalized)[0, 24]
77
+ queries[:fingerprints][fingerprint] += 1 if queries[:fingerprints].key?(fingerprint) || queries[:fingerprints].size < 100
78
+ end
79
+ %w[perform.active_job enqueue_retry.active_job retry_stopped.active_job discard.active_job].each do |name|
80
+ subscribe(name) do |event|
81
+ job = event.payload[:job]
82
+ 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
90
+ end
91
+ end
92
+ end
93
+ subscribe("feature_operation.flipper") do |event|
94
+ payload = event.payload
95
+ operation = payload[:operation].to_s
96
+ result = payload[:result]
97
+ RailsMind.client.emit(:flag, payload[:feature_name].to_s, duration_ms: event.duration,
98
+ properties: { operation: operation, enabled: result == true ? true : (result == false ? false : nil),
99
+ exposure: false })
100
+ end
101
+ end
102
+
103
+ def uninstall!
104
+ @subscriptions&.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
105
+ @subscriptions = nil
106
+ end
107
+
108
+ private
109
+
110
+ def subscribe(name, &callback)
111
+ @subscriptions << ActiveSupport::Notifications.subscribe(name) do |event|
112
+ callback.call(event) unless Context.suppressed?
113
+ rescue StandardError
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "integrations/rails"
2
+
3
+ module RailsMind
4
+ class Railtie < Rails::Railtie
5
+ initializer "rails_mind.request_context" do |app|
6
+ app.middleware.insert_after ActionDispatch::RequestId, Integrations::RequestContext
7
+ end
8
+
9
+ config.after_initialize do
10
+ Integrations::RailsNotifications.install!
11
+ Rails.error.subscribe(Integrations::ErrorSubscriber.new)
12
+ ActiveSupport.on_load(:active_job) do
13
+ include Integrations::JobContext unless included_modules.include?(Integrations::JobContext)
14
+ end
15
+ at_exit { RailsMind.shutdown }
16
+ end
17
+
18
+ rake_tasks { load File.expand_path("../tasks/rails_mind.rake", __dir__) }
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ module RailsMind
2
+ class Redactor
3
+ FILTER = /password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|cookie|credential|session|email|phone|address|credit|card|body|params|arguments|bind|sql|db\.statement|query\.text/i
4
+ MAX_STRING = 1024
5
+
6
+ def call(value, depth = 0)
7
+ return "[TRUNCATED]" if depth > 5
8
+ case value
9
+ when Hash
10
+ value.first(50).to_h.transform_keys { |key| key.to_s[0, 100] }.each_with_object({}) do |(key, item), clean|
11
+ clean[key] = key.match?(FILTER) ? "[FILTERED]" : call(item, depth + 1)
12
+ end
13
+ when Array then value.first(50).map { |item| call(item, depth + 1) }
14
+ when String then scrub(value)
15
+ when Integer, TrueClass, FalseClass, NilClass then value
16
+ when Float then value.finite? ? value : nil
17
+ when Symbol then scrub(value.to_s)
18
+ else nil # Never call #inspect on application objects.
19
+ end
20
+ end
21
+
22
+ def scrub(value)
23
+ value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
24
+ .gsub(/\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i, "[EMAIL]")
25
+ .gsub(/\bBearer\s+\S+/i, "Bearer [FILTERED]")
26
+ .gsub(/((?:password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)\s*[=:]\s*)[^\s,;&]+/i, '\\1[FILTERED]')
27
+ .gsub(%r{(https?://)[^\s/@]+:[^\s/@]+@}, '\\1[FILTERED]@')
28
+ .gsub(%r{(https?://[^\s?#]+)[?#][^\s]*}, '\\1')
29
+ .slice(0, MAX_STRING)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module RailsMind
5
+ class Transport
6
+ Result = Struct.new(:success, :retryable, :retry_after, keyword_init: true)
7
+ TRANSIENT_ERRORS = [ Timeout::Error, IOError, EOFError, SocketError, Errno::ECONNRESET,
8
+ Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE, OpenSSL::SSL::SSLError ].freeze
9
+
10
+ def initialize(config, http_factory: ->(uri) { Net::HTTP.new(uri.host, uri.port, nil) })
11
+ @config = config
12
+ @http_factory = http_factory
13
+ @uri = URI(config.endpoint)
14
+ @uri.path = "#{@uri.path.sub(%r{/$}, '')}/api/v1/events" unless @uri.path.end_with?("/api/v1/events")
15
+ end
16
+
17
+ def send_batch(json)
18
+ Context.suppress do
19
+ request = Net::HTTP::Post.new(@uri)
20
+ request["Authorization"] = "Bearer #{@config.token}"
21
+ request["Content-Type"] = "application/json"
22
+ request["User-Agent"] = "rails_mind/#{VERSION}"
23
+ request.body = json
24
+ http = @http_factory.call(@uri)
25
+ http.use_ssl = @uri.scheme == "https"
26
+ http.open_timeout = @config.open_timeout
27
+ http.read_timeout = @config.read_timeout
28
+ http.write_timeout = @config.read_timeout
29
+ http.max_retries = 0
30
+ response = http.start { |connection| connection.request(request) }
31
+ code = response.code.to_i
32
+ Result.new(success: (200..299).cover?(code), retryable: code == 429 || code >= 500,
33
+ retry_after: retry_after(response["Retry-After"]))
34
+ end
35
+ rescue *TRANSIENT_ERRORS
36
+ Result.new(success: false, retryable: true)
37
+ end
38
+
39
+ private
40
+
41
+ def retry_after(value)
42
+ return nil unless value
43
+ seconds = value.match?(/\A\d+\z/) ? value.to_i : Time.httpdate(value) - Time.now
44
+ [ [ seconds, 0 ].max, 30 ].min
45
+ rescue ArgumentError
46
+ nil
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module RailsMind
2
+ VERSION = "0.1.0"
3
+ end
data/lib/rails_mind.rb ADDED
@@ -0,0 +1,34 @@
1
+ require_relative "rails_mind/version"
2
+ require_relative "rails_mind/configuration"
3
+ require_relative "rails_mind/context"
4
+ require_relative "rails_mind/redactor"
5
+ require_relative "rails_mind/transport"
6
+ require_relative "rails_mind/collector"
7
+ require_relative "rails_mind/client"
8
+
9
+ module RailsMind
10
+ class << self
11
+ attr_writer :client
12
+
13
+ def configuration = @configuration ||= Configuration.new
14
+ def client = @client ||= Client.new(configuration)
15
+
16
+ def configure
17
+ yield configuration
18
+ @client&.collector&.shutdown
19
+ @client = Client.new(configuration)
20
+ end
21
+
22
+ def track(name, properties = {}, id: nil, occurred_at: nil, **additional_properties)
23
+ client.emit(:event, name, properties: properties.merge(additional_properties), id: id, occurred_at: occurred_at)
24
+ end
25
+ def capture_exception(error, **options) = client.capture_exception(error, **options)
26
+ def with_context(**context, &block) = Context.with(context, &block)
27
+ def identify(**identity) = Context.identify(**identity)
28
+ def flush(timeout: configuration.shutdown_timeout) = client.collector&.flush(timeout: timeout)
29
+ def shutdown = @client&.collector&.shutdown
30
+ def stats = client.collector&.stats || { enabled: false }
31
+ end
32
+ end
33
+
34
+ require_relative "rails_mind/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,15 @@
1
+ namespace :rails_mind do
2
+ desc "Send one installation check; confirm its processing in RailsMind Setup"
3
+ task verify: :environment do
4
+ require "rails_mind/installation_check"
5
+ result = RailsMind::InstallationCheck.new.run
6
+ puts JSON.pretty_generate(result)
7
+ exit 1 unless result[:success]
8
+ end
9
+
10
+ desc "Inspect dependencies and report the non-destructive integration plan"
11
+ task doctor: :environment do
12
+ require "rails_mind/diagnostic"
13
+ puts JSON.pretty_generate(RailsMind::Diagnostic.new(root: Rails.root).report.merge(collector: RailsMind.stats))
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_mind
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RailsMind
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: net-http
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.4'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '1'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.4'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '1'
52
+ description: The RailsMind client for Rails request, error, job, and business-event
53
+ telemetry, with optional Ahoy, Flipper, OpenTelemetry, and browser integrations.
54
+ executables: []
55
+ extensions: []
56
+ extra_rdoc_files: []
57
+ files:
58
+ - CHANGELOG.md
59
+ - CONTRIBUTING.md
60
+ - LICENSE
61
+ - README.md
62
+ - docs/compatibility.md
63
+ - docs/sdk.md
64
+ - javascript/rails_mind.js
65
+ - lib/generators/rails_mind/install/install_generator.rb
66
+ - lib/generators/rails_mind/install/templates/initializer.rb.tt
67
+ - lib/rails_mind.rb
68
+ - lib/rails_mind/client.rb
69
+ - lib/rails_mind/collector.rb
70
+ - lib/rails_mind/configuration.rb
71
+ - lib/rails_mind/context.rb
72
+ - lib/rails_mind/diagnostic.rb
73
+ - lib/rails_mind/installation_check.rb
74
+ - lib/rails_mind/integrations/ahoy.rb
75
+ - lib/rails_mind/integrations/open_telemetry.rb
76
+ - lib/rails_mind/integrations/rails.rb
77
+ - lib/rails_mind/railtie.rb
78
+ - lib/rails_mind/redactor.rb
79
+ - lib/rails_mind/transport.rb
80
+ - lib/rails_mind/version.rb
81
+ - lib/tasks/rails_mind.rake
82
+ homepage: https://github.com/cmartyn/rails_mind
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ source_code_uri: https://github.com/cmartyn/rails_mind
87
+ documentation_uri: https://github.com/cmartyn/rails_mind/blob/main/docs/sdk.md
88
+ bug_tracker_uri: https://github.com/cmartyn/rails_mind/issues
89
+ changelog_uri: https://github.com/cmartyn/rails_mind/blob/main/CHANGELOG.md
90
+ allowed_push_host: https://rubygems.org
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.3'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 4.0.16
106
+ specification_version: 4
107
+ summary: Bounded Rails telemetry for RailsMind
108
+ test_files: []