end_point_blank 0.6.0 → 0.12.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 +621 -0
- data/README.md +236 -19
- data/end_point_blank.gemspec +5 -3
- data/lib/end_point_blank/access_tokens.rb +244 -25
- data/lib/end_point_blank/authorization.rb +105 -20
- data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
- data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
- data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
- data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
- data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
- data/lib/end_point_blank/commands/http.rb +20 -1
- data/lib/end_point_blank/configuration.rb +111 -4
- data/lib/end_point_blank/configuration_error.rb +18 -0
- data/lib/end_point_blank/rails/authenticated.rb +62 -7
- data/lib/end_point_blank/rails/authorized.rb +9 -13
- data/lib/end_point_blank/target_url.rb +57 -0
- data/lib/end_point_blank/token_unavailable_error.rb +102 -0
- data/lib/end_point_blank/unauthorized_error.rb +81 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
- data/lib/end_point_blank/writers/direct_writer.rb +1 -1
- data/lib/end_point_blank/writers/exception_writer.rb +11 -2
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +1 -0
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank/writers/shared.rb +35 -4
- data/lib/end_point_blank.rb +248 -3
- metadata +15 -10
- data/lib/end_point_blank/loggers/logger.rb +0 -30
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "target_url"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
# Reopened with the same superclass in end_point_blank.rb; declared here too
|
|
7
|
+
# so this file can be required on its own.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Raised by {Authorization.header} when no access token can be obtained for
|
|
11
|
+
# an outbound call to a provider.
|
|
12
|
+
#
|
|
13
|
+
# There is deliberately no fallback. Before sc-1469 a failed mint silently
|
|
14
|
+
# produced "Basic base64(client_id:client_secret)" instead, which sent this
|
|
15
|
+
# service's own credential to whichever provider it was calling (and to
|
|
16
|
+
# that provider's intake). A client must never do that, so the call cannot
|
|
17
|
+
# be authorized and the caller has to decide what to do: retry, degrade, or
|
|
18
|
+
# fail its own request.
|
|
19
|
+
#
|
|
20
|
+
# `base_url` is the URL the token was wanted for with its userinfo, query
|
|
21
|
+
# and fragment removed ({TargetUrl.strip}), or nil when it could not be
|
|
22
|
+
# parsed. The raw value is never kept: the caller already has it, and a
|
|
23
|
+
# field on an exception reaches error reporting as surely as the message
|
|
24
|
+
# does.
|
|
25
|
+
#
|
|
26
|
+
# `failure` is the {AccessTokens::Failure} recorded for the mint, when one
|
|
27
|
+
# was recorded, so a handler can branch on `failure.outcome`
|
|
28
|
+
# (:credential_rejected, :request_rejected, :server_error,
|
|
29
|
+
# :transport_error) exactly as it would after calling
|
|
30
|
+
# {AccessTokens.last_failure} itself.
|
|
31
|
+
#
|
|
32
|
+
# A mint that raised is constructed with `unexpected: true` and reported as
|
|
33
|
+
# :transport_error, with the exception as `cause` (Ruby sets it when this
|
|
34
|
+
# is raised from the rescue). Its message is deliberately not copied into
|
|
35
|
+
# this one.
|
|
36
|
+
class TokenUnavailableError < Error
|
|
37
|
+
attr_reader :base_url, :failure
|
|
38
|
+
|
|
39
|
+
# @param base_url [String] the URL the token was wanted for
|
|
40
|
+
# @param failure [AccessTokens::Failure, nil] why the mint failed
|
|
41
|
+
# @param unexpected [Boolean] true when the mint raised rather than
|
|
42
|
+
# reporting a failure
|
|
43
|
+
def initialize(base_url, failure = nil, unexpected: false)
|
|
44
|
+
@base_url = TargetUrl.strip(base_url)
|
|
45
|
+
@failure = failure
|
|
46
|
+
@unexpected = unexpected
|
|
47
|
+
super(build_message)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true when the mint raised; `cause` holds what it raised
|
|
51
|
+
def unexpected?
|
|
52
|
+
@unexpected
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Symbol, nil] the failure outcome, or nil when none was recorded
|
|
56
|
+
def outcome
|
|
57
|
+
failure&.outcome
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Integer, nil] intake's HTTP status, or nil when it never answered
|
|
61
|
+
def status
|
|
62
|
+
failure&.status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
UNPARSEABLE_URL = "the requested URL (not shown: it could not be parsed)"
|
|
68
|
+
private_constant :UNPARSEABLE_URL
|
|
69
|
+
|
|
70
|
+
# One fixed text per outcome, the same in every EndPointBlank SDK. Never
|
|
71
|
+
# intake's response body (Failure#reason) or an exception message: the
|
|
72
|
+
# message is what reaches logs and error reporting, and neither is ours
|
|
73
|
+
# to vouch for. Failure#reason stays available on #failure.
|
|
74
|
+
def reason # rubocop:disable Metrics/MethodLength
|
|
75
|
+
# Before the outcome, because a mint that raised is also :transport_error.
|
|
76
|
+
return "the token request failed unexpectedly" if unexpected?
|
|
77
|
+
|
|
78
|
+
http = status ? " (HTTP #{status})" : ""
|
|
79
|
+
|
|
80
|
+
case outcome
|
|
81
|
+
when :credential_rejected
|
|
82
|
+
"intake rejected this application's client credential#{http}; " \
|
|
83
|
+
"retrying cannot help -- re-issue the credential"
|
|
84
|
+
when :request_rejected
|
|
85
|
+
"intake refused the token request#{http}; check the URL and that a grant covers the target"
|
|
86
|
+
when :server_error
|
|
87
|
+
"intake failed to issue a token#{http}; this may be transient"
|
|
88
|
+
when :transport_error
|
|
89
|
+
"intake could not be reached (timeout, connection refused or retries exhausted); " \
|
|
90
|
+
"this may be transient"
|
|
91
|
+
else
|
|
92
|
+
"the token request failed for an unknown reason"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_message
|
|
97
|
+
"Could not mint an EndPointBlank access token for #{base_url || UNPARSEABLE_URL}: #{reason}. " \
|
|
98
|
+
"EndPointBlank never sends this service's client_id/client_secret to a provider, " \
|
|
99
|
+
"so there is no Basic-auth fallback and the call must not be made without a token."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -1,4 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
1
5
|
module EndPointBlank
|
|
6
|
+
# Raised when a request fails authentication or authorization.
|
|
7
|
+
#
|
|
8
|
+
# This error is intentionally not logged by the middleware, as unauthorized
|
|
9
|
+
# access attempts are expected to occur.
|
|
10
|
+
#
|
|
11
|
+
# `status` carries the status intake answered the authenticate or authorize
|
|
12
|
+
# call with, so a handler can tell the two refusals apart: 401 means the
|
|
13
|
+
# credential was not accepted and the integrator should check it, 403 means
|
|
14
|
+
# the credential was fine but no grant covers this endpoint and the
|
|
15
|
+
# integrator should ask for one. Collapsing both to 401 sends them to debug
|
|
16
|
+
# the wrong thing. The other SDKs each carry the same value under their own
|
|
17
|
+
# idiomatic name -- `statusCode` in JS, `getStatusCode()` in Java,
|
|
18
|
+
# `status_code` in Python; this is Ruby's.
|
|
19
|
+
#
|
|
20
|
+
# It defaults to 401 so that `UnauthorizedError.new(message)` keeps working
|
|
21
|
+
# unchanged, and because 401 is the safe reading of a refusal that arrives
|
|
22
|
+
# with no status attached. The concerns never lean on that default: they pass
|
|
23
|
+
# intake's status, or 503 when intake did not answer at all.
|
|
2
24
|
class UnauthorizedError < StandardError
|
|
3
25
|
attr_reader :status
|
|
4
26
|
|
|
@@ -6,5 +28,63 @@ module EndPointBlank
|
|
|
6
28
|
super(message)
|
|
7
29
|
@status = status
|
|
8
30
|
end
|
|
31
|
+
|
|
32
|
+
# Builds the error for a non-201 answer to intake's authenticate or
|
|
33
|
+
# authorize call. `action` is "Authentication" or "Authorization".
|
|
34
|
+
#
|
|
35
|
+
# One method rather than a copy per concern. `Rails::Authenticated` and
|
|
36
|
+
# `Rails::Authorized` were two transcriptions of one decision, and two
|
|
37
|
+
# copies is how one path acquires a fix the other does not. That is not
|
|
38
|
+
# hypothetical here: `authorize!` passed intake's status and read the body
|
|
39
|
+
# defensively, while `authenticate!` used `raise UnauthorizedError,
|
|
40
|
+
# "message"` -- the two-argument `raise Class, message` form, which
|
|
41
|
+
# structurally cannot pass a status however willing this class is to accept
|
|
42
|
+
# one -- and parsed the body before it had checked there was a body to
|
|
43
|
+
# parse. Both are fixed here, once.
|
|
44
|
+
#
|
|
45
|
+
# @param result [#status, #body, nil] intake's answer, or nil if it did not
|
|
46
|
+
# answer at all.
|
|
47
|
+
# @param action [String] the word naming what was attempted.
|
|
48
|
+
# @return [UnauthorizedError]
|
|
49
|
+
def self.refusal_from(result, action)
|
|
50
|
+
if result.nil?
|
|
51
|
+
# Intake never answered at all, so nothing refused this caller and 401
|
|
52
|
+
# would blame a credential that was never judged. 503 says the true
|
|
53
|
+
# thing -- the check could not be made -- and is what the other four
|
|
54
|
+
# SDKs already send for this same case: JS and Java
|
|
55
|
+
# `response ? response.status : 503`, Python's `refusal_from`, and
|
|
56
|
+
# Elixir's literal `send_resp(503, ...)`. It is also the one case the
|
|
57
|
+
# "service unavailable" wording is actually true for.
|
|
58
|
+
#
|
|
59
|
+
# The wording deliberately has no "#{action} failed:" prefix, because
|
|
60
|
+
# that is what `authorize!` has always sent for this case and this
|
|
61
|
+
# method must not change what the working path produces for any input.
|
|
62
|
+
# The other SDKs prefix it; the status, which is what a handler
|
|
63
|
+
# branches on, agrees with them.
|
|
64
|
+
return new("#{action} service unavailable", 503)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Intake's verdict verbatim. 401 tells an integrator to check the
|
|
68
|
+
# credential, 403 tells them to ask for a grant.
|
|
69
|
+
new("#{action} failed: #{reason_from(result)}", result.status)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# intake sends `{"error": "..."}`, but the SDK reaches it through a proxy
|
|
73
|
+
# and a WAF or load balancer can answer with an HTML page intake never
|
|
74
|
+
# generated. An unparseable body is therefore an expected case, not a
|
|
75
|
+
# swallowed failure: it falls back to the raw body so the operator still
|
|
76
|
+
# sees exactly what came back rather than an empty reason.
|
|
77
|
+
def self.reason_from(result)
|
|
78
|
+
parsed =
|
|
79
|
+
begin
|
|
80
|
+
JSON.parse(result.body)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
detail = parsed.is_a?(Hash) ? parsed["error"] : nil
|
|
86
|
+
detail || result.body
|
|
87
|
+
end
|
|
88
|
+
private_class_method :reason_from
|
|
9
89
|
end
|
|
10
|
-
end
|
|
90
|
+
end
|
|
@@ -16,14 +16,46 @@ module EndPointBlank
|
|
|
16
16
|
module DelayedWriter
|
|
17
17
|
MAX_QUEUE_SIZE = 1000
|
|
18
18
|
WARN_THROTTLE_SECONDS = 30
|
|
19
|
+
# Payloads per outbound request. One request per payload would multiply
|
|
20
|
+
# the host application's outbound traffic by its own request rate; the
|
|
21
|
+
# drain exists to amortise that.
|
|
22
|
+
BATCH_SIZE = 6
|
|
19
23
|
# Fallback thread count used when Configuration#worker_count is unset,
|
|
20
24
|
# preserving the previously-hardcoded pool size.
|
|
21
25
|
DEFAULT_WORKER_COUNT = 2
|
|
26
|
+
# Applied after a worker iteration raises, doubling with each consecutive
|
|
27
|
+
# failure up to the cap. Surviving a defect must not mean retrying as fast
|
|
28
|
+
# as the CPU allows: a persistent failure should read as a slow, loud
|
|
29
|
+
# retry, not as a silent hot loop.
|
|
30
|
+
WORKER_BACKOFF_SECONDS = 0.1
|
|
31
|
+
MAX_WORKER_BACKOFF_SECONDS = 30
|
|
32
|
+
|
|
33
|
+
# sc-376: eager, so `queue`/`enqueue_mutex` are never nil for any
|
|
34
|
+
# worker thread. Every real includer (RequestWriter, ResponseWriter,
|
|
35
|
+
# ExceptionWriter, LogWriter) calls `super()` here before
|
|
36
|
+
# `start_threads`, so this always runs first, single-threaded, before
|
|
37
|
+
# any worker exists. An includer whose `initialize` skips `super`
|
|
38
|
+
# (some of this module's own specs, deliberately) never reaches this;
|
|
39
|
+
# `queue`/`enqueue_mutex` fall back to lazy `||=` for those.
|
|
40
|
+
def initialize
|
|
41
|
+
@queue = Queue.new
|
|
42
|
+
@enqueue_mutex = Mutex.new
|
|
43
|
+
end
|
|
22
44
|
|
|
23
45
|
def direct_writer
|
|
24
46
|
@direct_writer ||= DirectWriter.new(url)
|
|
25
47
|
end
|
|
26
48
|
|
|
49
|
+
# `@queue ||= Queue.new` is a read, a nil check, then a write -- not
|
|
50
|
+
# atomic. Two threads racing this before either assigns could each
|
|
51
|
+
# allocate their own Queue, and the loser's is an orphan nothing will
|
|
52
|
+
# ever read from again. In production only this class's own worker
|
|
53
|
+
# threads could ever race here (every writer is a Singleton whose
|
|
54
|
+
# `initialize`, and so `super()` above, completes before any caller
|
|
55
|
+
# can reach `enqueue`), and `initialize` above now sets `@queue`
|
|
56
|
+
# before `start_threads` spawns a single one of them. The `||=` stays
|
|
57
|
+
# only as a fallback for an includer that skips this module's
|
|
58
|
+
# `initialize` entirely -- see the comment there.
|
|
27
59
|
def queue
|
|
28
60
|
@queue ||= Queue.new
|
|
29
61
|
end
|
|
@@ -36,27 +68,7 @@ module EndPointBlank
|
|
|
36
68
|
@threads = []
|
|
37
69
|
|
|
38
70
|
worker_count.times do
|
|
39
|
-
@threads << Thread.new
|
|
40
|
-
loop do
|
|
41
|
-
payload = queue.pop
|
|
42
|
-
payloads = [payload]
|
|
43
|
-
while (payload = pop_additional)
|
|
44
|
-
payloads << payload
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
payloads.compact!
|
|
48
|
-
while payloads.any?
|
|
49
|
-
list = payloads[0..5]
|
|
50
|
-
response = direct_writer.write(list)
|
|
51
|
-
if response.status < 299
|
|
52
|
-
on_success(response) if respond_to?(:on_success)
|
|
53
|
-
elsif respond_to?(:on_failure)
|
|
54
|
-
on_failure(response)
|
|
55
|
-
end
|
|
56
|
-
payloads -= list
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
71
|
+
@threads << Thread.new { run_worker }
|
|
60
72
|
end
|
|
61
73
|
end
|
|
62
74
|
|
|
@@ -78,8 +90,106 @@ module EndPointBlank
|
|
|
78
90
|
EndPointBlank.logger.warn(message)
|
|
79
91
|
end
|
|
80
92
|
|
|
93
|
+
# Logs a delivery or worker-loop error, through the same seam. Everything
|
|
94
|
+
# a worker recovers from goes through here: recovering quietly would trade
|
|
95
|
+
# one silent failure for another.
|
|
96
|
+
def log_error(message)
|
|
97
|
+
EndPointBlank.logger.error(message)
|
|
98
|
+
end
|
|
99
|
+
|
|
81
100
|
private
|
|
82
101
|
|
|
102
|
+
# The body of a worker thread.
|
|
103
|
+
#
|
|
104
|
+
# A thread that dies here takes every future payload in the process with
|
|
105
|
+
# it - silently, until a restart - which is far worse than losing the
|
|
106
|
+
# batch in flight. So the loop catches every StandardError, says what it
|
|
107
|
+
# caught, and carries on.
|
|
108
|
+
#
|
|
109
|
+
# What it deliberately does not catch is anything outside StandardError:
|
|
110
|
+
# SystemExit, Interrupt, SignalException, NoMemoryError. Those mean the
|
|
111
|
+
# process itself is going down or is already broken, and a telemetry
|
|
112
|
+
# worker has no business arguing with that.
|
|
113
|
+
def run_worker
|
|
114
|
+
consecutive_failures = 0
|
|
115
|
+
|
|
116
|
+
loop do
|
|
117
|
+
drain_once
|
|
118
|
+
consecutive_failures = 0
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
consecutive_failures += 1
|
|
121
|
+
note_worker_error(e, consecutive_failures)
|
|
122
|
+
sleep(worker_backoff(consecutive_failures))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Blocks for the next payload, then takes everything else already waiting
|
|
127
|
+
# so a burst leaves as a few batches rather than one request per payload.
|
|
128
|
+
#
|
|
129
|
+
# The batches are cut by position, and that is the point. This used to
|
|
130
|
+
# take a prefix and then remove it with `payloads -= list`, and Array#-
|
|
131
|
+
# removes every element *equal to* one in the batch rather than the ones
|
|
132
|
+
# actually sent: seven byte-identical payloads with a batch size of six
|
|
133
|
+
# meant six delivered and all seven gone. Nothing counted the loss and the
|
|
134
|
+
# queue drained normally, so it read as if nothing had happened.
|
|
135
|
+
#
|
|
136
|
+
# Equal payloads are ordinary, not exotic. Payloads are hashes, and two
|
|
137
|
+
# requests to the same endpoint from the same application in the same
|
|
138
|
+
# environment differ only in high-cardinality fields; `sent_at` at
|
|
139
|
+
# millisecond precision is not a reliable discriminator at ingest volumes.
|
|
140
|
+
# Slicing by index makes equality irrelevant rather than merely handled.
|
|
141
|
+
def drain_once
|
|
142
|
+
payloads = [queue.pop]
|
|
143
|
+
while (payload = pop_additional)
|
|
144
|
+
payloads << payload
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
payloads.compact!
|
|
148
|
+
payloads.each_slice(BATCH_SIZE) { |list| deliver_batch(list) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def deliver_batch(list)
|
|
152
|
+
response = direct_writer.write(list)
|
|
153
|
+
return note_unanswered(list) if response.nil?
|
|
154
|
+
|
|
155
|
+
if response.status < 299
|
|
156
|
+
on_success(response) if respond_to?(:on_success)
|
|
157
|
+
elsif respond_to?(:on_failure)
|
|
158
|
+
on_failure(response)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# nil is what Commands::Http returns once its retries are exhausted. It is
|
|
163
|
+
# not a status - it is the absence of an answer - so it is reported rather
|
|
164
|
+
# than compared: on_failure hears about it with nil, meaning "nothing
|
|
165
|
+
# answered", and the batch it cost us is named in the log either way.
|
|
166
|
+
def note_unanswered(list)
|
|
167
|
+
log_error(
|
|
168
|
+
"[EndPointBlank] no response from #{url} after retries; " \
|
|
169
|
+
"#{list.size} payload(s) in that batch are lost"
|
|
170
|
+
)
|
|
171
|
+
on_failure(nil) if respond_to?(:on_failure)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def note_worker_error(error, consecutive_failures)
|
|
175
|
+
log_error(
|
|
176
|
+
"[EndPointBlank] send worker recovered from #{error.class}: #{error.message} " \
|
|
177
|
+
"(consecutive failure #{consecutive_failures}); the batch in flight is lost, " \
|
|
178
|
+
"retrying in #{worker_backoff(consecutive_failures).round(1)}s at #{Array(error.backtrace).first}"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def worker_backoff(consecutive_failures)
|
|
183
|
+
[
|
|
184
|
+
WORKER_BACKOFF_SECONDS * (2**(consecutive_failures - 1)),
|
|
185
|
+
MAX_WORKER_BACKOFF_SECONDS
|
|
186
|
+
].min
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Same shape and same fallback rationale as `queue` above, and worse in
|
|
190
|
+
# kind if it ever raced: two threads synchronizing on *different* Mutex
|
|
191
|
+
# objects are not synchronized at all, so the critical section in
|
|
192
|
+
# `enqueue_one` would silently stop being one.
|
|
83
193
|
def enqueue_mutex
|
|
84
194
|
@enqueue_mutex ||= Mutex.new
|
|
85
195
|
end
|
|
@@ -12,6 +12,7 @@ module EndPointBlank
|
|
|
12
12
|
attr_reader :url
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
|
+
super()
|
|
15
16
|
@url = EndPointBlank::Configuration.instance.errors_url
|
|
16
17
|
start_threads
|
|
17
18
|
end
|
|
@@ -20,10 +21,18 @@ module EndPointBlank
|
|
|
20
21
|
instance.write(exception)
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Builds an error payload with or without an in-flight request. `env` is
|
|
25
|
+
# nil whenever an exception is reported from outside `EnvStore`'s scope
|
|
26
|
+
# (a background job, a worker, a boot-time failure) -- that is precisely
|
|
27
|
+
# the case this method exists to handle, not an edge case to special-case
|
|
28
|
+
# away. `Rack::Request.new(env)` was built unconditionally here even
|
|
29
|
+
# though nothing about an error report requires a request: version
|
|
30
|
+
# detection (Commands::VersionFinder) reads request params/path and is
|
|
31
|
+
# meaningless without one, so it is skipped rather than attempted and
|
|
32
|
+
# rescued. Its result was never even merged into the returned hash, so
|
|
33
|
+
# skipping it changes nothing for the in-request case either.
|
|
23
34
|
def payload(exception)
|
|
24
35
|
env = ::EndPointBlank::Rack::EnvStore.get
|
|
25
|
-
request = ::Rack::Request.new(env)
|
|
26
|
-
version = Commands::VersionFinder.new.find(request)
|
|
27
36
|
{
|
|
28
37
|
app_name: app_name,
|
|
29
38
|
uuid: request_uuid(env),
|
|
@@ -12,6 +12,7 @@ module EndPointBlank
|
|
|
12
12
|
attr_reader :url
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
|
+
super()
|
|
15
16
|
@url = EndPointBlank::Configuration.instance.logs_url
|
|
16
17
|
start_threads
|
|
17
18
|
end
|
|
@@ -61,7 +62,6 @@ module EndPointBlank
|
|
|
61
62
|
stamped_http_method: rack_req.request_method
|
|
62
63
|
)
|
|
63
64
|
end
|
|
64
|
-
puts "Writing log: #{json}"
|
|
65
65
|
enqueue(json)
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
1
3
|
module EndPointBlank
|
|
2
4
|
module Writers
|
|
3
5
|
module Shared
|
|
4
6
|
attr_accessor :url
|
|
5
7
|
|
|
8
|
+
# Where a minted request_uuid is cached on the Rack env, when the env
|
|
9
|
+
# carries no request id of its own. Distinct from
|
|
10
|
+
# "action_dispatch.request_id" so a later Rails middleware that sets
|
|
11
|
+
# the real key is never shadowed by ours -- we only ever read that key
|
|
12
|
+
# first and fall back to this one.
|
|
13
|
+
GENERATED_UUID_KEY = "end_point_blank.generated_request_uuid".freeze
|
|
14
|
+
|
|
6
15
|
def configuration
|
|
7
16
|
Configuration.instance
|
|
8
17
|
end
|
|
@@ -24,11 +33,33 @@ module EndPointBlank
|
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
# Rails' ActionDispatch::Request#uuid simply reads this same Rack env
|
|
27
|
-
# key, so
|
|
28
|
-
#
|
|
29
|
-
#
|
|
36
|
+
# key, so reading it directly is behavior-preserving when running under
|
|
37
|
+
# Rails, without requiring actionpack's ActionDispatch::Request to be
|
|
38
|
+
# loaded.
|
|
39
|
+
#
|
|
40
|
+
# Outside Rails (plain Rack / Sinatra) nothing sets that key. Falling
|
|
41
|
+
# through to nil there used to be described as "graceful", but it is
|
|
42
|
+
# not: intake requires `uuid` on every error/log/request/response row
|
|
43
|
+
# and refuses the ones that lack it, so a nil uuid is a silently
|
|
44
|
+
# dropped row, not a tolerated one. Minting a fresh uuid instead means
|
|
45
|
+
# the row still lands -- correlating with nothing is strictly better
|
|
46
|
+
# than not existing.
|
|
47
|
+
#
|
|
48
|
+
# The mint is cached back onto `env`, not just returned. RequestWriter
|
|
49
|
+
# and ResponseWriter both call this with the *same* env object for one
|
|
50
|
+
# HTTP request -- EnvStore hands back whatever `set/1` was given at the
|
|
51
|
+
# top of the middleware, unchanged, for the life of the request. Without
|
|
52
|
+
# caching, each writer's call under plain Rack would mint its own fresh
|
|
53
|
+
# uuid, and the request row and the response row for a single
|
|
54
|
+
# interaction would carry two different, unrelated ids -- silently
|
|
55
|
+
# defeating the one thing these columns exist for. When `env` is nil
|
|
56
|
+
# (an exception reported with no request in flight at all) there is no
|
|
57
|
+
# shared object to cache onto and nothing to correlate across, so a
|
|
58
|
+
# standalone mint every time is correct.
|
|
30
59
|
def request_uuid(env)
|
|
31
|
-
|
|
60
|
+
return SecureRandom.uuid unless env
|
|
61
|
+
|
|
62
|
+
env["action_dispatch.request_id"] || (env[GENERATED_UUID_KEY] ||= SecureRandom.uuid)
|
|
32
63
|
end
|
|
33
64
|
|
|
34
65
|
def apply_masking(payload, record_type)
|