rails_mind 0.2.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: fdc13f69dfd22c26fb7bf8d6f8cbf3f202d0cd7e5fd118d84611473f6486c2ab
4
- data.tar.gz: af85f41317d417ad0521d2c93258ebee6f0605c6fbdfb0f0d3bbbabf759555a6
3
+ metadata.gz: 902c75e77dcb5d76b75de68e62e73fe7a92f38ec05f621084bc6a61c047cb186
4
+ data.tar.gz: b499762228b099f7d940a3b4aa58ba1d1c73d4a9c53958a02e379982fa08b28b
5
5
  SHA512:
6
- metadata.gz: a6c3fa13f2cbf791b1e6d0128e23d2060454386f39bb3ceb97817c368f1a4577145bae249d15db009cf8b967a65474e767dedcf97710af3b238bdde3334a12f9
7
- data.tar.gz: 6a8eee210906bde6ab8edda408966e535139ba3ce114eda1a91ff3075700223e2dde8f867dd65727f14f80be38da0c7c8783d6d905adf513d5afcb20f746c4d3
6
+ metadata.gz: ae733243113a0206980e2b34f2545f5648c00b4e032d32ae2727bd8b5da85d67067970cc73ff1654eef1eb93782457d71cb65c45e9a6bfcde5f6f7fc03985310
7
+ data.tar.gz: 48d196c72e82519469bc6afba1e47a585012a9ff3f0812c243ddd9b00378f6512f5b7d4f8d62fbd5158a029e2166d35e8d3373588fc3458c87c324848d477ed2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
12
+ ## 0.3.0 — 2026-10-10
13
+
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.
15
+ - Report eligible request selection and queue losses with immutable IDs and independent acknowledgments, including report-only batches at zero sampling rates. Keep old-server event delivery compatible until a v1 handshake.
16
+ - Add explicit local opt-out, effective request metadata, bounded control parsing, fork resets, and sampling diagnostics. Local opt-out does not disable server-side sampling or quotas.
17
+ - Separate known local drops from unconfirmed delivery; failed responses may follow partial server acceptance.
18
+
3
19
  ## 0.2.0 — 2026-10-09
4
20
 
5
21
  - Correlate requests, serialized ActiveJob executions, and mailer attempts with native trace IDs while preserving existing OpenTelemetry context.
data/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  The MIT-licensed Ruby client for [RailsMind](https://railsmind.com). One bounded
4
4
  collector for Rails requests, errors, jobs, mailer attempts, Ahoy events, Flipper
5
- observations, and selected OpenTelemetry spans. Version 0.2.0 is an early release; see
5
+ observations, and selected OpenTelemetry spans. Version 0.3.0 is an early release; see
6
6
  [compatibility and validation limits](docs/compatibility.md).
7
7
 
8
8
  ```ruby
9
- gem "rails_mind", "~> 0.2.0"
9
+ gem "rails_mind", "~> 0.3.0"
10
10
  ```
11
11
 
12
12
  Install from RubyGems.org and commit your application's `Gemfile.lock` to pin the
@@ -67,4 +67,4 @@ bundle exec ruby script/package_smoke.rb
67
67
  ruby script_benchmark.rb
68
68
  ```
69
69
 
70
- Business/request/error events are unsampled, but delivery is best effort. Check `RailsMind.stats` for losses. The SDK does not guarantee durable business-event counts; use a transactional outbox when that is required.
70
+ Automatic endpoint sampling is enabled by default. The hosted policy can reduce routine successful requests while preserving failed, slow, or repeated-query requests and all other signal kinds from sampling. Business/error events remain unsampled; bounded queues and delivery are still best effort. See [sampling and coverage](docs/sdk.md#automatic-endpoint-sampling) and inspect `RailsMind.stats`. Use a transactional outbox when durable business-event delivery is required.
@@ -94,3 +94,19 @@ recipient inbox delivery, direct backend jobs, direct provider HTTP sends, and
94
94
  durable workflow completeness are outside this contract. Validate the released
95
95
  SDK against your app's queue, callback ordering, and delivery adapter. The hosted
96
96
  service exposes bounded workflow metadata and leaves recipient delivery unknown.
97
+
98
+ ## 0.3.0 endpoint sampling verification
99
+
100
+ The Ruby suite passes locally with Ruby 4.0.7 and the locked Rails 8.1.4
101
+ libraries: 105 tests and 1,311 assertions, with no failures or skips. The three
102
+ Node browser-adapter contract tests also pass. These are isolated SDK/library
103
+ tests; they do not boot a Rails application or use a database. CI separately
104
+ runs the packaged-gem installation smoke check on Ruby 4.0.6.
105
+
106
+ Coverage includes protected signals, deterministic shared selection, endpoint
107
+ rates, local opt-out, malformed/expired controls, monotonic expiry, queue loss,
108
+ immutable reports and acknowledgments, quota-response retries, old-server
109
+ fallback, idle zero-rate report refresh, streamed response bounds, batch byte
110
+ limits, and real process fork resets. Fake transports verify the wire contract;
111
+ deployed server policy behavior, cross-process traffic forecasts, and a
112
+ customer app's production overhead require hosted/staging validation.
data/docs/sdk.md CHANGED
@@ -3,13 +3,13 @@
3
3
  The gem is `rails_mind`, namespace `RailsMind`. Install the MIT-licensed client
4
4
  from RubyGems.org and commit the application lockfile to retain the selected
5
5
  version. The hosted RailsMind service is a separate, proprietary application.
6
- Version 0.2.0 is an early release with the [documented compatibility limits](compatibility.md).
6
+ Version 0.3.0 is an early release with the [documented compatibility limits](compatibility.md).
7
7
 
8
8
  ## Install
9
9
 
10
10
  ```ruby
11
11
  # Customer Gemfile
12
- gem "rails_mind", "~> 0.2.0"
12
+ gem "rails_mind", "~> 0.3.0"
13
13
  ```
14
14
 
15
15
  ```sh
@@ -180,9 +180,74 @@ 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 durations/counts and error reports come independently from Rails notifications/error reporting, even when detailed traces are sampled out. 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
- Full metrics/logs SDK integration is deferred; hosted aggregates use unsampled request events and the SDK does not capture application logs.
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
+
187
+ ## Automatic endpoint sampling
188
+
189
+ Version 0.3.0 enables automatic endpoint sampling by default. A compatible hosted
190
+ service supplies environment-wide endpoint rates based on its allowance policy.
191
+ The SDK applies them before queueing, reducing serialization, network, and retained
192
+ event volume. Each process uses the same policy and deterministic event-ID hash;
193
+ there are no independent per-process allowance grants. A server-side tightening
194
+ uses the same selection rule, so the two samplers do not multiply probabilities.
195
+
196
+ Only completed `request` events with status 200–399, a finite duration from zero
197
+ to below 1,000 ms, and fewer than three repeated queries are eligible. Slow,
198
+ failed, unknown-duration, and repeated-query requests remain unsampled, as do
199
+ business events, errors, jobs, mailer observations, visits, flags, checks, and
200
+ OpenTelemetry spans. This preserves their sampling priority, not guaranteed
201
+ delivery: queue bounds, payload limits, outages, and the server's hard daily quota
202
+ still apply. Existing OpenTelemetry samplers remain authoritative; this feature
203
+ does not promise complete distributed traces.
204
+
205
+ To turn off SDK request selection explicitly:
206
+
207
+ ```ruby
208
+ RailsMind.configure do |config|
209
+ config.automatic_sampling = false
210
+ end
211
+ ```
212
+
213
+ The SDK communicates this choice on policy and ingestion requests. The
214
+ environment owner's hosted setting separately controls server-side sampling;
215
+ local opt-out does **not** disable it or bypass the daily quota.
216
+
217
+ The background worker requests `/api/v1/sampling_policy` with the ingest key and
218
+ refreshes at most once per minute after its first request observation, including
219
+ when the current rate is zero. It also receives controls on successful ingestion
220
+ and quota responses. Missing, malformed, or expired policies fall back to rate 1;
221
+ this may exhaust the server allowance during an outage. Expiry uses wall and
222
+ monotonic clocks, with a maximum accepted lifetime of ten minutes. Before a valid
223
+ v1 handshake, old servers and custom transports receive ordinary event-only
224
+ batches and retain normal flush behavior. Forked workers reset queues, policies,
225
+ counters, and report IDs and fetch their own copy of the shared policy.
226
+
227
+ Retained request properties include SDK-owned `rails_mind_sampling` metadata:
228
+ `version`, `policy_id`, effective `rate`, and `state` (`active`, `unavailable`,
229
+ `expired`, `disabled`, or `protected`). This is the SDK selection decision, not
230
+ proof of server acceptance or a complete traffic denominator.
231
+
232
+ At most once per minute per process, the worker freezes an identified report of
233
+ routine eligible observations; an explicit flush may send it sooner. Reports
234
+ contain controller/action names and counts only: `observed`, `retained` (selected
235
+ for enqueue), `sampled_out`, and `queue_dropped` (a subset of retained). They never
236
+ contain event properties or user/account identifiers. Counts are observed after
237
+ validation, redaction, and `before_send`; hooks that suppress events, crashes,
238
+ protected requests, and unknown endpoint names are outside this denominator.
239
+ Retained counts are not accepted counts. The resulting request percentiles and
240
+ rates cannot be treated as an unbiased whole-traffic distribution.
241
+
242
+ Reports have immutable UUIDs/bodies and separate durable acknowledgments. They
243
+ can travel in `events: []` batches without consuming event quota, and an
244
+ acknowledged report does not establish delivery of its accompanying events.
245
+ Counters cover at most 100 endpoint names per window, reports share the normal
246
+ batch byte limit, and each process retains at most five unacknowledged reports.
247
+ Overflows or invalid clock windows mark coverage `limited`; reports older than
248
+ seven days are discarded. This is bounded in-memory observation, not a durable
249
+ request ledger. Unacknowledged reports after a confirmed handshake can cause
250
+ `RailsMind.flush` to return false at its deadline.
186
251
 
187
252
  ## Jobs and mailer workflows
188
253
 
@@ -250,18 +315,20 @@ Add `<meta name="rails-mind-page" content="Invoices#index">` to supply a stable
250
315
 
251
316
  ## Bounds, privacy, and outages
252
317
 
253
- Defaults are one lazy worker per process, 1,000 queued/in-flight events, 16 KiB per event, at most 100 events and 256 KiB per batch, and a two-second flush interval. Overflow drops the newest event. Retries retain the same encoded bytes/UUIDs; only HTTP 429, 5xx, and transient transport failures retry, at most three retries with bounded backoff. Other 4xx responses drop the batch. Redirects are not followed. Shutdown gets two seconds by default, then discards outstanding telemetry; forks start a fresh child queue rather than resending the parent's buffer.
318
+ Defaults are one lazy worker per process, 1,000 queued/in-flight events, 16 KiB per event, at most 100 events and 256 KiB per batch, and a two-second flush interval. Overflow drops the newest event. Retries retain the same encoded bytes/UUIDs; only HTTP 429, 5xx, and transient transport failures retry, at most three retries with bounded backoff. Other 4xx responses end delivery attempts. A failed response can follow partial server acceptance, so such events are classified as delivery unconfirmed. Redirects are not followed. Shutdown gets two seconds by default, then discards outstanding telemetry; forks start a fresh child queue rather than resending the parent's buffer.
254
319
 
255
320
  ```ruby
256
321
  RailsMind.stats
257
- # => { enqueued:, delivered:, dropped:, retried:, queued:, in_flight:,
258
- # losses: { "event.overflow" => 3, "error.delivery" => 1 } }
322
+ # => { enqueued:, delivered:, dropped:, delivery_unconfirmed:, retried:, queued:, in_flight:,
323
+ # losses: { "event.overflow" => 3, "error.delivery_unconfirmed" => 1 },
324
+ # sampling: { observed:, retained:, sampled_out:, queue_dropped:,
325
+ # unreported:, reports_lost:, pending_reports:, limited:, policy_state:, policy_id: } }
259
326
  RailsMind.flush(timeout: 2)
260
327
  ```
261
328
 
262
- These are per-process counters. The doctor prints them for its process; inspect running app stats through your existing operational tooling. The hosted app does not currently receive loss counters. Request/business/error signals are unsampled, but process crashes, rejected payloads, redaction hooks, exhausted retries, or queue pressure can still undercount. This is best-effort monitoring, not an accounting ledger. A future transactional outbox can provide durable business-event delivery; the current implementation does not claim that guarantee.
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.
263
330
 
264
- 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.
265
332
 
266
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.
267
334
 
@@ -1,4 +1,7 @@
1
1
  RailsMind.configure do |config|
2
+ # Enabled by default: reduce routine successful request events using hosted policy.
3
+ # Local opt-out does not disable server sampling or the environment daily quota.
4
+ # config.automatic_sampling = false
2
5
  config.token = ENV["RAILS_MIND_KEY"] # Environment-scoped server credential. Never expose to browsers.
3
6
 
4
7
  # Endpoint defaults to https://railsmind.com; RAILS_MIND_ENDPOINT overrides it.
@@ -11,8 +14,12 @@ RailsMind.configure do |config|
11
14
  # config.analytics = false
12
15
  # config.flags = false
13
16
 
14
- # Exception messages are omitted by default. Review before enabling.
15
- # 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
16
23
  # config.before_send = ->(event) { event } # Return nil to drop an event.
17
24
  end
18
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
 
@@ -7,17 +7,28 @@ module RailsMind
7
7
 
8
8
  def push(event)
9
9
  check_process
10
- json = JSON.generate(event)
10
+ event = event.transform_keys(&:to_s)
11
11
  @mutex.synchronize do
12
- reason = if @closed then :closed
13
- elsif json.bytesize > @config.max_event_bytes || json.bytesize + 13 > @config.max_batch_bytes then :oversize
12
+ if @closed
13
+ drop(event["kind"], :closed)
14
+ return false
15
+ end
16
+ selected, observation = @sampling.consider(event)
17
+ unless selected
18
+ start_worker if @autostart
19
+ return false
20
+ end
21
+ json = JSON.generate(event)
22
+ reason = if json.bytesize > @config.max_event_bytes || json.bytesize + 13 > @config.max_batch_bytes then :oversize
14
23
  elsif @queue.size + @in_flight >= @config.queue_capacity then :overflow
15
24
  end
16
25
  if reason
17
- drop(event[:kind] || event["kind"], reason)
26
+ @sampling.queue_dropped(observation)
27
+ drop(event["kind"], reason)
28
+ start_worker if @autostart
18
29
  return false
19
30
  end
20
- @queue << [ json.freeze, event[:kind] || event["kind"] ]
31
+ @queue << [ json.freeze, event["kind"] ]
21
32
  @stats[:enqueued] += 1
22
33
  start_worker if @autostart
23
34
  @condition.broadcast if @queue.size >= @config.batch_size
@@ -27,7 +38,7 @@ module RailsMind
27
38
 
28
39
  def stats
29
40
  check_process
30
- @mutex.synchronize { @stats.merge(queued: @queue.size, in_flight: @in_flight, losses: @losses.dup) }
41
+ @mutex.synchronize { @stats.merge(queued: @queue.size, in_flight: @in_flight, losses: @losses.dup, sampling: @sampling.stats) }
31
42
  end
32
43
 
33
44
  def flush(timeout: @config.shutdown_timeout)
@@ -35,9 +46,9 @@ module RailsMind
35
46
  deadline = monotonic + timeout
36
47
  @mutex.synchronize do
37
48
  start_worker
38
- @force_flush = true
49
+ @force_flush = @force_report = true
39
50
  @condition.broadcast
40
- while @queue.any? || @in_flight.positive?
51
+ while @queue.any? || @in_flight.positive? || @active_report || @sampling.pending?
41
52
  remaining = deadline - monotonic
42
53
  return false if remaining <= 0
43
54
  @condition.wait(@mutex, remaining)
@@ -58,9 +69,10 @@ module RailsMind
58
69
  @mutex.synchronize do
59
70
  @queue.each { |_, kind| drop(kind, :shutdown) }
60
71
  @queue.clear
61
- @active_batch.to_a.each { |_, kind| drop(kind, :shutdown) }
62
- @active_batch = nil
72
+ @active_batch.to_a.each { |_, kind| unconfirmed(kind) }
73
+ @active_batch = @active_report = nil
63
74
  @in_flight = 0
75
+ @sampling.discard!
64
76
  end
65
77
  end
66
78
  success
@@ -73,13 +85,15 @@ module RailsMind
73
85
  @mutex = Mutex.new
74
86
  @condition = ConditionVariable.new
75
87
  @queue, @losses = [], Hash.new(0)
76
- @stats = { enqueued: 0, delivered: 0, dropped: 0, retried: 0 }
88
+ @stats = { enqueued: 0, delivered: 0, dropped: 0, delivery_unconfirmed: 0, retried: 0 }
89
+ @sampling = Sampling.new(@config)
77
90
  @in_flight = 0
78
- @worker = @active_batch = nil
79
- @closed = @stopping = @force_flush = false
91
+ @worker = @active_batch = @active_report = nil
92
+ @closed = @stopping = @force_flush = @force_report = false
80
93
  end
81
94
 
82
- # Never resend a parent's inherited queue after Puma/worker forks.
95
+ # No inherited observations, report IDs, policies or locked mutexes survive
96
+ # a Puma/worker fork. Each child refreshes the same environment-wide policy.
83
97
  def check_process
84
98
  reset_process if @pid != Process.pid
85
99
  end
@@ -94,46 +108,73 @@ module RailsMind
94
108
 
95
109
  def run
96
110
  loop do
97
- batch = @mutex.synchronize do
111
+ refresh = @mutex.synchronize do
98
112
  @condition.wait(@mutex, @config.flush_interval) if @queue.size < @config.batch_size && !@force_flush && !@stopping
99
113
  return if @stopping && @queue.empty?
100
- take_batch
114
+ due = @sampling.policy_due? && @transport.respond_to?(:fetch_sampling_policy)
115
+ @sampling.policy_requested! if due
116
+ due
101
117
  end
102
- next if batch.empty?
103
- result = deliver(batch)
118
+ refresh_policy if refresh
119
+ batch, body = @mutex.synchronize { take_batch }
120
+ next unless body
121
+ result = deliver(body, report_id: @active_report)
104
122
  @mutex.synchronize do
105
123
  if result
106
124
  @stats[:delivered] += batch.size
107
125
  else
108
- batch.each { |_, kind| drop(kind, :delivery) }
126
+ batch.each { |_, kind| unconfirmed(kind) }
109
127
  end
110
128
  @in_flight = 0
111
- @active_batch = nil
112
- @force_flush = false if @queue.empty?
129
+ @active_batch = @active_report = nil
130
+ @force_flush = false if @queue.empty? && !@sampling.pending?
113
131
  @condition.broadcast
114
132
  end
115
133
  end
116
134
  end
117
135
 
118
136
  def take_batch
137
+ @sampling.rotate(force: @force_report, byte_budget: @config.max_batch_bytes - 64)
138
+ report = @sampling.report(force: @force_report)
139
+ @force_report = false if @sampling.supported?
119
140
  batch = []
120
- bytes = 13
141
+ report_json = report && JSON.generate(report)
142
+ bytes = 13 + (report_json ? 19 + report_json.bytesize : 0)
121
143
  while batch.size < @config.batch_size && @queue.any?
122
144
  entry = @queue.first
123
145
  break if bytes + entry.first.bytesize + (batch.empty? ? 0 : 1) > @config.max_batch_bytes
124
146
  bytes += entry.first.bytesize + (batch.empty? ? 0 : 1)
125
147
  batch << @queue.shift
126
148
  end
149
+ if batch.empty? && !report
150
+ @force_flush = false
151
+ @condition.broadcast
152
+ return [ [], nil ]
153
+ end
127
154
  @in_flight = batch.size
128
155
  @active_batch = batch
129
- batch
156
+ @active_report = report && report["id"]
157
+ @sampling.report_sent! if report
158
+ body = '{"events":[' + batch.map(&:first).join(",") + ']'
159
+ body += ',"sampling_report":' + report_json if report_json
160
+ [ batch, (body + '}').freeze ]
130
161
  end
131
162
 
132
- def deliver(batch)
133
- body = '{"events":[' + batch.map(&:first).join(",") + ']}'
163
+ def refresh_policy
164
+ result = @transport.fetch_sampling_policy
165
+ @mutex.synchronize { @sampling.apply_policy(result.sampling_policy) }
166
+ rescue StandardError
167
+ nil # Missing/expired controls fall back to unsampled collection.
168
+ end
169
+
170
+ def deliver(body, report_id:)
134
171
  attempts = 0
135
172
  loop do
136
173
  result = @transport.send_batch(body)
174
+ @mutex.synchronize do
175
+ @sampling.apply_policy(result.sampling_policy) if result.respond_to?(:sampling_policy)
176
+ @sampling.acknowledge(result.sampling_report_ack) if report_id && result.respond_to?(:sampling_report_ack) && result.sampling_report_ack == report_id
177
+ end
137
178
  return true if result.success
138
179
  return false unless result.retryable && attempts < @config.max_retries
139
180
  delay = result.retry_after || @config.retry_base * (2**attempts) * (0.75 + rand * 0.5)
@@ -142,7 +183,12 @@ module RailsMind
142
183
  sleep(delay) if delay.positive?
143
184
  end
144
185
  rescue StandardError
145
- false # Telemetry errors cannot escape into the customer app.
186
+ false
187
+ end
188
+
189
+ def unconfirmed(kind)
190
+ @stats[:delivery_unconfirmed] += 1
191
+ @losses["#{kind}.delivery_unconfirmed"] += 1
146
192
  end
147
193
 
148
194
  def drop(kind, reason)
@@ -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
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,13 +26,21 @@ 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
31
+ @automatic_sampling = true
30
32
  end
31
33
 
32
34
  def enabled? = !token.to_s.strip.empty? && !endpoint.to_s.strip.empty?
33
35
 
34
36
  def validate!
35
37
  return self unless enabled?
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
36
44
  uri = URI(endpoint)
37
45
  loopback = %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
38
46
  raise ArgumentError, "endpoint must use HTTPS (HTTP only on loopback)" unless uri.is_a?(URI::HTTPS) || (uri.is_a?(URI::HTTP) && loopback)
@@ -21,15 +21,16 @@ module RailsMind
21
21
  endpoint_present: !@config.endpoint.to_s.strip.empty?,
22
22
  release_present: !@config.release.to_s.strip.empty?,
23
23
  enabled: @config.enabled?,
24
+ automatic_sampling: @config.automatic_sampling,
24
25
  plan: [
25
26
  "Add one initializer. No database migrations or Gemfile changes.",
26
- "Requests and jobs: unsampled measurements via Rails notifications; errors via Rails.error.",
27
+ "Requests: automatic sampling of routine successes; jobs via Rails notifications and errors via Rails.error stay unsampled.",
27
28
  detected["ahoy_matey"] ? "Ahoy detected: keep current store; explicitly opt into mirror mode after review." : "Analytics: optionally add ahoy_matey; choose hosted-only store or existing database store.",
28
29
  detected["opentelemetry-sdk"] ? "OpenTelemetry detected: attach RailsMind exporter after existing SDK setup; preserve sampler and exporters." : "APM traces: optionally add selected OpenTelemetry libraries and explicitly configure one provider.",
29
30
  detected["flipper"] ? "Flipper detected: observe notifications only; preserve adapter, groups, targeting and cache." : "Flags: Flipper is optional. No flag configuration is created.",
30
31
  "Existing error reporters stay installed. RailsMind deduplicates its own collector only.",
31
32
  "Browser: optional Ahoy adapter; enable only after checking existing pageview handlers.",
32
- "Business/request/error signals are not sampled; bounded queue losses remain possible. Inspect RailsMind.stats."
33
+ "Business/error signals are not sampled; routine requests follow the hosted policy. Local opt-out does not disable server sampling or quotas. Inspect RailsMind.stats for queue losses and unconfirmed delivery."
33
34
  ],
34
35
  limitations: [ "Compatibility applies to tested versions only; this is not a historical import.",
35
36
  "No network check is performed and credential values are never printed." ]
@@ -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
@@ -0,0 +1,211 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "time"
4
+ require "digest"
5
+
6
+ module RailsMind
7
+ # Called under Collector's mutex. Decisions and queue-loss accounting share
8
+ # the same critical section as report rotation; reports are immutable once cut.
9
+ class Sampling
10
+ REPORT_INTERVAL = 60
11
+ POLICY_INTERVAL = 60
12
+ MAX_POLICY_TTL = 600
13
+ MAX_ENDPOINTS = 100
14
+ MAX_PENDING = 5
15
+ MAX_COUNT = 1_000_000_000
16
+ MAX_REPORT_AGE = 7 * 86_400
17
+ ENDPOINT = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*#[A-Za-z_][A-Za-z0-9_!?]*\z/
18
+ UUID = /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/i
19
+
20
+ def initialize(config, clock: -> { Time.now.utc }, monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
21
+ @config, @clock, @monotonic = config, clock, monotonic
22
+ @counts, @reports = {}, []
23
+ @from = @clock.call
24
+ @last_rotation = @monotonic.call
25
+ @next_policy_at = @next_report_at = 0
26
+ @policy = nil
27
+ @activated = false
28
+ @limited = false
29
+ @policy_ids = []
30
+ @stats = { observed: 0, retained: 0, sampled_out: 0, queue_dropped: 0, unreported: 0, reports_lost: 0 }
31
+ end
32
+
33
+ def consider(event)
34
+ return [ true, nil ] unless event["kind"] == "request"
35
+ @activated = true
36
+ rate, state, policy_id = effective_policy
37
+ name = event["name"]
38
+ unless eligible?(event) && name.is_a?(String) && name.bytesize <= 200 && name.match?(ENDPOINT)
39
+ annotate(event, 1.0, "protected", policy_id)
40
+ return [ true, nil ]
41
+ end
42
+ if !@counts.key?(name) && @counts.size >= MAX_ENDPOINTS
43
+ @stats[:unreported] += 1
44
+ @limited = true
45
+ annotate(event, 1.0, "unavailable", policy_id)
46
+ return [ true, nil ]
47
+ end
48
+ count = @counts[name] ||= { "name" => name, "observed" => 0, "retained" => 0, "sampled_out" => 0, "queue_dropped" => 0 }
49
+ if count["observed"] >= MAX_COUNT
50
+ @stats[:unreported] += 1
51
+ @limited = true
52
+ annotate(event, 1.0, "unavailable", policy_id)
53
+ return [ true, nil ]
54
+ end
55
+ rate = @policy[:endpoints].fetch(name, rate) if state == "active"
56
+ selected = rate >= 1 || (rate.positive? && fraction(event["id"]) < rate)
57
+ count["observed"] += 1
58
+ count[selected ? "retained" : "sampled_out"] += 1
59
+ @stats[:observed] += 1
60
+ @stats[selected ? :retained : :sampled_out] += 1
61
+ @policy_ids << (policy_id || state) unless @policy_ids.include?(policy_id || state) || @policy_ids.size >= 2
62
+ annotate(event, rate, state, policy_id) if selected
63
+ [ selected, count ]
64
+ end
65
+
66
+ def queue_dropped(count)
67
+ return unless count
68
+ count["queue_dropped"] += 1
69
+ @stats[:queue_dropped] += 1
70
+ end
71
+
72
+ def apply_policy(value)
73
+ return false unless value.is_a?(Hash) && value["version"] == 1 && boolean?(value["enabled"])
74
+ id = value["id"]
75
+ return false unless id.is_a?(String) && id.bytesize.between?(1, 200) && !id.match?(/\p{Cc}/)
76
+ issued = parse_time(value["issued_at"])
77
+ expires = parse_time(value["expires_at"])
78
+ now = @clock.call
79
+ return false unless issued && expires && issued <= now + 300 && expires > now && expires > issued && expires - issued <= MAX_POLICY_TTL
80
+ return false if @policy && issued < @policy[:issued_at]
81
+ rate = value["default_rate"]
82
+ rows = value["endpoints"]
83
+ return false unless valid_rate?(rate) && rows.is_a?(Array) && rows.size <= MAX_ENDPOINTS
84
+ endpoints = {}
85
+ rows.each do |row|
86
+ return false unless row.is_a?(Hash) && row["name"].is_a?(String) && row["name"].bytesize.between?(1, 800) && row["name"].length <= 200 && !row["name"].match?(/\p{Cc}/) && valid_rate?(row["rate"])
87
+ return false if endpoints.key?(row["name"])
88
+ endpoints[row["name"]] = row["rate"].to_f
89
+ end
90
+ @policy = { id: id, issued_at: issued, expires_at: expires, deadline: @monotonic.call + [ expires - now, MAX_POLICY_TTL ].min,
91
+ enabled: value["enabled"], default_rate: rate.to_f, endpoints: endpoints }
92
+ true
93
+ rescue ArgumentError, EncodingError
94
+ false
95
+ end
96
+
97
+ def policy_due?
98
+ @activated && @monotonic.call >= @next_policy_at
99
+ end
100
+
101
+ def policy_requested!
102
+ @next_policy_at = @monotonic.call + POLICY_INTERVAL
103
+ end
104
+
105
+ def rotate(force: false, byte_budget:)
106
+ return unless supported?
107
+ now = @clock.call
108
+ while @reports.first && Time.iso8601(@reports.first.fetch("from")) < now - MAX_REPORT_AGE
109
+ @reports.shift
110
+ @stats[:reports_lost] += 1
111
+ @limited = true
112
+ end
113
+ return if @counts.empty? || (!force && @monotonic.call - @last_rotation < REPORT_INTERVAL)
114
+ if now < @from || now - @from > 86_400
115
+ @stats[:unreported] += @counts.values.sum { |entry| entry["observed"] }
116
+ @limited = true
117
+ else
118
+ rows = []
119
+ template = { "version" => 1, "limited" => @limited, "id" => SecureRandom.uuid, "from" => @from.iso8601(6), "to" => now.iso8601(6),
120
+ "enabled" => @config.automatic_sampling, "policy_id" => @policy_ids.size == 1 ? @policy_ids.first : "mixed", "endpoints" => rows }
121
+ @counts.each_value do |entry|
122
+ candidate = template.merge("endpoints" => rows + [ entry ])
123
+ if JSON.generate(candidate).bytesize > byte_budget
124
+ store_report(template.merge("endpoints" => rows)) if rows.any?
125
+ rows = []
126
+ template = template.merge("id" => SecureRandom.uuid)
127
+ end
128
+ if JSON.generate(template.merge("endpoints" => [ entry ])).bytesize <= byte_budget
129
+ rows << entry.dup
130
+ else
131
+ @stats[:unreported] += entry["observed"]
132
+ @limited = true
133
+ end
134
+ end
135
+ store_report(template.merge("endpoints" => rows)) if rows.any?
136
+ end
137
+ @counts, @policy_ids = {}, []
138
+ @from, @last_rotation = now, @monotonic.call
139
+ end
140
+
141
+ def report(force: false)
142
+ @reports.first if force || @monotonic.call >= @next_report_at
143
+ end
144
+
145
+ def report_sent!
146
+ @next_report_at = @monotonic.call + REPORT_INTERVAL
147
+ end
148
+
149
+ def acknowledge(id)
150
+ return unless id.is_a?(String) && id.match?(UUID)
151
+ removed = @reports.reject! { |report| report["id"] == id }
152
+ @next_report_at = 0 if removed && @reports.any?
153
+ end
154
+
155
+ def discard!
156
+ @stats[:reports_lost] += @reports.size
157
+ @stats[:unreported] += @counts.values.sum { |entry| entry["observed"] }
158
+ @reports, @counts = [], {}
159
+ end
160
+
161
+ def supported? = !@policy.nil?
162
+ def pending? = supported? && (@reports.any? || @counts.any?)
163
+ def stats = @stats.merge(limited: @limited, pending_reports: @reports.size, policy_state: effective_policy[1], policy_id: @policy&.fetch(:id))
164
+
165
+ private
166
+
167
+ def eligible?(event)
168
+ status, duration = event["status"], event["duration_ms"]
169
+ repeated = event.fetch("properties", {})["repeated_query_count"]
170
+ status.is_a?(String) && status.match?(/\A[23][0-9]{2}\z/) && duration.is_a?(Numeric) && duration.finite? && duration >= 0 && duration < 1000 && !(repeated.is_a?(Numeric) && repeated >= 3)
171
+ end
172
+
173
+ def effective_policy
174
+ return [ 1.0, "disabled", @policy&.fetch(:id) ] unless @config.automatic_sampling
175
+ return [ 1.0, "unavailable", nil ] unless @policy
176
+ return [ 1.0, "expired", @policy[:id] ] if @monotonic.call >= @policy[:deadline] || @clock.call >= @policy[:expires_at]
177
+ return [ 1.0, "disabled", @policy[:id] ] unless @policy[:enabled]
178
+ [ @policy[:default_rate], "active", @policy[:id] ]
179
+ end
180
+
181
+ def annotate(event, rate, state, policy_id)
182
+ event["properties"] = event.fetch("properties", {}).merge("rails_mind_sampling" => {
183
+ "version" => 1, "policy_id" => policy_id, "rate" => rate, "state" => state })
184
+ end
185
+
186
+ def store_report(report)
187
+ if @reports.size >= MAX_PENDING
188
+ @reports.shift
189
+ @stats[:reports_lost] += 1
190
+ @limited = true
191
+ end
192
+ report["limited"] ||= @limited
193
+ @reports << deep_freeze(JSON.parse(JSON.generate(report)))
194
+ end
195
+
196
+ def deep_freeze(value)
197
+ case value
198
+ when Hash then value.each { |key, child| deep_freeze(key); deep_freeze(child) }
199
+ when Array then value.each { |child| deep_freeze(child) }
200
+ end
201
+ value.freeze
202
+ end
203
+
204
+ def fraction(id) = Digest::SHA256.digest("rails_mind:sampling:v1:#{id.to_s.downcase}").unpack1("Q>") / (2.0**64)
205
+ def boolean?(value) = value == true || value == false
206
+ def valid_rate?(value) = (value.is_a?(Float) || value.is_a?(Integer)) && value.finite? && value.between?(0, 1)
207
+ def parse_time(value)
208
+ Time.iso8601(value) if value.is_a?(String) && value.bytesize <= 40
209
+ end
210
+ end
211
+ end
@@ -3,7 +3,8 @@ require "json"
3
3
 
4
4
  module RailsMind
5
5
  class Transport
6
- Result = Struct.new(:success, :retryable, :retry_after, keyword_init: true)
6
+ MAX_RESPONSE_BYTES = 64 * 1024
7
+ Result = Struct.new(:success, :retryable, :retry_after, :sampling_policy, :sampling_report_ack, keyword_init: true)
7
8
  TRANSIENT_ERRORS = [ Timeout::Error, IOError, EOFError, SocketError, Errno::ECONNRESET,
8
9
  Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE, OpenSSL::SSL::SSLError ].freeze
9
10
 
@@ -15,28 +16,65 @@ module RailsMind
15
16
  end
16
17
 
17
18
  def send_batch(json)
19
+ request = Net::HTTP::Post.new(@uri)
20
+ request.body = json
21
+ perform(request)
22
+ end
23
+
24
+ def fetch_sampling_policy
25
+ uri = @uri.dup
26
+ uri.path = uri.path.sub(%r{/events$}, "/sampling_policy")
27
+ perform(Net::HTTP::Get.new(uri))
28
+ end
29
+
30
+ private
31
+
32
+ def perform(request)
18
33
  Context.suppress do
19
- request = Net::HTTP::Post.new(@uri)
20
34
  request["Authorization"] = "Bearer #{@config.token}"
21
35
  request["Content-Type"] = "application/json"
22
36
  request["User-Agent"] = "rails_mind/#{VERSION}"
23
- request.body = json
37
+ request["X-RailsMind-Automatic-Sampling"] = @config.automatic_sampling ? "enabled" : "disabled"
24
38
  http = @http_factory.call(@uri)
25
39
  http.use_ssl = @uri.scheme == "https"
26
40
  http.open_timeout = @config.open_timeout
27
41
  http.read_timeout = @config.read_timeout
28
42
  http.write_timeout = @config.read_timeout
29
43
  http.max_retries = 0
30
- response = http.start { |connection| connection.request(request) }
44
+ body, streamed, oversized = +"", false, false
45
+ response = http.start do |connection|
46
+ connection.request(request) do |reply|
47
+ streamed = true
48
+ reply.read_body do |chunk|
49
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
50
+ oversized = true
51
+ elsif !oversized
52
+ body << chunk
53
+ end
54
+ end
55
+ end
56
+ end
57
+ body = response.body.to_s if !streamed && response.respond_to?(:body)
31
58
  code = response.code.to_i
59
+ control = if !oversized && body.bytesize <= MAX_RESPONSE_BYTES && ((200..299).cover?(code) || code == 429)
60
+ parse_control(body)
61
+ else
62
+ {}
63
+ end
32
64
  Result.new(success: (200..299).cover?(code), retryable: code == 429 || code >= 500,
33
- retry_after: retry_after(response["Retry-After"]))
65
+ retry_after: retry_after(response["Retry-After"]), sampling_policy: control["sampling_policy"],
66
+ sampling_report_ack: control["sampling_report_ack"])
34
67
  end
35
68
  rescue *TRANSIENT_ERRORS
36
69
  Result.new(success: false, retryable: true)
37
70
  end
38
71
 
39
- private
72
+ def parse_control(body)
73
+ value = JSON.parse(body)
74
+ value.is_a?(Hash) ? value : {}
75
+ rescue JSON::ParserError, EncodingError
76
+ {}
77
+ end
40
78
 
41
79
  def retry_after(value)
42
80
  return nil unless value
@@ -1,3 +1,3 @@
1
1
  module RailsMind
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  end
data/lib/rails_mind.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "rails_mind/configuration"
3
3
  require_relative "rails_mind/context"
4
4
  require_relative "rails_mind/redactor"
5
5
  require_relative "rails_mind/transport"
6
+ require_relative "rails_mind/sampling"
6
7
  require_relative "rails_mind/collector"
7
8
  require_relative "rails_mind/client"
8
9
 
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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - RailsMind
@@ -81,6 +81,7 @@ files:
81
81
  - lib/rails_mind/integrations/rails.rb
82
82
  - lib/rails_mind/railtie.rb
83
83
  - lib/rails_mind/redactor.rb
84
+ - lib/rails_mind/sampling.rb
84
85
  - lib/rails_mind/transport.rb
85
86
  - lib/rails_mind/version.rb
86
87
  - lib/tasks/rails_mind.rake