crumbtrail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fc7d1bce77e6988e2adaf35ddd5767cad8d7730981d023e198b0a6c1c99dae4c
4
+ data.tar.gz: 9f3f63783428f5d08c5b2cfb7c42455a965d98f2aa84fe301608fd76a3519e0a
5
+ SHA512:
6
+ metadata.gz: 7484aa90ddacaeb4b52b455bcbe48ceb051705dc6aaf96b85d5414058809699dd6069963055df2f4526c4119ee39996d79587c1a19787c7c0106b7436cd0c236
7
+ data.tar.gz: 343f693391c31004a28af66519940a6c5fd9255ccff050cf621787cfb794d12838b20aa5398e0effe99d353fbc8db26aa2b9e3ebdd45597d215d4abfcfa840c8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Crumbtrail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # Capture Rack and Rails requests
2
+
3
+ Requires Ruby 3.2 or later and Rack 2 or 3. This package is source only until a RubyGems release is published. In a checkout, run `gem build crumbtrail.gemspec` and install the resulting gem. Applications can use a Bundler `path` dependency on this directory during local verification.
4
+
5
+ ```ruby
6
+ require 'crumbtrail'
7
+ sender = Crumbtrail::Sender.new(endpoint: ENV.fetch('CRUMBTRAIL_ENDPOINT'), key: ENV.fetch('CRUMBTRAIL_INGEST_KEY'), logger: Rails.logger)
8
+ # config.ru, after the application's normal requires
9
+ use Crumbtrail::Middleware, sink: sender, service: 'api', routes: ->(env) { env['PATH_INFO'].start_with?('/api/') && !env['PATH_INFO'].start_with?('/api/auth') }
10
+ at_exit { sender.close(timeout: 5) }
11
+ ```
12
+
13
+ For Rails, use the same keyword arguments with `config.middleware.use Crumbtrail::Middleware` in your application configuration. Initialize the sender inside each worker process after any application server fork.
14
+
15
+ Both valid `x-crumbtrail-session-id` and `x-crumbtrail-request-id` headers are required. The browser SDK must register that session. No routes are captured unless the `routes` predicate returns true. Upgrade requests and responses that execute or arrange Rack hijacking bypass capture. A server advertising hijack capability still captures ordinary requests. Query strings, headers and raw paths are never captured. The optional `route` callback can return an application configured template such as `/api/orders/:id`. Its default is `/`. Do not return a path containing actual parameter values.
16
+
17
+ JSON capture preserves bounded numeric operands, booleans, null, short enums and currency codes. Sensitive names and other strings are redacted, and a sensitive name redacts its whole subtree without walking it. A number is kept only when it stays inside the safe integer range, has at most six integer digits, and is not a valid card number. That cap is below the length of a phone number, a national identifier or an account number, so those are withheld even under an innocent field name. Strings are kept only as a short lowercase word, a three letter uppercase code, or at most six digits. The Ruby, Go and ASP.NET Core packages all run `test-fixtures/backend-body/cases.json`, so the three agree on every case in it. Bodies over 16 KiB are withheld with `truncated` state. Malformed, ambiguous, or structurally excessive JSON has `invalid` state. Non JSON or empty bodies have `missing` state. The request is observed as the application reads it. Unread bodies are missing. A nonempty request body is withheld as truncated unless it exactly matches a declared Content Length, or a full read or EOF proves completeness when no length was declared. Response chunks are forwarded unchanged and captured when Rack enumerates the body. Closing the body finalizes delivery once. Callable Rack streaming bodies receive the original stream unchanged and report missing response body evidence. Their request evidence and response status are still recorded. Enumerable response failures with partial bytes report truncated response evidence.
18
+
19
+ The queue holds 64 batches and refuses new batches when full. A refused batch is a hole in the session, so the request records a `buffer_overflow` capture gap naming how many events were lost. Each request holds at most 200 database events plus request boundary events and reports an event limit gap.
20
+
21
+ The optional `cert_store` accepts an OpenSSL certificate store for private trust roots and keeps certificate verification enabled. A sender uses HTTPS and does not follow redirects. It retries network errors, 429 and server errors up to four attempts. Any other status is permanent: repeating it cannot help, so the sender records a `delivery_failed` capture gap and writes one line to `logger`, or to `warn` when no logger was given. Without that line a revoked key looks exactly like a working SDK with an empty project.
22
+
23
+ The cloud can also answer 202 with `{"capture":"shed"}` and a retry window, which means it accepted the request and discarded the evidence. The sender pauses delivery for the window, counts what it drops, and sends one capture gap naming the shed reason once the window passes.
24
+
25
+ `close(timeout: 5)` waits at most five seconds for the queue to drain, then cancels the worker, and returns whether draining completed. A retry backoff cannot hold process exit open past the timeout the caller asked for. Abrupt process termination can still lose queued events.
26
+
27
+ ## Capture ActiveRecord commands
28
+
29
+ Requires ActiveSupport and ActiveRecord from the application. Install once during initialization:
30
+
31
+ ```ruby
32
+ require 'crumbtrail/active_record'
33
+ Crumbtrail::ActiveRecord.install(engine: 'postgres')
34
+ ```
35
+
36
+ Accepted engines are `postgres`, `mysql`, and `sqlite`. Installing twice with the same engine is a no op; installing a second, different engine raises `ArgumentError` rather than silently keeping the first. The adapter records operation, duration, statement sequence, reported row count, and exception class within captured requests, including queries during response enumeration. Statement order is the order the application issued statements in, so both the sequence and the timestamp are taken before the statement runs; `durationMs` recovers the completion time. Rails schema and query cache statements are ignored so they cannot spend the request's event budget. It deliberately withholds SQL, bindings and row values. It does not claim before/after snapshots, transaction correlation or database read evidence. `Crumbtrail::ActiveRecord.uninstall` removes the subscription.
37
+
38
+ Run `pnpm test:ruby` from the repository root, or `bundle exec ruby -Ilib -Itest test/capture_test.rb` here, with Rack, ActiveRecord, SQLite3 and Minitest installed. Set `CRUMBTRAIL_CAPTURE_CONTRACT_OUTPUT=/tmp/ruby-capture.json` to export the real Rack test's batches for consumer contract checks.
@@ -0,0 +1,88 @@
1
+ require 'crumbtrail'
2
+ require 'active_support/notifications'
3
+
4
+ module Crumbtrail
5
+ module ActiveRecord
6
+ # Query text and bindings are intentionally withheld. This works across SQL dialects
7
+ # without treating a partial SQL parser as a privacy boundary.
8
+ ENGINES = %w[postgres mysql sqlite].freeze
9
+ MUTEX = Mutex.new
10
+ # Rails issues schema reflection and query cache hits inside application requests. They are
11
+ # not statements the application asked for, and they spend the per request event budget the
12
+ # application's own statements need.
13
+ IGNORED_NAMES = %w[SCHEMA CACHE].freeze
14
+ MAX_PENDING = 1024
15
+ OPERATIONS = %w[SELECT INSERT UPDATE DELETE].freeze
16
+ OPERATION = /\A(?:SELECT|INSERT|UPDATE|DELETE|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i
17
+
18
+ # An evented subscriber, so the sequence and the timestamp are both taken when the statement
19
+ # is issued rather than when it completes. Stamping at completion sorts a slow query after
20
+ # faster ones issued after it, and leaves `seq` contradicting `t`.
21
+ class Subscriber
22
+ def initialize(engine)
23
+ @engine = engine
24
+ end
25
+
26
+ def start(_name, id, payload)
27
+ return if ignore?(payload)
28
+ context = Crumbtrail.current
29
+ return unless context
30
+ pending = (Thread.current[:crumbtrail_sql_pending] ||= {})
31
+ # A statement whose completion notification never arrives would otherwise retain its
32
+ # context for the life of the thread.
33
+ return if pending.size >= MAX_PENDING
34
+ pending[id] = [context, context.next_sequence, Crumbtrail.now, Process.clock_gettime(Process::CLOCK_MONOTONIC)]
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ def finish(_name, id, payload)
40
+ state = Thread.current[:crumbtrail_sql_pending]&.delete(id)
41
+ return unless state
42
+ context, sequence, at, started = state
43
+ sql = payload[:sql].to_s
44
+ sql = '' if sql.bytesize > 32_768
45
+ operation = sql.lstrip[OPERATION]&.upcase || 'OTHER'
46
+ data = { engine: @engine, op: OPERATIONS.include?(operation) ? operation.downcase : 'other', table: nil,
47
+ shape: '[statement omitted]',
48
+ rowCount: payload[:row_count].is_a?(Integer) ? payload[:row_count] : nil,
49
+ rowEvidence: 'not_captured',
50
+ durationMs: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000,
51
+ cached: !!payload[:cached] }
52
+ error = payload[:exception_object]
53
+ data.merge!(errorName: error.class.name, code: nil, category: 'unknown') if error
54
+ context.database(error ? 'db.error' : 'db.statement', data, sequence: sequence, time: at)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def ignore?(payload)
62
+ IGNORED_NAMES.include?(payload[:name].to_s) || !!payload[:cached]
63
+ end
64
+ end
65
+
66
+ def self.install(engine:)
67
+ raise ArgumentError, 'Unsupported database engine' unless ENGINES.include?(engine)
68
+ MUTEX.synchronize do
69
+ if @subscription
70
+ # A second install with a different engine used to be discarded in silence, and every
71
+ # event after it named the first engine.
72
+ raise ArgumentError, "Crumbtrail ActiveRecord capture is already installed for #{@engine}" unless @engine == engine
73
+ return @subscription
74
+ end
75
+ @engine = engine
76
+ @subscription = ActiveSupport::Notifications.subscribe('sql.active_record', Subscriber.new(engine))
77
+ end
78
+ end
79
+
80
+ def self.uninstall
81
+ MUTEX.synchronize do
82
+ ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
83
+ @subscription = nil
84
+ @engine = nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,109 @@
1
+ require 'json'
2
+
3
+ module Crumbtrail
4
+ # Structured body policy. Ruby, Go and the ASP.NET Core package implement the same rules and
5
+ # are driven by the same corpus in `test-fixtures/backend-body/cases.json`, so a divergence
6
+ # between them fails a test instead of quietly producing three different bodies.
7
+ module Body
8
+ LIMIT = 16_384
9
+ POLICY = 'crumbtrail.backend-redaction.v1'
10
+ MAX_NESTING = 8
11
+ MAX_KEYS = 64
12
+ MAX_ITEMS = 40
13
+ # Well below a phone number (10), a national insurance number (9) and a card (13 to 19).
14
+ MAX_INTEGER_DIGITS = 6
15
+ SAFE_INTEGER = 9_007_199_254_740_991
16
+ DENIED = /password|passwd|passphrase|passcode|secret|token|auth|card|cvv|cvc|ssn|email|phone|address|iban|account|birth|credential|creds|cookie|session|privatekey|apikey|accesskey|securitycode|verificationcode|connection|routingnumber|taxid|nationalid|sortcode|name|postal|payload|beforejson|afterjson|mobile|contact|diagnosis|medical|patient|prescription|gender|ethnic|religion|salary|income|identifier|username|passport|insurance|beneficiary|guardian|occupation|citizen|latitude|longitude|coordinate|geolocation|province|country|street/i
17
+ # Short words that appear inside innocent identifiers ("capacity" contains "city"), so they
18
+ # are matched as whole words rather than as substrings.
19
+ DENIED_WORD = /\A(?:pwd|pin|pan|otp|pass|sid|dob|zip|jwt|mfa|csrf|xsrf|city|town|geo|cell|race|sex|age|location|lat|lng|lon|gps)s?[0-9]*\z/i
20
+ SAFE_STRING = /\A(?:[a-z][a-z_]{0,22}|[A-Z]{3}|[0-9]{1,#{MAX_INTEGER_DIGITS}})\z/
21
+ TOKEN_STRING = /(?:sk|pk|rk|ghp|gho|ghu|ghs|glpat|xox[baprs])[-_][a-zA-Z0-9_.=-]{12,}/i
22
+ FIELD_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
23
+ REDACTED = '[REDACTED]'.freeze
24
+
25
+ class Invalid < StandardError; end
26
+
27
+ def self.capture(bytes, truncated = false)
28
+ return [nil, 'truncated'] if truncated || bytes.bytesize > LIMIT
29
+ return [nil, 'missing'] if bytes.empty?
30
+ removed = [false]
31
+ value = JSON.parse(bytes, max_nesting: MAX_NESTING, allow_duplicate_key: false)
32
+ value = walk(value, '', removed)
33
+ encoded = JSON.generate(value)
34
+ return [nil, 'truncated'] if encoded.bytesize > LIMIT
35
+ [encoded, removed[0] ? 'redacted' : 'captured']
36
+ rescue JSON::ParserError, JSON::GeneratorError, JSON::NestingError, Invalid, EncodingError
37
+ [nil, 'invalid']
38
+ end
39
+
40
+ # A sensitive key is redacted whole. The subtree under it is never walked, so a malformed
41
+ # or oversized value inside a secret cannot turn the whole body into `invalid` and cannot
42
+ # be inspected on the way to being dropped.
43
+ def self.walk(value, key, removed)
44
+ unless sensitive?(key)
45
+ case value
46
+ when Hash
47
+ raise Invalid if value.size > MAX_KEYS
48
+ return value.to_h do |k, v|
49
+ raise Invalid unless k.bytesize <= 64 && k.match?(FIELD_NAME)
50
+ [k, walk(v, k, removed)]
51
+ end
52
+ when Array
53
+ raise Invalid if value.size > MAX_ITEMS
54
+ return value.map { |v| walk(v, key, removed) }
55
+ when Numeric
56
+ return value if number?(value)
57
+ when TrueClass, FalseClass, NilClass
58
+ return value
59
+ when String
60
+ return value if value.match?(SAFE_STRING) && !value.match?(TOKEN_STRING)
61
+ end
62
+ end
63
+ removed[0] = true
64
+ REDACTED
65
+ end
66
+
67
+ def self.sensitive?(key)
68
+ words = key.gsub(/([a-z0-9])([A-Z])/, '\1 \2').split(/[^a-zA-Z0-9]+/)
69
+ key.gsub(/[^a-zA-Z0-9]/, '').match?(DENIED) || words.any? { |word| word.match?(DENIED_WORD) }
70
+ end
71
+
72
+ # The integer digit cap already excludes every card length. Luhn stays because it also
73
+ # catches a card smuggled across a decimal point, where the integer part is short.
74
+ def self.number?(value)
75
+ return false unless value.finite?
76
+ return false if value.abs > SAFE_INTEGER
77
+ return false if value.to_i.abs.to_s.length > MAX_INTEGER_DIGITS
78
+ !luhn?(digits(value))
79
+ end
80
+
81
+ def self.digits(value)
82
+ value.abs.to_s.gsub(/[^0-9]/, '')
83
+ end
84
+
85
+ def self.luhn?(digits)
86
+ return false unless digits.length.between?(13, 19)
87
+ sum = 0
88
+ twice = false
89
+ digits.reverse.each_char do |char|
90
+ n = char.ord - 48
91
+ if twice
92
+ n *= 2
93
+ n -= 9 if n > 9
94
+ end
95
+ sum += n
96
+ twice = !twice
97
+ end
98
+ (sum % 10).zero?
99
+ end
100
+
101
+ def self.json?(type)
102
+ type.to_s.split(';', 2).first.to_s.strip.match?(%r{\Aapplication/(?:json|[^/;\s]+\+json)\z}i)
103
+ end
104
+
105
+ def self.metadata(field, state)
106
+ { policy: POLICY, fields: state == 'redacted' ? [{ path: field, reason: 'backend_structured_profile', action: 'redacted' }] : [] }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,23 @@
1
+ module Crumbtrail
2
+ VERSION = '0.1.0'
3
+ SCHEMA_VERSION = 1
4
+ PLATFORM = 'ruby'
5
+ SDK = { name: 'crumbtrail-ruby', version: VERSION }.freeze
6
+
7
+ def self.now
8
+ (Time.now.to_f * 1000).to_i
9
+ end
10
+
11
+ # Wire envelope for every event. `schemaVersion`, `platform` and `sdk` are required of every
12
+ # SDK that is not built on crumbtrail-core: without them ingest defaults the event to
13
+ # `platform: "web"` and no reader can tell a Rails request apart from a browser one.
14
+ # `capabilities` and `target` are the remaining envelope fields. This SDK never populates
15
+ # them, and they are accepted here so the wire contract fixtures can be exercised through
16
+ # the real serializer rather than a copy of it.
17
+ def self.event(time, kind, data, platform: PLATFORM, sdk: SDK, capabilities: nil, target: nil)
18
+ event = { t: time, k: kind, d: data, schemaVersion: SCHEMA_VERSION, platform: platform, sdk: sdk }
19
+ event[:capabilities] = capabilities if capabilities && !capabilities.empty?
20
+ event[:target] = target if target && !target.empty?
21
+ event
22
+ end
23
+ end
@@ -0,0 +1,191 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require_relative 'event'
5
+
6
+ module Crumbtrail
7
+ class Sender
8
+ # 429 and 5xx are the only answers worth repeating the identical batch into. A 404 names a
9
+ # session the cloud does not have, and every later batch for that id is refused the same
10
+ # way, so retrying it four times only delays the gap that says the evidence is gone.
11
+ RETRYABLE = [429].freeze
12
+ # Shed reasons authored by the capture edge. An unrecognised reason is recorded as a plain
13
+ # delivery failure rather than passed through as an invented classification.
14
+ SHED_REASONS = %w[kill_switch sessions_per_hour bytes_per_day rate_limited_ingest
15
+ rate_limited_session_start trial_expired payment_failed upgrade_required].freeze
16
+ EVENTS_PATH = '/api/events'.freeze
17
+ ATTEMPTS = 4
18
+ MAX_SHED_SECONDS = 300
19
+ MAX_RESPONSE_BYTES = 4096
20
+
21
+ def initialize(endpoint:, key:, cert_store: nil, logger: nil)
22
+ @endpoint = URI(endpoint)
23
+ raise ArgumentError, 'Crumbtrail requires HTTPS without credentials, query or fragment' unless @endpoint.is_a?(URI::HTTPS) && @endpoint.host && !@endpoint.userinfo && !@endpoint.query && !@endpoint.fragment
24
+ raise ArgumentError, 'Crumbtrail ingest key is required' if key.to_s.strip.empty? || key.match?(/[[:cntrl:]]/)
25
+ @key = key
26
+ @cert_store = cert_store
27
+ @logger = logger
28
+ @queue = SizedQueue.new(64)
29
+ @monitor = Mutex.new
30
+ @wake = ConditionVariable.new
31
+ @stopping = false
32
+ @shed_until = nil
33
+ @shed = nil
34
+ @worker = Thread.new do
35
+ while (batch = @queue.pop)
36
+ begin
37
+ deliver(batch[0], batch[1], batch[2])
38
+ rescue Exception
39
+ # A worker killed by anything other than a StandardError would leave `close` joining
40
+ # a dead thread and reporting a clean drain over an empty queue.
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ @worker.report_on_exception = false
46
+ end
47
+
48
+ def enqueue(batch)
49
+ session = (batch[:sessionId] || batch['sessionId']).to_s
50
+ events = batch[:events] || batch['events'] || []
51
+ @queue.push([session, JSON.generate(batch), events.size], true)
52
+ true
53
+ rescue ThreadError, ClosedQueueError, JSON::GeneratorError
54
+ false
55
+ end
56
+
57
+ # Closing the queue allows queued batches to drain within the caller's deadline. Past the
58
+ # deadline the worker is cancelled, so a retry backoff cannot hold process exit open past
59
+ # the timeout the caller asked for.
60
+ def close(timeout: 5)
61
+ @queue.close
62
+ return true if @worker.join(timeout)
63
+ cancel
64
+ @worker.join(1)
65
+ false
66
+ end
67
+
68
+ private
69
+
70
+ def cancel
71
+ @monitor.synchronize do
72
+ @stopping = true
73
+ @wake.broadcast
74
+ end
75
+ end
76
+
77
+ def stopping?
78
+ @monitor.synchronize { @stopping }
79
+ end
80
+
81
+ # Interruptible backoff. `sleep` here blocks process exit for the whole retry budget.
82
+ def pause(seconds)
83
+ @monitor.synchronize do
84
+ next if @stopping
85
+ @wake.wait(@monitor, seconds)
86
+ end
87
+ end
88
+
89
+ def deliver(session, payload, count, gap: false)
90
+ return if stopping?
91
+ if shedding?
92
+ @shed[:dropped] += count unless gap
93
+ return
94
+ end
95
+ flush_shed_gap unless gap
96
+ ATTEMPTS.times do |attempt|
97
+ return if stopping?
98
+ response = begin
99
+ post(payload)
100
+ rescue StandardError
101
+ nil # No response at all. A network failure is retried per the queue policy.
102
+ end
103
+ if response
104
+ code = response.code.to_i
105
+ if code == 202 && (directive = shed_directive(response))
106
+ begin_shed(session, directive, count) unless gap
107
+ return
108
+ end
109
+ return if code / 100 == 2
110
+ unless RETRYABLE.include?(code) || code >= 500
111
+ refuse(session, count, code) unless gap
112
+ return
113
+ end
114
+ end
115
+ break if attempt == ATTEMPTS - 1
116
+ pause(0.25 * (attempt + 1))
117
+ end
118
+ refuse(session, count, nil) unless gap
119
+ end
120
+
121
+ def post(payload)
122
+ uri = @endpoint.dup
123
+ uri.path = EVENTS_PATH
124
+ request = Net::HTTP::Post.new(uri)
125
+ request['Authorization'] = "Bearer #{@key}"
126
+ request['Content-Type'] = 'application/json'
127
+ request.body = payload
128
+ options = { use_ssl: true, open_timeout: 2, read_timeout: 3, write_timeout: 3 }
129
+ options[:cert_store] = @cert_store if @cert_store
130
+ Net::HTTP.start(uri.host, uri.port, **options) { |http| http.request(request) }
131
+ end
132
+
133
+ # A 202 passes any "is this a success" test while the cloud has already discarded the
134
+ # evidence. Reading the body is the only way to tell the two apart.
135
+ def shed_directive(response)
136
+ body = response.body.to_s
137
+ return nil if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
138
+ parsed = JSON.parse(body)
139
+ return nil unless parsed.is_a?(Hash) && parsed['capture'] == 'shed'
140
+ seconds = parsed['retryAfterSeconds']
141
+ seconds = response['Retry-After'].to_i unless seconds.is_a?(Numeric)
142
+ { reason: SHED_REASONS.include?(parsed['reason']) ? parsed['reason'] : 'delivery_failed',
143
+ seconds: seconds.to_f.clamp(0, MAX_SHED_SECONDS) }
144
+ rescue JSON::ParserError, TypeError
145
+ nil
146
+ end
147
+
148
+ def begin_shed(session, directive, count)
149
+ @shed = { session: session, reason: directive[:reason], dropped: count }
150
+ @monitor.synchronize { @shed_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + directive[:seconds] }
151
+ log("Crumbtrail capture is shed at the endpoint (#{directive[:reason]}); pausing delivery for #{directive[:seconds].round}s")
152
+ end
153
+
154
+ def shedding?
155
+ deadline = @monitor.synchronize { @shed_until }
156
+ !deadline.nil? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
157
+ end
158
+
159
+ # The gap for a shed window cannot be delivered during the window. It is held and sent on
160
+ # the first batch after Retry-After has passed.
161
+ def flush_shed_gap
162
+ return if @shed.nil? || shedding?
163
+ shed = @shed
164
+ @shed = nil
165
+ @monitor.synchronize { @shed_until = nil }
166
+ send_gap(shed[:session], shed[:reason], shed[:dropped], 'capture shed by the endpoint')
167
+ end
168
+
169
+ def refuse(session, count, code)
170
+ detail = code ? "HTTP #{code}" : 'retry budget exhausted'
171
+ log("Crumbtrail refused #{count} captured event(s): #{detail}")
172
+ send_gap(session, 'delivery_failed', count, detail)
173
+ end
174
+
175
+ def send_gap(session, reason, count, detail)
176
+ return if session.to_s.empty? || count.to_i <= 0
177
+ event = Crumbtrail.event(Crumbtrail.now, 'capture_gap',
178
+ { kind: 'capture_gap', surface: 'backend_request', reason: reason,
179
+ sessionId: session, droppedEventCount: count, detail: detail })
180
+ deliver(session, JSON.generate(sessionId: session, events: [event]), 1, gap: true)
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ def log(message)
186
+ @logger ? @logger.warn(message) : warn(message)
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ end
data/lib/crumbtrail.rb ADDED
@@ -0,0 +1,282 @@
1
+ require_relative 'crumbtrail/event'
2
+ require_relative 'crumbtrail/body'
3
+ require_relative 'crumbtrail/sender'
4
+
5
+ module Crumbtrail
6
+ EVENT_BUDGET = 200
7
+ BOUNDARY_KINDS = %w[backend.req.start backend.req.end backend.req.error].freeze
8
+
9
+ class Context
10
+ attr_reader :session_id, :request_id
11
+ def initialize(session_id, request_id, sink)
12
+ @session_id, @request_id, @sink = session_id, request_id, sink
13
+ @events = []
14
+ @dropped = 0
15
+ @sequence = 0
16
+ end
17
+ def add(kind, data, time = Crumbtrail.now)
18
+ if @events.size < EVENT_BUDGET || BOUNDARY_KINDS.include?(kind)
19
+ @events << Crumbtrail.event(time, kind, data.merge(requestId: @request_id, sessionId: @session_id))
20
+ else
21
+ @dropped += 1
22
+ end
23
+ end
24
+ # Statement order is the order the application issued statements in, so both the sequence
25
+ # and the timestamp are taken before the statement runs. Stamping either at completion
26
+ # sorts a slow query after faster ones issued after it, and makes `seq` contradict `t`.
27
+ def next_sequence
28
+ @sequence += 1
29
+ end
30
+ def database(kind, data, sequence: nil, time: nil)
31
+ time ||= Crumbtrail.now
32
+ add(kind, data.merge(seq: sequence || next_sequence, t: time), time)
33
+ end
34
+ def gap(reason, dropped, surface, detail = nil)
35
+ data = { kind: 'capture_gap', surface: surface, reason: reason, requestId: @request_id,
36
+ sessionId: @session_id, droppedEventCount: dropped }
37
+ data[:detail] = detail if detail
38
+ Crumbtrail.event(Crumbtrail.now, 'capture_gap', data)
39
+ end
40
+ def flush
41
+ @events << gap('scan_budget_exceeded', @dropped, 'backend_request') if @dropped > 0
42
+ @events.sort_by! { |event| [event[:t], event[:k] == 'backend.req.start' ? 0 : 1] }
43
+ refused = 0
44
+ @events.each_slice(20) do |events|
45
+ refused += events.size unless @sink.enqueue(sessionId: @session_id, events: events)
46
+ end
47
+ # A batch the sink refused is a hole in the session. Declaring it costs one event; leaving
48
+ # it implicit lets a burst drop `backend.req.end` and leaves a request that never
49
+ # terminated looking exactly like a request that never happened.
50
+ if refused > 0
51
+ @sink.enqueue(sessionId: @session_id, events: [gap('buffer_overflow', refused, 'queue', 'sink queue full')])
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ ensure
56
+ @events.clear
57
+ end
58
+ end
59
+
60
+ def self.current
61
+ Thread.current[:crumbtrail_context]
62
+ end
63
+ def self.with_context(context)
64
+ previous = current
65
+ Thread.current[:crumbtrail_context] = context
66
+ yield
67
+ ensure
68
+ Thread.current[:crumbtrail_context] = previous
69
+ end
70
+
71
+ class Input
72
+ attr_reader :bytes, :truncated, :complete
73
+ def initialize(io)
74
+ @io, @bytes, @truncated, @complete = io, ''.b, false, false
75
+ # Rack does not require the input stream to be rewindable, and application code branches
76
+ # on `respond_to?(:rewind)`. Advertising a method the wrapped stream does not have turns
77
+ # a working request into a NoMethodError this middleware introduced. `close` and `size`
78
+ # are delegated for the same reason: the wrapper must not remove capability from the
79
+ # object it replaces.
80
+ define_singleton_method(:rewind) { rewind! } if io.respond_to?(:rewind)
81
+ define_singleton_method(:close) { @io.close } if io.respond_to?(:close)
82
+ define_singleton_method(:size) { @io.size } if io.respond_to?(:size)
83
+ end
84
+ def keep(data)
85
+ return data unless data
86
+ remaining = Body::LIMIT - @bytes.bytesize
87
+ @bytes << data.b.byteslice(0, remaining)
88
+ @truncated ||= data.bytesize > remaining
89
+ data
90
+ end
91
+ def read(*args)
92
+ data = @io.read(*args)
93
+ @complete = true if args.empty? || args[0].nil? || data.nil? || data.bytesize < args[0]
94
+ keep(data)
95
+ end
96
+ def gets(*args)
97
+ data = @io.gets(*args)
98
+ @complete = true if data.nil?
99
+ keep(data)
100
+ end
101
+ def each
102
+ return enum_for(:each) unless block_given?
103
+ @io.each { |data| yield keep(data) }
104
+ @complete = true
105
+ end
106
+ private
107
+ def rewind!
108
+ result = @io.rewind
109
+ @bytes.clear
110
+ @truncated = false
111
+ @complete = false
112
+ result
113
+ end
114
+ end
115
+
116
+ class ResponseBody
117
+ def initialize(body, context, finish)
118
+ @body, @context, @finish = body, context, finish
119
+ @bytes, @truncated, @finished, @complete = ''.b, false, false, false
120
+ # Rack::Files answers `to_path` so the server can hand the descriptor to sendfile. Hiding
121
+ # it costs every static response its fast path. The bytes never pass through Ruby on that
122
+ # path, so the response body is honestly reported as missing rather than invented.
123
+ define_singleton_method(:to_path) { @body.to_path } if body.respond_to?(:to_path)
124
+ define_singleton_method(:to_ary) { buffered } if body.respond_to?(:to_ary)
125
+ end
126
+ def each
127
+ return enum_for(:each) unless block_given?
128
+ Crumbtrail.with_context(@context) do
129
+ begin
130
+ @body.each do |chunk|
131
+ keep(chunk)
132
+ yield chunk
133
+ end
134
+ @complete = true
135
+ rescue Exception => error
136
+ finish(error)
137
+ raise
138
+ ensure
139
+ finish
140
+ end
141
+ end
142
+ end
143
+ def close
144
+ @body.close if @body.respond_to?(:close)
145
+ ensure
146
+ finish
147
+ end
148
+ private
149
+ def keep(chunk)
150
+ remaining = Body::LIMIT - @bytes.bytesize
151
+ @bytes << chunk.b.byteslice(0, remaining)
152
+ @truncated ||= chunk.bytesize > remaining
153
+ end
154
+ def buffered
155
+ chunks = Crumbtrail.with_context(@context) { @body.to_ary }
156
+ chunks.each { |chunk| keep(chunk) }
157
+ @complete = true
158
+ chunks
159
+ ensure
160
+ finish
161
+ end
162
+ def finish(error = nil)
163
+ @truncated = true if (error || !@complete) && !@bytes.empty?
164
+ return if @finished
165
+ @finished = true
166
+ @finish.call(@bytes, @truncated, error)
167
+ rescue StandardError
168
+ nil
169
+ end
170
+ end
171
+
172
+ class CallableResponseBody
173
+ def initialize(body, context, finish)
174
+ @body, @context, @finish, @finished = body, context, finish, false
175
+ end
176
+ def call(stream)
177
+ Crumbtrail.with_context(@context) do
178
+ begin
179
+ result = @body.call(stream)
180
+ rescue Exception => error
181
+ finish('', false, error)
182
+ raise
183
+ end
184
+ finish('', false)
185
+ result
186
+ end
187
+ end
188
+ def close
189
+ @body.close if @body.respond_to?(:close)
190
+ ensure
191
+ finish('', false)
192
+ end
193
+ private
194
+ def finish(bytes, truncated, error = nil)
195
+ return if @finished
196
+ @finished = true
197
+ @finish.call(bytes, truncated, error, false)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+ end
202
+
203
+ class Middleware
204
+ ID = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
205
+ def initialize(app, sink:, service:, routes: nil, route: nil)
206
+ @app, @sink, @service, @routes, @route = app, sink, service, routes, route
207
+ end
208
+ def call(env)
209
+ eligible = begin
210
+ @routes && @routes.call(env) && env['HTTP_UPGRADE'].to_s.empty? && ID.match?(env['HTTP_X_CRUMBTRAIL_SESSION_ID'].to_s) && ID.match?(env['HTTP_X_CRUMBTRAIL_REQUEST_ID'].to_s)
211
+ rescue StandardError
212
+ false
213
+ end
214
+ return @app.call(env) unless eligible
215
+ context = Context.new(env['HTTP_X_CRUMBTRAIL_SESSION_ID'], env['HTTP_X_CRUMBTRAIL_REQUEST_ID'], @sink)
216
+ original = env['rack.input']
217
+ original_hijack = env['rack.hijack']
218
+ hijacked = false
219
+ if original_hijack.respond_to?(:call)
220
+ env['rack.hijack'] = lambda do |*args, &block|
221
+ result = original_hijack.call(*args, &block)
222
+ hijacked = true
223
+ result
224
+ end
225
+ end
226
+ input = Input.new(original) if original && Body.json?(env['CONTENT_TYPE'])
227
+ env['rack.input'] = input if input
228
+ started, started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC), Crumbtrail.now
229
+ base = { method: env['REQUEST_METHOD'], url: '/', route: '/', pathname: '/', service: @service,
230
+ correlation: { status: 'linked', sessionIdSource: 'header', requestIdSource: 'header' } }
231
+ status, headers, body = nil
232
+ finish = lambda do |bytes, truncated, error = nil, observed = true|
233
+ template = begin
234
+ value = @route&.call(env)
235
+ value.is_a?(String) && value.bytesize <= 512 && value.match?(%r{\A/[a-zA-Z0-9_{}:.* /-]*\z}) ? value : '/'
236
+ rescue StandardError
237
+ '/'
238
+ end
239
+ base.merge!(url: template, route: template, pathname: template)
240
+ length = env['CONTENT_LENGTH'].to_s
241
+ complete = input && (length.match?(/\A[0-9]+\z/) ? input.bytes.bytesize == length.to_i : input.complete)
242
+ request_body, request_state = input && !input.bytes.empty? ? Body.capture(input.bytes, input.truncated || !complete) : [nil, 'missing']
243
+ body_allowed = env['REQUEST_METHOD'] != 'HEAD' && status != 204 && status != 304 && !(status && status < 200)
244
+ content_length = (headers && (headers['content-length'] || headers['Content-Length'])).to_s
245
+ if observed && body_allowed && content_length.match?(/\A[0-9]+\z/)
246
+ truncated ||= bytes.bytesize != content_length.to_i
247
+ end
248
+ response_body, response_state = observed && body_allowed && Body.json?(headers && (headers['content-type'] || headers['Content-Type'])) ? Body.capture(bytes, truncated) : [nil, 'missing']
249
+ context.add('backend.req.start', base.merge(body: request_body, requestBodyState: request_state, redaction: Body.metadata('body', request_state)), started_at)
250
+ context.add('backend.req.error', base.merge(error: { name: error.class.name })) if error
251
+ context.add('backend.req.end', base.merge(statusCode: status || 500, durationMs: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000,
252
+ responseBody: response_body, responseBodyState: response_state, responseBodyTruncated: truncated, redaction: Body.metadata('responseBody', response_state)))
253
+ context.flush
254
+ ensure
255
+ env['rack.input'] = original
256
+ env['rack.hijack'] = original_hijack if original_hijack
257
+ end
258
+ begin
259
+ status, headers, body = Crumbtrail.with_context(context) { @app.call(env) }
260
+ rescue Exception => error
261
+ begin
262
+ if hijacked
263
+ env['rack.input'] = original
264
+ env['rack.hijack'] = original_hijack if original_hijack
265
+ else
266
+ finish.call('', false, error)
267
+ end
268
+ rescue StandardError
269
+ nil
270
+ end
271
+ raise
272
+ end
273
+ if hijacked || headers&.fetch('rack.hijack', nil)
274
+ env['rack.input'] = original
275
+ env['rack.hijack'] = original_hijack if original_hijack
276
+ return [status, headers, body]
277
+ end
278
+ wrapper = body.respond_to?(:each) ? ResponseBody : CallableResponseBody
279
+ [status, headers, wrapper.new(body, context, finish)]
280
+ end
281
+ end
282
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: crumbtrail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Crumbtrail
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.21.2
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 2.21.2
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
33
+ description:
34
+ email:
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - LICENSE
40
+ - README.md
41
+ - lib/crumbtrail.rb
42
+ - lib/crumbtrail/active_record.rb
43
+ - lib/crumbtrail/body.rb
44
+ - lib/crumbtrail/event.rb
45
+ - lib/crumbtrail/sender.rb
46
+ homepage: https://github.com/CrumbtrailDev/crumbtrail-cli
47
+ licenses:
48
+ - MIT
49
+ metadata: {}
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.2'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Correlated Rack request and ActiveRecord evidence for Crumbtrail
69
+ test_files: []