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 +7 -0
- data/LICENSE +21 -0
- data/README.md +38 -0
- data/lib/crumbtrail/active_record.rb +88 -0
- data/lib/crumbtrail/body.rb +109 -0
- data/lib/crumbtrail/event.rb +23 -0
- data/lib/crumbtrail/sender.rb +191 -0
- data/lib/crumbtrail.rb +282 -0
- metadata +69 -0
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: []
|