rails_mind 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 819a570e3f4f65e0fb8bae7de68b1a81ee744bccb77349858c640fcc912e95b4
4
- data.tar.gz: 62df4fca70603e28458d277dcd8d734a5de04af7c514f997e15686122a02dbd2
3
+ metadata.gz: 902c75e77dcb5d76b75de68e62e73fe7a92f38ec05f621084bc6a61c047cb186
4
+ data.tar.gz: b499762228b099f7d940a3b4aa58ba1d1c73d4a9c53958a02e379982fa08b28b
5
5
  SHA512:
6
- metadata.gz: 3583084684b6ac4b165864c14fbfe3922fc2f678c8d7aa9383418d95fdc528083bbe2327e5428d167d80e54a28445309eb6243bd3c6c3dac36fc8a67fa9e0630
7
- data.tar.gz: e968233e4025ce470a83207214ef886032bff662faea00f824f3aceefad55319aa6d3235b62cc16368c5b8ccec7db86a6914ce1d5cb1a5db2c9bb282a543f0f2
6
+ metadata.gz: ae733243113a0206980e2b34f2545f5648c00b4e032d32ae2727bd8b5da85d67067970cc73ff1654eef1eb93782457d71cb65c45e9a6bfcde5f6f7fc03985310
7
+ data.tar.gz: 48d196c72e82519469bc6afba1e47a585012a9ff3f0812c243ddd9b00378f6512f5b7d4f8d62fbd5158a029e2166d35e8d3373588fc3458c87c324848d477ed2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — unreleased
4
+
5
+ - Send exception messages by default, scrubbed like every other string (emails, credentials, URL queries), and further for what generic scrubbing misses: database errors lose the failing row, key values, rejected input and quoted SQL literals, and printed hashes or keyword arguments lose the values of sensitive keys. `config.capture_exception_message = false` restores the placeholder. The flag is still not validated, so an app that assigned it from ENV boots as before.
6
+ - Send the shape of each repeated query next to its fingerprint: the normalized SQL with literals replaced by `?`, comments removed, and at most 400 characters and 900 bytes, only for fingerprints that repeated within the request. One pass handles doubled and backslash-escaped quotes, `E''` and `$$` strings, hex numbers, comments containing quotes, a literal left open where a long statement is cut, and MySQL's double-quoted strings. `config.capture_query_shapes = false` sends fingerprints only.
7
+ - Strip SQL comments (query log tags, Marginalia) before fingerprinting, so a query tagged per request keeps one fingerprint.
8
+ - Name where each exception happened: `role` (`web`, `job`, `runner` or `console`), with the controller action as `route` for requests and `job_class` and `queue` for jobs, including unhandled job failures.
9
+ - Keep the origin of library frames in backtraces (`gems/activerecord-8.1.4/lib/…`, `ruby/4.0.0/…`, `ruby/vendor_ruby/…`) so they can no longer pass for the application's own `lib/` files; an application directory that happens to be called `gems/` stays the application's.
10
+ - Stop fingerprinting session settings and transaction control (`SET`, `SHOW`, `RESET`, `DISCARD`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `PRAGMA` and the like), so a per-query `SET LOCAL statement_timeout` no longer reads as an N+1. They still count toward `query_count`.
11
+
3
12
  ## 0.3.0 — 2026-10-10
4
13
 
5
14
  - Enable bounded automatic endpoint sampling for routine successful requests, driven by expiring hosted policies. Preserve slow, failed, repeated-query requests and all other signal kinds from SDK sampling.
data/docs/sdk.md CHANGED
@@ -180,7 +180,7 @@ RailsMind::Integrations::OpenTelemetry.attach!
180
180
 
181
181
  Attachment adds one idempotent processor; it does not replace the global provider, sampler, propagator, instrumentation, or other exporters. Its exporter only sanitizes and enqueues locally; the shared worker performs network I/O. Span context is attached at start so identity survives later export. Known safe attributes are allowlisted. Raw span names, exception span events, HTTP bodies/headers, SQL statements, and bind values are not exported to RailsMind. Existing third-party exporters retain their own behavior and privacy settings. [OpenTelemetry exporters](https://opentelemetry.io/docs/languages/ruby/exporters/).
182
182
 
183
- Request measurements and error reports come independently from Rails notifications/error reporting, even when detailed traces are sampled out. Routine requests separately follow the automatic endpoint sampling policy described below. Request measurements cover controller actions, not assets/health checks rejected before ActionController or streaming-body completion. Repeated-query evidence contains only normalized-query hashes and counts; it is a suspicion, not proof of an N+1. Hash normalization handles common literals, not every SQL grammar. Use Bullet/Prosopite or an explicit query-bound test in development to verify fixes. No production object scans are installed.
183
+ Request measurements and error reports come independently from Rails notifications/error reporting, even when detailed traces are sampled out. Routine requests separately follow the automatic endpoint sampling policy described below. Request measurements cover controller actions, not assets/health checks rejected before ActionController or streaming-body completion. Repeated-query evidence contains normalized-query hashes and counts, plus the shape of each query that repeated: the normalized SQL with string and numeric literals replaced by `?`, comments removed, and at most 400 characters. Set `config.capture_query_shapes = false` to send hashes only. Session settings and transaction control (`SET`, `SHOW`, `BEGIN`, `COMMIT`, `SAVEPOINT` and the like) count toward `query_count` but are never fingerprinted. It is a suspicion, not proof of an N+1. Normalization handles common literals, not every SQL grammar. Use Bullet/Prosopite or an explicit query-bound test in development to verify fixes. No production object scans are installed.
184
184
 
185
185
  Full metrics/logs SDK integration is deferred. Hosted request aggregates describe retained events; sampling can bias their distributions. The SDK does not capture application logs.
186
186
 
@@ -328,7 +328,7 @@ RailsMind.flush(timeout: 2)
328
328
 
329
329
  These are per-process counters. `dropped` counts known local discards, such as queue overflow or oversize payloads. `delivery_unconfirmed` counts events whose delivery attempts ended without a success response, including interrupted in-flight batches; some may have been accepted by the server. `delivered` counts submitted events in batches with a successful HTTP response; it does not prove server retention or background processing. The doctor prints stats for its process; inspect running app stats through your existing operational tooling. Only the routine-request report above sends queue-loss observations to the hosted app. All signal kinds can still undercount after crashes, rejected payloads, hooks, or outages. This is best-effort monitoring, not an accounting ledger; use a transactional outbox when durable business-event delivery is required.
330
330
 
331
- Redaction occurs before buffering and again after custom `before_send`. Sensitive property names are filtered, nesting/string/collection sizes are capped, emails and common credential forms are scrubbed, exception messages are omitted by default, and backtraces drop machine-home prefixes. Bodies, cookies, credentials, SQL binds, and job arguments are never selected by integrations. Arbitrary user-provided text may contain sensitive content that generic redaction cannot recognize: use small, explicitly reviewed business properties. Opting into exception messages requires reviewing `capture_exception_message` and your custom scrubbing policy.
331
+ Redaction occurs before buffering and again after custom `before_send`. Sensitive property names are filtered, nesting/string/collection sizes are capped, emails and common credential forms are scrubbed (exception messages included), and backtraces drop machine-home prefixes while keeping a `gems/<name>-<version>/` or `ruby/<version>/` origin for library frames. Error events also name where they happened: `role`, plus the controller action as `route` or the job's `job_class` and `queue`. Bodies, cookies, credentials, SQL binds, and job arguments are never selected by integrations. Arbitrary user-provided text may contain sensitive content that generic redaction cannot recognize: use small, explicitly reviewed business properties. Exception messages can contain whatever your code interpolated into them; review your scrubbing policy, or set `config.capture_exception_message = false` to replace every message with a placeholder before it leaves.
332
332
 
333
333
  The final local synthetic burst benchmark on Ruby 4.0.6 processed 10,000 calls in 1.7612 seconds (176.12 μs/call), delivering all 10,000 with zero drops/retries at the 1,000-event queue bound. This measures local sanitization/enqueue with a no-op transport, not production request overhead or network delivery. Earlier runs did overflow; thread scheduling and downstream speed affect the result. Deterministic tests separately verify overflow losses and bounded shutdown during a hung transport. Run `ruby script_benchmark.rb` on the deployment hardware before setting a production budget.
334
334
 
@@ -14,8 +14,12 @@ RailsMind.configure do |config|
14
14
  # config.analytics = false
15
15
  # config.flags = false
16
16
 
17
- # Exception messages are omitted by default. Review before enabling.
18
- # config.capture_exception_message = true
17
+ # Exception messages are scrubbed (emails, credentials) and sent. Set false to replace
18
+ # them with a placeholder and keep them in your app.
19
+ # config.capture_exception_message = false
20
+ # Repeated queries are sent as a fingerprint plus their shape (the normalized SQL with
21
+ # literals replaced and comments removed). Set false to send the fingerprints only.
22
+ # config.capture_query_shapes = false
19
23
  # config.before_send = ->(event) { event } # Return nil to drop an event.
20
24
  end
21
25
 
@@ -40,7 +40,9 @@ module RailsMind
40
40
  false
41
41
  end
42
42
 
43
- def capture_exception(error, handled: false, severity: :error, context: {}, source: nil, **_options)
43
+ LOCATION_KEYS = %i[role route job_class queue].freeze
44
+
45
+ def capture_exception(error, handled: false, severity: :error, context: {}, source: nil, location: nil, **_options)
44
46
  return false unless config.errors
45
47
  # Rails reports an exception through more than one boundary. A weak key map
46
48
  # avoids retaining exceptions; identity dedup applies only to this object.
@@ -48,13 +50,14 @@ module RailsMind
48
50
  return false if @reported_errors.key?(error)
49
51
  @reported_errors[error] = true
50
52
  end
51
- message = config.capture_exception_message ? @redactor.scrub(error.message.to_s) : "[Message omitted]"
53
+ message = config.capture_exception_message ? @redactor.scrub_message(error) : "[Message omitted]"
52
54
  frames = Array(error.backtrace).first(40).map { |frame| safe_frame(frame) }
53
55
  Context.with(context.slice(*Context::KEYS)) do
56
+ where = (location || {}).slice(*LOCATION_KEYS).select { |_, value| value.is_a?(String) && !value.empty? }.transform_values { |value| value[0, 200] }
54
57
  emit(:error, error.class.name, status: "error", properties: {
55
58
  exception_class: error.class.name, message: message, backtrace: frames,
56
59
  handled: handled, severity: severity.to_s, source: source
57
- })
60
+ }.merge(where))
58
61
  end
59
62
  rescue StandardError
60
63
  false
@@ -104,12 +107,27 @@ module RailsMind
104
107
  time.iso8601(6)
105
108
  end
106
109
 
110
+ # An installed gem or Ruby's own library keeps its origin ("gems/rack-3.1.8/lib/…",
111
+ # "ruby/4.0.0/…") so it can never pass for the application's lib/. A gem
112
+ # directory is name-version, or name-sha for a Bundler git checkout.
113
+ GEM_FRAME = %r{\A.*/gems/([^/]+-(?:\d[^/]*|\h{7,40}))/}
114
+ RUBY_FRAME = %r{\A.*?/lib/ruby/((?:site_ruby/|vendor_ruby/)?\d[^/]*)/}
115
+ APP_RELATIVE = %r{\A(?:app|lib|config|test|spec)/}
116
+
107
117
  def safe_frame(frame)
108
118
  frame = frame.to_s
109
119
  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')
120
+ if frame.match?(APP_RELATIVE)
121
+ # Already the application's own path.
122
+ elsif frame.match?(GEM_FRAME)
123
+ frame = frame.sub(GEM_FRAME, 'gems/\\1/')
124
+ elsif frame.match?(RUBY_FRAME)
125
+ frame = frame.sub(RUBY_FRAME, 'ruby/\\1/')
126
+ else
127
+ # Remove machine/user home paths, retaining source filename + line + method.
128
+ frame = frame.sub(%r{\A.*?/(app|lib|config|test|spec)/}, '\\1/')
129
+ frame = frame.sub(%r{\A/(?:[^/:]+/)*([^/:]+:\d+)}, '\\1')
130
+ end
113
131
  @redactor.scrub(frame)
114
132
  end
115
133
 
@@ -9,7 +9,7 @@ module RailsMind
9
9
  attr_accessor :endpoint, :token, :release, :apm, :errors, :analytics, :flags,
10
10
  :queue_capacity, :batch_size, :max_batch_bytes, :max_event_bytes, :flush_interval,
11
11
  :max_retries, :retry_base, :open_timeout, :read_timeout, :shutdown_timeout,
12
- :before_send, :capture_exception_message, :automatic_sampling
12
+ :before_send, :capture_exception_message, :capture_query_shapes, :automatic_sampling
13
13
 
14
14
  def initialize(env: ENV, root: nil)
15
15
  @endpoint = nonblank(env["RAILS_MIND_ENDPOINT"]) || DEFAULT_ENDPOINT
@@ -26,7 +26,8 @@ module RailsMind
26
26
  @open_timeout = 1.0
27
27
  @read_timeout = 2.0
28
28
  @shutdown_timeout = 2.0
29
- @capture_exception_message = false
29
+ @capture_exception_message = true
30
+ @capture_query_shapes = true
30
31
  @automatic_sampling = true
31
32
  end
32
33
 
@@ -34,7 +35,12 @@ module RailsMind
34
35
 
35
36
  def validate!
36
37
  return self unless enabled?
37
- raise ArgumentError, "automatic_sampling must be true or false" unless automatic_sampling == true || automatic_sampling == false
38
+ # capture_exception_message stays unvalidated as in 0.3.0, so an app that
39
+ # assigned it from ENV still boots after upgrading.
40
+ %i[automatic_sampling capture_query_shapes].each do |flag|
41
+ value = public_send(flag)
42
+ raise ArgumentError, "#{flag} must be true or false" unless value == true || value == false
43
+ end
38
44
  uri = URI(endpoint)
39
45
  loopback = %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
40
46
  raise ArgumentError, "endpoint must use HTTPS (HTTP only on loopback)" unless uri.is_a?(URI::HTTPS) || (uri.is_a?(URI::HTTP) && loopback)
@@ -1,11 +1,38 @@
1
1
  require "active_support/notifications"
2
+ require "active_support/inflector"
2
3
  require "digest"
3
4
 
4
5
  module RailsMind
5
6
  module Integrations
7
+ # Where an exception happened, from the execution context Rails hands its
8
+ # error subscribers: the controller action, the job, or the kind of process.
9
+ # A runner script wins over a job it ran inline, whose context Rails leaves set.
10
+ def self.location(context, source)
11
+ controller, job = context[:controller], context[:job]
12
+ if source == "application.runner.railties"
13
+ { role: "runner" }
14
+ elsif controller.respond_to?(:action_name)
15
+ { role: "web", route: "#{controller.class.name}##{controller.action_name}" }
16
+ elsif job.respond_to?(:queue_name)
17
+ { role: "job", job_class: job.class.name, queue: job.queue_name.to_s }
18
+ elsif defined?(::Rails::Console)
19
+ { role: "console" }
20
+ else
21
+ {}
22
+ end
23
+ end
24
+
25
+ # The routed action of a request, named the way request events name it.
26
+ def self.request_location(env)
27
+ params = env["action_dispatch.request.path_parameters"]
28
+ return { role: "web" } unless params.is_a?(Hash) && params[:controller] && params[:action]
29
+ { role: "web", route: "#{ActiveSupport::Inflector.camelize(params[:controller].to_s)}Controller##{params[:action]}" }
30
+ end
31
+
6
32
  class ErrorSubscriber
7
33
  def report(error, handled:, severity:, context:, source: nil, **options)
8
- RailsMind.capture_exception(error, handled: handled, severity: severity, context: context, source: source)
34
+ RailsMind.capture_exception(error, handled: handled, severity: severity, context: context, source: source,
35
+ location: Integrations.location(context, source))
9
36
  end
10
37
  end
11
38
 
@@ -16,12 +43,12 @@ module RailsMind
16
43
  Context.with(request_id: env["action_dispatch.request_id"] || SecureRandom.uuid) do
17
44
  Context.with_trace do
18
45
  previous = Thread.current[:rails_mind_queries]
19
- Thread.current[:rails_mind_queries] = { count: 0, fingerprints: Hash.new(0) }
46
+ Thread.current[:rails_mind_queries] = { count: 0, fingerprints: Hash.new(0), shapes: {} }
20
47
  @app.call(env)
21
48
  rescue StandardError => error
22
49
  # Capture identity before this request boundary unwinds. Object
23
50
  # dedup still leaves other error reporters unaffected.
24
- RailsMind.capture_exception(error, source: "rack")
51
+ RailsMind.capture_exception(error, source: "rack", location: Integrations.request_location(env))
25
52
  raise
26
53
  ensure
27
54
  Thread.current[:rails_mind_queries] = previous
@@ -74,6 +101,30 @@ module RailsMind
74
101
  end
75
102
 
76
103
  module RailsNotifications
104
+ SHAPE_CHARS = 400
105
+ # The server keeps a shape of at most 1000 bytes; redaction can lengthen one slightly.
106
+ SHAPE_BYTES = 900
107
+ # Session settings and transaction control are bookkeeping, not reads: a
108
+ # per-query `SET LOCAL statement_timeout` must never look like an N+1.
109
+ BOOKKEEPING = /\A(?:SET|SHOW|RESET|DISCARD|BEGIN|START TRANSACTION|COMMIT|END|ABORT|ROLLBACK|SAVEPOINT|RELEASE|PRAGMA)\b/i
110
+ # One pass over the statement, so a quote inside a comment or a comment
111
+ # marker inside a string cannot derail it: strings (doubled or backslash-
112
+ # escaped quotes, E'' and $$ forms, and one left open where the statement
113
+ # was cut), numbers and hex literals become ?, and comments go. Double
114
+ # quotes are identifiers, except on MySQL, where they can be strings.
115
+ LEXEME = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^'\\]|\\.|'')*'?|\$(\w*)\$.*?(?:\$\1\$|\z)|"(?:[^"\\]|\\.|"")*"?|`[^`]*`?|--[^\n]*|\/\*.*?(?:\*\/|\z)|\b0[xX]\h+\b|\b\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\b/m
116
+
117
+ def self.normalize(sql, mysql: false)
118
+ sql.scrub("?").gsub(LEXEME) do |token|
119
+ case token[0]
120
+ when "-", "/" then " "
121
+ when "`" then token
122
+ when '"' then mysql ? "?" : token
123
+ else "?"
124
+ end
125
+ end.gsub(/\s+/, " ").strip
126
+ end
127
+
77
128
  class << self
78
129
  def install!
79
130
  return if @subscriptions
@@ -83,20 +134,33 @@ module RailsMind
83
134
  queries = Thread.current[:rails_mind_queries]
84
135
  counts = queries&.fetch(:fingerprints, {}) || {}
85
136
  status = payload[:status] || (payload[:exception] ? 500 : 200)
137
+ top = counts.sort_by { |_, count| -count }.first(10).to_h
138
+ properties = { controller: payload[:controller], action: payload[:action],
139
+ method: payload[:method], view_runtime: payload[:view_runtime], db_runtime: payload[:db_runtime],
140
+ query_count: queries&.fetch(:count), repeated_query_count: counts.values.sum { |n| [ n - 1, 0 ].max },
141
+ query_fingerprints: top }
142
+ # Shapes go only for the queries that repeated: that is what a finding is about.
143
+ if RailsMind.client.config.capture_query_shapes
144
+ shapes = queries&.fetch(:shapes, {}) || {}
145
+ properties[:query_shapes] = top.filter_map { |fingerprint, count| [ fingerprint, shapes[fingerprint] ] if count >= 2 && shapes[fingerprint] }.to_h
146
+ end
86
147
  RailsMind.client.emit(:request, "#{payload[:controller]}##{payload[:action]}", duration_ms: event.duration,
87
- status: status.to_s, properties: { controller: payload[:controller], action: payload[:action],
88
- method: payload[:method], view_runtime: payload[:view_runtime], db_runtime: payload[:db_runtime],
89
- query_count: queries&.fetch(:count), repeated_query_count: counts.values.sum { |n| [ n - 1, 0 ].max },
90
- query_fingerprints: counts.sort_by { |_, count| -count }.first(10).to_h })
148
+ status: status.to_s, properties: properties)
91
149
  end
92
150
  subscribe("sql.active_record") do |event|
93
151
  queries = Thread.current[:rails_mind_queries]
94
152
  next unless queries && !event.payload[:cached] && !%w[SCHEMA TRANSACTION].include?(event.payload[:name])
95
153
  queries[:count] += 1
96
- # Normalize locally, transmit only one-way hashes; never SQL or binds.
97
- normalized = event.payload[:sql].to_s[0, 16_384].gsub(/'(?:[^']|'')*'/, "?").gsub(/\b\d+\b/, "?").gsub(/\s+/, " ")
154
+ # Comments (query log tags, Marginalia) and literals go before hashing, so one
155
+ # query has one fingerprint. Binds are never read. The shape is this same
156
+ # normalized text, capped, and leaves only when capture_query_shapes allows.
157
+ mysql = event.payload[:connection].respond_to?(:adapter_name) && event.payload[:connection].adapter_name.to_s.match?(/mysql|trilogy/i)
158
+ normalized = RailsNotifications.normalize(event.payload[:sql].to_s[0, 16_384], mysql: mysql)
159
+ next if normalized.match?(BOOKKEEPING)
98
160
  fingerprint = Digest::SHA256.hexdigest(normalized)[0, 24]
99
- queries[:fingerprints][fingerprint] += 1 if queries[:fingerprints].key?(fingerprint) || queries[:fingerprints].size < 100
161
+ next unless queries[:fingerprints].key?(fingerprint) || queries[:fingerprints].size < 100
162
+ queries[:fingerprints][fingerprint] += 1
163
+ (queries[:shapes] ||= {})[fingerprint] ||= normalized[0, SHAPE_CHARS].byteslice(0, SHAPE_BYTES).scrub("") if RailsMind.client.config.capture_query_shapes
100
164
  end
101
165
  %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|
102
166
  subscribe(name) do |event|
@@ -128,7 +192,10 @@ module RailsMind
128
192
  kind = enqueue || operation == "perform_start" ? :span : :job
129
193
  RailsMind.client.emit(kind, job.class.name, duration_ms: event.duration, status: status,
130
194
  properties: job_properties(job, operation))
131
- RailsMind.capture_exception(error, source: "active_job") if error
195
+ if error
196
+ RailsMind.capture_exception(error, source: "active_job",
197
+ location: { role: "job", job_class: job.class.name, queue: job.queue_name.to_s })
198
+ end
132
199
  end
133
200
  end
134
201
  end
@@ -19,6 +19,22 @@ module RailsMind
19
19
  end
20
20
  end
21
21
 
22
+ # Exception messages carry what generic scrubbing cannot recognize: database
23
+ # errors quote the row, key or input that failed (and the SQL with its
24
+ # literals), and Ruby prints hashes and keyword arguments with their values.
25
+ DATABASE_ERROR = /\A(?:PG|Mysql2|Trilogy|SQLite3)::|StatementInvalid|RecordNotUnique|NotNullViolation|ForeignKeyViolation|ValueTooLong|RangeError\z/
26
+ SENSITIVE_VALUE = /((?:"|:)?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|credential|cookie|session)\w*"?\s*(?:=>|:)\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,}\])]+)/i
27
+
28
+ def scrub_message(error)
29
+ text = error.message.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
30
+ text = text.gsub(SENSITIVE_VALUE, '\\1"[FILTERED]"')
31
+ .gsub(/(Failing row contains )\([^\n]*\)/, "\\1(…)")
32
+ .gsub(/(Key \([^)\n]*\)=)\([^\n]*\)/, "\\1(…)")
33
+ .gsub(/(invalid input (?:syntax|value) for [^:\n]*: )"[^"\n]*"/, '\\1"…"')
34
+ text = text.gsub(/'(?:[^'\\]|\\.|'')*'?/, "?") if database_error?(error)
35
+ scrub(text)
36
+ end
37
+
22
38
  def scrub(value)
23
39
  value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
24
40
  .gsub(/\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i, "[EMAIL]")
@@ -28,5 +44,11 @@ module RailsMind
28
44
  .gsub(%r{(https?://[^\s?#]+)[?#][^\s]*}, '\\1')
29
45
  .slice(0, MAX_STRING)
30
46
  end
47
+
48
+ private
49
+
50
+ def database_error?(error)
51
+ error.class.ancestors.any? { |ancestor| ancestor.name.to_s.match?(DATABASE_ERROR) }
52
+ end
31
53
  end
32
54
  end
@@ -1,3 +1,3 @@
1
1
  module RailsMind
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_mind
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - RailsMind