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,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
|
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: []
|