rails_mind 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +3 -3
- data/docs/compatibility.md +16 -0
- data/docs/sdk.md +75 -8
- data/lib/generators/rails_mind/install/templates/initializer.rb.tt +3 -0
- data/lib/rails_mind/collector.rb +72 -26
- data/lib/rails_mind/configuration.rb +3 -1
- data/lib/rails_mind/diagnostic.rb +3 -2
- data/lib/rails_mind/sampling.rb +211 -0
- data/lib/rails_mind/transport.rb +44 -6
- data/lib/rails_mind/version.rb +1 -1
- data/lib/rails_mind.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 819a570e3f4f65e0fb8bae7de68b1a81ee744bccb77349858c640fcc912e95b4
|
|
4
|
+
data.tar.gz: 62df4fca70603e28458d277dcd8d734a5de04af7c514f997e15686122a02dbd2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3583084684b6ac4b165864c14fbfe3922fc2f678c8d7aa9383418d95fdc528083bbe2327e5428d167d80e54a28445309eb6243bd3c6c3dac36fc8a67fa9e0630
|
|
7
|
+
data.tar.gz: e968233e4025ce470a83207214ef886032bff662faea00f824f3aceefad55319aa6d3235b62cc16368c5b8ccec7db86a6914ce1d5cb1a5db2c9bb282a543f0f2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-10
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- Separate known local drops from unconfirmed delivery; failed responses may follow partial server acceptance.
|
|
9
|
+
|
|
3
10
|
## 0.2.0 — 2026-10-09
|
|
4
11
|
|
|
5
12
|
- 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.
|
|
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.
|
|
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/
|
|
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.
|
data/docs/compatibility.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
183
|
+
Request measurements and error reports come independently from Rails notifications/error reporting, even when detailed traces are sampled out. Routine requests separately follow the automatic endpoint sampling policy described below. Request measurements cover controller actions, not assets/health checks rejected before ActionController or streaming-body completion. Repeated-query evidence contains only normalized-query hashes and counts; it is a suspicion, not proof of an N+1. Hash normalization handles common literals, not every SQL grammar. Use Bullet/Prosopite or an explicit query-bound test in development to verify fixes. No production object scans are installed.
|
|
184
184
|
|
|
185
|
-
Full metrics/logs SDK integration is deferred
|
|
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,16 +315,18 @@ 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
|
|
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.
|
|
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
|
|
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
331
|
Redaction occurs before buffering and again after custom `before_send`. Sensitive property names are filtered, nesting/string/collection sizes are capped, emails and common credential forms are scrubbed, exception messages are omitted by default, and backtraces drop machine-home prefixes. Bodies, cookies, credentials, SQL binds, and job arguments are never selected by integrations. Arbitrary user-provided text may contain sensitive content that generic redaction cannot recognize: use small, explicitly reviewed business properties. Opting into exception messages requires reviewing `capture_exception_message` and your custom scrubbing policy.
|
|
265
332
|
|
|
@@ -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.
|
data/lib/rails_mind/collector.rb
CHANGED
|
@@ -7,17 +7,28 @@ module RailsMind
|
|
|
7
7
|
|
|
8
8
|
def push(event)
|
|
9
9
|
check_process
|
|
10
|
-
|
|
10
|
+
event = event.transform_keys(&:to_s)
|
|
11
11
|
@mutex.synchronize do
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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[
|
|
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|
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
due = @sampling.policy_due? && @transport.respond_to?(:fetch_sampling_policy)
|
|
115
|
+
@sampling.policy_requested! if due
|
|
116
|
+
due
|
|
101
117
|
end
|
|
102
|
-
|
|
103
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
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
|
|
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, :automatic_sampling
|
|
13
13
|
|
|
14
14
|
def initialize(env: ENV, root: nil)
|
|
15
15
|
@endpoint = nonblank(env["RAILS_MIND_ENDPOINT"]) || DEFAULT_ENDPOINT
|
|
@@ -27,12 +27,14 @@ module RailsMind
|
|
|
27
27
|
@read_timeout = 2.0
|
|
28
28
|
@shutdown_timeout = 2.0
|
|
29
29
|
@capture_exception_message = false
|
|
30
|
+
@automatic_sampling = true
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def enabled? = !token.to_s.strip.empty? && !endpoint.to_s.strip.empty?
|
|
33
34
|
|
|
34
35
|
def validate!
|
|
35
36
|
return self unless enabled?
|
|
37
|
+
raise ArgumentError, "automatic_sampling must be true or false" unless automatic_sampling == true || automatic_sampling == false
|
|
36
38
|
uri = URI(endpoint)
|
|
37
39
|
loopback = %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
38
40
|
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
|
|
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/
|
|
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." ]
|
|
@@ -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
|
data/lib/rails_mind/transport.rb
CHANGED
|
@@ -3,7 +3,8 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module RailsMind
|
|
5
5
|
class Transport
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/rails_mind/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
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
|