rails_mind 0.3.0 → 0.3.2
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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +8 -2
- data/docs/sdk.md +12 -3
- data/lib/generators/rails_mind/install/templates/initializer.rb.tt +6 -2
- data/lib/rails_mind/client.rb +24 -6
- data/lib/rails_mind/configuration.rb +9 -3
- data/lib/rails_mind/diagnostic.rb +12 -0
- data/lib/rails_mind/integrations/rails.rb +132 -11
- data/lib/rails_mind/redactor.rb +144 -8
- data/lib/rails_mind/version.rb +1 -1
- data/lib/tasks/rails_mind.rake +8 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9306b38b60c315db82ad49a0d338c625396362d4cb738f1b26639719c1805aab
|
|
4
|
+
data.tar.gz: 4a8b8e5e00a4b4141d8c95e0fcaf06cd1bb9b6e99980dc0c3a7248fb772c4dc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5cae059308e349dc76067a47da37b21f2d11b6bc62b3690f5669187aa9663500aa3f4df0f39a4e1648489808263861223f12f82aaef7df161881d8c7f1710dd8
|
|
7
|
+
data.tar.gz: 5367481af0017d310b6b5de0bf82e29ecf4d734e366c03fbf2562033e71475eb976cd9d99f99d4c2634067350f22f078f6a2c0dbb760a5711f03c7833949fef6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
Security fix for the scrubbing 0.3.1 turned on by default. Upgrade from 0.3.1.
|
|
6
|
+
|
|
7
|
+
- An `Authorization` header in a message or string (`Bearer`, `Basic`, `Token` and other schemes; any case; `:`, `=` or `=>`; quoted, unquoted or wrapped in an array as `Net::HTTP#to_hash` prints it; `Proxy-Authorization` too) loses its credential. A `Digest` or `AWS4-HMAC-SHA256` header loses its whole value. 0.3.1 replaced only the scheme word and sent the credential after it.
|
|
8
|
+
- Userinfo goes from a URL of any scheme (`redis://:password@`, `rediss://`, `amqp://`, `postgres://`, `mysql2://`, `mongodb+srv://` and others), not only `http(s)://`, up to the last `@` before the path, so an unencoded `@` in a password leaves nothing behind.
|
|
9
|
+
- Provider credentials go without a key word next to them: GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), AWS access key IDs (`AKIA`, `ASIA`), Stripe secret and restricted keys (`sk_live_`, `sk_test_`, `rk_…`), Slack tokens (`xox…-`), `sk-` API keys and JWTs.
|
|
10
|
+
- Card numbers (13 to 19 digits, grouped by spaces or dashes or not, passing the Luhn check, also next to an expiry or another number) and US social security numbers (`123-45-6789`) go from free text: messages, backtraces, event names and properties. Identity, correlation and release values (`user_id`, `account_id`, `visitor_id`, `request_id`, `release` and the like) are left alone, so a numeric account ID is never mistaken for a card. In printed hashes, `phone` and `ssn` values go too.
|
|
11
|
+
- Scrubbing reads at most 8 KB of a string, cut at whitespace, and every pass is linear, so a hostile message cannot stall the request that raised it. What is sent is still at most 1024 characters.
|
|
12
|
+
- A PostgreSQL literal ending in a backslash no longer lets the next literal through, in query shapes or database error messages. Rails runs PostgreSQL with `standard_conforming_strings`, where `\` escapes a quote only inside `E'…'`, while MySQL lets it escape one; a statement or message containing a backslash is now read both ways and anything either reading takes for a literal goes. Statements without a backslash keep their fingerprints; one with an ambiguous `\'` keeps its text only up to that literal, so its fingerprint changes.
|
|
13
|
+
- Tests: an adversarial corpus of these inputs that no secret may survive, as a message, an event property or a query shape.
|
|
14
|
+
- `rails_mind:doctor` now exits 1 and names `RAILS_MIND_KEY` or the endpoint when collection would be off.
|
|
15
|
+
|
|
16
|
+
## 0.3.1 — 2026-10-11
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- Strip SQL comments (query log tags, Marginalia) before fingerprinting, so a query tagged per request keeps one fingerprint.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- 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`.
|
|
24
|
+
|
|
3
25
|
## 0.3.0 — 2026-10-10
|
|
4
26
|
|
|
5
27
|
- 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/README.md
CHANGED
|
@@ -19,7 +19,6 @@ and [release notes](CHANGELOG.md).
|
|
|
19
19
|
bundle install
|
|
20
20
|
bin/rails generate rails_mind:install --plan
|
|
21
21
|
bin/rails generate rails_mind:install
|
|
22
|
-
bin/rails rails_mind:doctor
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
Set **`RAILS_MIND_KEY`** for the destination application environment. It is the only
|
|
@@ -29,7 +28,14 @@ app's deployed `REVISION` file; telemetry still works when none is available.
|
|
|
29
28
|
`RAILS_MIND_ENDPOINT` and `RAILS_MIND_RELEASE` are optional overrides. See
|
|
30
29
|
[configuration and release detection](docs/sdk.md#configuration-and-release-detection).
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
With the key in the app's environment, check the effective settings. Until the
|
|
32
|
+
key is set, doctor names it and exits non-zero.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bin/rails rails_mind:doctor
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Next, run `bin/rails rails_mind:verify` on the app's
|
|
33
39
|
server. Match its check ID in RailsMind **Setup** and wait for **Check processed**.
|
|
34
40
|
Then visit the app to confirm real traffic. This command sends one labeled
|
|
35
41
|
diagnostic check, consumes one event of quota, and leaves business/performance
|
data/docs/sdk.md
CHANGED
|
@@ -16,6 +16,13 @@ gem "rails_mind", "~> 0.3.0"
|
|
|
16
16
|
bundle install
|
|
17
17
|
bin/rails generate rails_mind:install --plan
|
|
18
18
|
bin/rails generate rails_mind:install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then set `RAILS_MIND_KEY` ([configuration](#configuration-and-release-detection)) and, with
|
|
22
|
+
it in the app's environment, check the effective settings. Until the key is set,
|
|
23
|
+
doctor names it and exits non-zero.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
19
26
|
bin/rails rails_mind:doctor
|
|
20
27
|
```
|
|
21
28
|
|
|
@@ -66,7 +73,9 @@ When upgrading an existing generated initializer, remove the old endpoint/releas
|
|
|
66
73
|
assignments that directly read `ENV` so they do not overwrite the new defaults
|
|
67
74
|
with `nil` or bypass platform detection. The installer preserves existing files.
|
|
68
75
|
The doctor reports whether the effective endpoint, key, and release are present,
|
|
69
|
-
without printing credential values or contacting the server.
|
|
76
|
+
without printing credential values or contacting the server. While the key is
|
|
77
|
+
missing or the endpoint is blank, it names the setting after the report and
|
|
78
|
+
exits non-zero, as `rails_mind:verify` does.
|
|
70
79
|
|
|
71
80
|
## Verify the connection
|
|
72
81
|
|
|
@@ -180,7 +189,7 @@ RailsMind::Integrations::OpenTelemetry.attach!
|
|
|
180
189
|
|
|
181
190
|
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
191
|
|
|
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
|
|
192
|
+
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
193
|
|
|
185
194
|
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
195
|
|
|
@@ -328,7 +337,7 @@ RailsMind.flush(timeout: 2)
|
|
|
328
337
|
|
|
329
338
|
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
339
|
|
|
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
|
|
340
|
+
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
341
|
|
|
333
342
|
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
343
|
|
|
@@ -14,8 +14,12 @@ RailsMind.configure do |config|
|
|
|
14
14
|
# config.analytics = false
|
|
15
15
|
# config.flags = false
|
|
16
16
|
|
|
17
|
-
# Exception messages are
|
|
18
|
-
#
|
|
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
|
|
data/lib/rails_mind/client.rb
CHANGED
|
@@ -40,7 +40,9 @@ module RailsMind
|
|
|
40
40
|
false
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
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.
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
@@ -36,5 +36,17 @@ module RailsMind
|
|
|
36
36
|
"No network check is performed and credential values are never printed." ]
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
# What keeps collection off, one sentence each, naming the setting to change.
|
|
41
|
+
def problems
|
|
42
|
+
[].tap do |found|
|
|
43
|
+
if @config.token.to_s.strip.empty?
|
|
44
|
+
found << "RAILS_MIND_KEY is not set, so collection is off. Set it in this app's environment to the ingest key from RailsMind Setup, restart, then run bin/rails rails_mind:verify."
|
|
45
|
+
end
|
|
46
|
+
if @config.endpoint.to_s.strip.empty?
|
|
47
|
+
found << "The RailsMind endpoint is blank, so collection is off. Leave config.endpoint and RAILS_MIND_ENDPOINT unset to use #{Configuration::DEFAULT_ENDPOINT}, or set your RailsMind instance's URL."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
39
51
|
end
|
|
40
52
|
end
|
|
@@ -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,84 @@ 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
|
+
# The same lexemes where a backslash escapes nothing outside E'...', as on
|
|
117
|
+
# PostgreSQL (Rails turns standard_conforming_strings on) and SQLite. Which
|
|
118
|
+
# rule applies is not always known (MySQL can turn backslash escapes off),
|
|
119
|
+
# so a statement with a backslash is read both ways and anything either
|
|
120
|
+
# reading takes for a literal goes. Without a backslash both agree.
|
|
121
|
+
STANDARD_LEXEME = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^']|'')*'?|\$(\w*)\$.*?(?:\$\1\$|\z)|"(?:[^"]|"")*"?|`[^`]*`?|--[^\n]*|\/\*.*?(?:\*\/|\z)|\b0[xX]\h+\b|\b\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\b/m
|
|
122
|
+
|
|
123
|
+
def self.normalize(sql, mysql: false)
|
|
124
|
+
sql = sql.scrub("?")
|
|
125
|
+
text = sql.include?("\\") ? read_both_ways(sql, mysql) : sql.gsub(LEXEME) { |token| replacement(token, mysql) }
|
|
126
|
+
text.gsub(/\s+/, " ").strip
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.replacement(token, mysql)
|
|
130
|
+
case token[0]
|
|
131
|
+
when "-", "/" then " "
|
|
132
|
+
when "`" then token
|
|
133
|
+
when '"' then mysql ? "?" : token
|
|
134
|
+
else "?"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Literal spans from either reading merge into one ?; a comment that
|
|
139
|
+
# overlaps one joins it. Kept identifiers stay only where no reading saw a
|
|
140
|
+
# literal.
|
|
141
|
+
def self.read_both_ways(sql, mysql)
|
|
142
|
+
literals, comments = [], []
|
|
143
|
+
[ STANDARD_LEXEME, LEXEME ].each do |pattern|
|
|
144
|
+
sql.scan(pattern) do
|
|
145
|
+
token = Regexp.last_match
|
|
146
|
+
case replacement(token[0], mysql)
|
|
147
|
+
when "?" then literals << token.offset(0)
|
|
148
|
+
when " " then comments << token.offset(0)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
literals = merge(literals)
|
|
153
|
+
loop do
|
|
154
|
+
# Merged literal spans are sorted and disjoint, so their ends are too.
|
|
155
|
+
joining, comments = comments.partition do |start, finish|
|
|
156
|
+
overlap = literals.bsearch { |_, last| last > start }
|
|
157
|
+
overlap && overlap[0] < finish
|
|
158
|
+
end
|
|
159
|
+
break if joining.empty?
|
|
160
|
+
literals = merge(literals + joining)
|
|
161
|
+
end
|
|
162
|
+
spans = (literals.map { |span| span + [ "?" ] } + merge(comments).map { |span| span + [ " " ] }).sort
|
|
163
|
+
output, position = +"", 0
|
|
164
|
+
spans.each do |start, finish, mark|
|
|
165
|
+
output << sql[position...start] << mark
|
|
166
|
+
position = finish
|
|
167
|
+
end
|
|
168
|
+
output << sql[position..]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.merge(spans)
|
|
172
|
+
spans.sort.each_with_object([]) do |(start, finish), merged|
|
|
173
|
+
if merged.any? && start < merged.last[1]
|
|
174
|
+
merged.last[1] = [ merged.last[1], finish ].max
|
|
175
|
+
else
|
|
176
|
+
merged << [ start, finish ]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
private_class_method :replacement, :read_both_ways, :merge
|
|
181
|
+
|
|
77
182
|
class << self
|
|
78
183
|
def install!
|
|
79
184
|
return if @subscriptions
|
|
@@ -83,20 +188,33 @@ module RailsMind
|
|
|
83
188
|
queries = Thread.current[:rails_mind_queries]
|
|
84
189
|
counts = queries&.fetch(:fingerprints, {}) || {}
|
|
85
190
|
status = payload[:status] || (payload[:exception] ? 500 : 200)
|
|
191
|
+
top = counts.sort_by { |_, count| -count }.first(10).to_h
|
|
192
|
+
properties = { controller: payload[:controller], action: payload[:action],
|
|
193
|
+
method: payload[:method], view_runtime: payload[:view_runtime], db_runtime: payload[:db_runtime],
|
|
194
|
+
query_count: queries&.fetch(:count), repeated_query_count: counts.values.sum { |n| [ n - 1, 0 ].max },
|
|
195
|
+
query_fingerprints: top }
|
|
196
|
+
# Shapes go only for the queries that repeated: that is what a finding is about.
|
|
197
|
+
if RailsMind.client.config.capture_query_shapes
|
|
198
|
+
shapes = queries&.fetch(:shapes, {}) || {}
|
|
199
|
+
properties[:query_shapes] = top.filter_map { |fingerprint, count| [ fingerprint, shapes[fingerprint] ] if count >= 2 && shapes[fingerprint] }.to_h
|
|
200
|
+
end
|
|
86
201
|
RailsMind.client.emit(:request, "#{payload[:controller]}##{payload[:action]}", duration_ms: event.duration,
|
|
87
|
-
status: status.to_s, properties:
|
|
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 })
|
|
202
|
+
status: status.to_s, properties: properties)
|
|
91
203
|
end
|
|
92
204
|
subscribe("sql.active_record") do |event|
|
|
93
205
|
queries = Thread.current[:rails_mind_queries]
|
|
94
206
|
next unless queries && !event.payload[:cached] && !%w[SCHEMA TRANSACTION].include?(event.payload[:name])
|
|
95
207
|
queries[:count] += 1
|
|
96
|
-
#
|
|
97
|
-
|
|
208
|
+
# Comments (query log tags, Marginalia) and literals go before hashing, so one
|
|
209
|
+
# query has one fingerprint. Binds are never read. The shape is this same
|
|
210
|
+
# normalized text, capped, and leaves only when capture_query_shapes allows.
|
|
211
|
+
mysql = event.payload[:connection].respond_to?(:adapter_name) && event.payload[:connection].adapter_name.to_s.match?(/mysql|trilogy/i)
|
|
212
|
+
normalized = RailsNotifications.normalize(event.payload[:sql].to_s[0, 16_384], mysql: mysql)
|
|
213
|
+
next if normalized.match?(BOOKKEEPING)
|
|
98
214
|
fingerprint = Digest::SHA256.hexdigest(normalized)[0, 24]
|
|
99
|
-
|
|
215
|
+
next unless queries[:fingerprints].key?(fingerprint) || queries[:fingerprints].size < 100
|
|
216
|
+
queries[:fingerprints][fingerprint] += 1
|
|
217
|
+
(queries[:shapes] ||= {})[fingerprint] ||= normalized[0, SHAPE_CHARS].byteslice(0, SHAPE_BYTES).scrub("") if RailsMind.client.config.capture_query_shapes
|
|
100
218
|
end
|
|
101
219
|
%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
220
|
subscribe(name) do |event|
|
|
@@ -128,7 +246,10 @@ module RailsMind
|
|
|
128
246
|
kind = enqueue || operation == "perform_start" ? :span : :job
|
|
129
247
|
RailsMind.client.emit(kind, job.class.name, duration_ms: event.duration, status: status,
|
|
130
248
|
properties: job_properties(job, operation))
|
|
131
|
-
|
|
249
|
+
if error
|
|
250
|
+
RailsMind.capture_exception(error, source: "active_job",
|
|
251
|
+
location: { role: "job", job_class: job.class.name, queue: job.queue_name.to_s })
|
|
252
|
+
end
|
|
132
253
|
end
|
|
133
254
|
end
|
|
134
255
|
end
|
data/lib/rails_mind/redactor.rb
CHANGED
|
@@ -2,13 +2,22 @@ module RailsMind
|
|
|
2
2
|
class Redactor
|
|
3
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
4
|
MAX_STRING = 1024
|
|
5
|
+
# Scrubbing reads at most this much of a string; what it sends is still at
|
|
6
|
+
# most MAX_STRING. Every pass below is linear, and the cap bounds the rest.
|
|
7
|
+
INPUT_LIMIT = 8192
|
|
8
|
+
# Identity, correlation and release values are not free text: a numeric
|
|
9
|
+
# account ID that passes the Luhn check is not a card number.
|
|
10
|
+
PLAIN_FIELDS = %w[id kind occurred_at status release trace_id span_id parent_span_id request_id job_id visitor_id user_id account_id].freeze
|
|
5
11
|
|
|
6
12
|
def call(value, depth = 0)
|
|
7
13
|
return "[TRUNCATED]" if depth > 5
|
|
8
14
|
case value
|
|
9
15
|
when Hash
|
|
10
16
|
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)
|
|
17
|
+
clean[key] = if key.match?(FILTER) then "[FILTERED]"
|
|
18
|
+
elsif depth.zero? && PLAIN_FIELDS.include?(key) && (item.is_a?(String) || item.is_a?(Symbol)) then scrub(item.to_s, free_text: false)
|
|
19
|
+
else call(item, depth + 1)
|
|
20
|
+
end
|
|
12
21
|
end
|
|
13
22
|
when Array then value.first(50).map { |item| call(item, depth + 1) }
|
|
14
23
|
when String then scrub(value)
|
|
@@ -19,14 +28,141 @@ module RailsMind
|
|
|
19
28
|
end
|
|
20
29
|
end
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
# An authorization scheme names the credential that follows it, so the
|
|
32
|
+
# scheme and the credential go together. Digest and AWS signature headers
|
|
33
|
+
# carry several credential parts, so their whole value goes.
|
|
34
|
+
SCHEME = "(?:bearer|basic|token|negotiate|ntlm|bot|apikey)\\s+".freeze
|
|
35
|
+
WHOLE_VALUE_SCHEME = "(?:digest|aws4-hmac-sha256)\\s[^\\n]*".freeze
|
|
36
|
+
|
|
37
|
+
# Exception messages carry what generic scrubbing cannot recognize: database
|
|
38
|
+
# errors quote the row, key or input that failed (and the SQL with its
|
|
39
|
+
# literals), and Ruby prints hashes and keyword arguments with their values,
|
|
40
|
+
# header values wrapped in arrays (Net::HTTP#to_hash) included.
|
|
41
|
+
DATABASE_ERROR = /\A(?:PG|Mysql2|Trilogy|SQLite3)::|StatementInvalid|RecordNotUnique|NotNullViolation|ForeignKeyViolation|ValueTooLong|RangeError\z/
|
|
42
|
+
SENSITIVE_VALUE = /((?:"|:)?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|credential|cookie|session|phone|ssn)\w{0,64}"?\s*(?:=>|:)\s*(?:\[(?!FILTERED\]))?)(?:\[FILTERED\]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#{WHOLE_VALUE_SCHEME}|(?:#{SCHEME})?[^\s,}\])]+)/i
|
|
43
|
+
|
|
44
|
+
# PostgreSQL connections use standard_conforming_strings, where a backslash
|
|
45
|
+
# does not escape the quote of a '...' literal (it does inside E'...');
|
|
46
|
+
# MySQL lets it escape the quote. Text with a backslash is read both ways,
|
|
47
|
+
# and anything either reading takes for a literal goes.
|
|
48
|
+
STANDARD_LITERAL = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^']|'')*'?/
|
|
49
|
+
ESCAPED_LITERAL = /'(?:[^'\\]|\\.|'')*'?/
|
|
50
|
+
|
|
51
|
+
def scrub_message(error)
|
|
52
|
+
text = bounded(error.message.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�"))
|
|
53
|
+
text = text.gsub(SENSITIVE_VALUE, '\\1"[FILTERED]"')
|
|
54
|
+
.gsub(/(Failing row contains )\([^\n]*\)/, "\\1(…)")
|
|
55
|
+
.gsub(/(Key \([^)\n]*\)=)\([^\n]*\)/, "\\1(…)")
|
|
56
|
+
.gsub(/(invalid input (?:syntax|value) for [^:\n]*: )"[^"\n]*"/, '\\1"…"')
|
|
57
|
+
text = mask_literals(text) if database_error?(error)
|
|
58
|
+
scrub(text)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Userinfo runs to the last @ before the path, so an unencoded @ in a
|
|
62
|
+
# password cannot leave its tail behind.
|
|
63
|
+
USERINFO = %r{\b([a-z][a-z0-9+.\-]{0,31}://)[^\s/]*@}i
|
|
64
|
+
EMAIL = /\b[A-Z0-9._%+\-]{1,128}@[A-Z0-9.\-]{1,253}\.[A-Z]{2,}\b/i
|
|
65
|
+
AUTHORIZATION = /((?:proxy-)?authorization["']?\s*(?:=>|[=:])\s*(?:\[(?!FILTERED\]))?\s*["']?)(?:\[FILTERED\]|#{WHOLE_VALUE_SCHEME}|(?:#{SCHEME})?[^\s,;&"']+)/i
|
|
66
|
+
KEY_VALUE = /((?:password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)\s*(?:=>|[=:])\s*)(?:#{SCHEME})?[^\s,;&]+/i
|
|
67
|
+
PROVIDER_TOKEN = /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{22,}|(?:AKIA|ASIA)[A-Z0-9]{16}\b|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abposr]-[A-Za-z0-9-]{10,}|sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}|eyJ[\w-]{5,512}\.[\w-]{5,}\.[\w-]*)/
|
|
68
|
+
SSN = /\b\d{3}([- ])\d{2}\1\d{4}\b/
|
|
69
|
+
DIGIT_RUN = /(?<!\w)\d(?:[ -]?\d)*(?!\w)/
|
|
70
|
+
|
|
71
|
+
def scrub(value, free_text: true)
|
|
72
|
+
text = bounded(value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�"))
|
|
73
|
+
.gsub(USERINFO, '\\1[FILTERED]@')
|
|
74
|
+
.gsub(EMAIL, "[EMAIL]")
|
|
75
|
+
.gsub(AUTHORIZATION, '\\1[FILTERED]')
|
|
25
76
|
.gsub(/\bBearer\s+\S+/i, "Bearer [FILTERED]")
|
|
26
|
-
.gsub(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
77
|
+
.gsub(KEY_VALUE, '\\1[FILTERED]')
|
|
78
|
+
text = text.gsub(PROVIDER_TOKEN, "[FILTERED]").gsub(SSN, "[FILTERED]").gsub(DIGIT_RUN) { |run| mask_cards(run) } if free_text
|
|
79
|
+
text.gsub(%r{https?://\S+}) { |url| url[/\A[^?#]*/] }.slice(0, MAX_STRING)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def database_error?(error)
|
|
85
|
+
error.class.ancestors.any? { |ancestor| ancestor.name.to_s.match?(DATABASE_ERROR) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A cut string ends at its last whitespace, so no half token is left that a
|
|
89
|
+
# pass could no longer recognize.
|
|
90
|
+
def bounded(text)
|
|
91
|
+
return text if text.length <= INPUT_LIMIT
|
|
92
|
+
text = text[0, INPUT_LIMIT]
|
|
93
|
+
cut = text.rindex(/\s/)
|
|
94
|
+
cut ? text[0, cut] : text
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mask_literals(text)
|
|
98
|
+
return text.gsub(ESCAPED_LITERAL, "?") unless text.include?("\\")
|
|
99
|
+
spans = [ STANDARD_LITERAL, ESCAPED_LITERAL ].flat_map { |pattern| text.to_enum(:scan, pattern).map { Regexp.last_match.offset(0) } }
|
|
100
|
+
output, position = +"", 0
|
|
101
|
+
merge(spans).each do |start, finish|
|
|
102
|
+
output << text[position...start] << "?"
|
|
103
|
+
position = finish
|
|
104
|
+
end
|
|
105
|
+
output << text[position..]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def merge(spans)
|
|
109
|
+
spans.sort.each_with_object([]) do |(start, finish), merged|
|
|
110
|
+
if merged.any? && start < merged.last[1]
|
|
111
|
+
merged.last[1] = [ merged.last[1], finish ].max
|
|
112
|
+
else
|
|
113
|
+
merged << [ start, finish ]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A card number is 13 to 19 digits, maybe in groups split by spaces or
|
|
119
|
+
# dashes, that pass the Luhn check. A run of digit groups can hold one next
|
|
120
|
+
# to other numbers (an expiry, a quantity), so every span of whole groups
|
|
121
|
+
# with at most 19 digits is tried, and the longest that passes goes.
|
|
122
|
+
def mask_cards(run)
|
|
123
|
+
groups = run.to_enum(:scan, /\d+/).map { Regexp.last_match.offset(0) }
|
|
124
|
+
digits = run.delete(" -")
|
|
125
|
+
return run if digits.length < 13
|
|
126
|
+
bounds, count = [], 0
|
|
127
|
+
groups.each { |start, finish| bounds << [ count, count += finish - start ] }
|
|
128
|
+
plain, doubled = luhn_sums(digits)
|
|
129
|
+
output, position, first = +"", 0, 0
|
|
130
|
+
while first < groups.size
|
|
131
|
+
longest, from = nil, bounds[first][0]
|
|
132
|
+
(first...groups.size).each do |last|
|
|
133
|
+
to = bounds[last][1]
|
|
134
|
+
break if to - from > 19
|
|
135
|
+
next if to - from < 13
|
|
136
|
+
parity = to % 2
|
|
137
|
+
sum = doubled[parity][to] - doubled[parity][from] + plain[1 - parity][to] - plain[1 - parity][from]
|
|
138
|
+
longest = last if (sum % 10).zero?
|
|
139
|
+
end
|
|
140
|
+
if longest
|
|
141
|
+
output << run[position...groups[first][0]] << "[FILTERED]"
|
|
142
|
+
position, first = groups[longest][1], longest + 1
|
|
143
|
+
else
|
|
144
|
+
first += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
output << run[position..]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Prefix sums of each digit, plain and Luhn-doubled, split by position
|
|
151
|
+
# parity. In a span ending at index `to`, Luhn doubles the digits whose
|
|
152
|
+
# position has the parity of `to`, so any span's checksum is four lookups.
|
|
153
|
+
def luhn_sums(digits)
|
|
154
|
+
plain, doubled = Array.new(2) { [ 0 ] }, Array.new(2) { [ 0 ] }
|
|
155
|
+
digits.each_char.with_index do |char, index|
|
|
156
|
+
value = char.ord - 48
|
|
157
|
+
twice = value * 2
|
|
158
|
+
twice -= 9 if twice > 9
|
|
159
|
+
[ 0, 1 ].each do |parity|
|
|
160
|
+
own = index % 2 == parity
|
|
161
|
+
plain[parity] << plain[parity].last + (own ? value : 0)
|
|
162
|
+
doubled[parity] << doubled[parity].last + (own ? twice : 0)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
[ plain, doubled ]
|
|
30
166
|
end
|
|
31
167
|
end
|
|
32
168
|
end
|
data/lib/rails_mind/version.rb
CHANGED
data/lib/tasks/rails_mind.rake
CHANGED
|
@@ -7,9 +7,15 @@ namespace :rails_mind do
|
|
|
7
7
|
exit 1 unless result[:success]
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
desc "Inspect dependencies and report the non-destructive integration plan"
|
|
10
|
+
desc "Inspect dependencies and report the non-destructive integration plan; fails while collection is off"
|
|
11
11
|
task doctor: :environment do
|
|
12
12
|
require "rails_mind/diagnostic"
|
|
13
|
-
|
|
13
|
+
diagnostic = RailsMind::Diagnostic.new(root: Rails.root)
|
|
14
|
+
puts JSON.pretty_generate(diagnostic.report.merge(collector: RailsMind.stats))
|
|
15
|
+
problems = diagnostic.problems
|
|
16
|
+
unless problems.empty?
|
|
17
|
+
warn problems
|
|
18
|
+
exit 1
|
|
19
|
+
end
|
|
14
20
|
end
|
|
15
21
|
end
|