devbench 0.5.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/README.md +225 -0
- data/lib/adt.rb +5 -0
- data/lib/devbench/backtrace.rb +61 -0
- data/lib/devbench/config.rb +146 -0
- data/lib/devbench/current.rb +74 -0
- data/lib/devbench/direct.rb +470 -0
- data/lib/devbench/fingerprint.rb +180 -0
- data/lib/devbench/http.rb +34 -0
- data/lib/devbench/identity.rb +53 -0
- data/lib/devbench/middleware.rb +135 -0
- data/lib/devbench/rails_hooks.rb +126 -0
- data/lib/devbench/railtie.rb +107 -0
- data/lib/devbench/reporter.rb +247 -0
- data/lib/devbench/scrub.rb +154 -0
- data/lib/devbench/session.rb +99 -0
- data/lib/devbench/session_middleware.rb +73 -0
- data/lib/devbench/sidecar_transport.rb +95 -0
- data/lib/devbench/sidekiq_hooks.rb +208 -0
- data/lib/devbench/trace.rb +79 -0
- data/lib/devbench/transport.rb +181 -0
- data/lib/devbench/version.rb +10 -0
- data/lib/devbench.rb +37 -0
- metadata +75 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'fingerprint'
|
|
4
|
+
|
|
5
|
+
module Devbench
|
|
6
|
+
# A port of the sidecar's strict egress (internal/scrub and
|
|
7
|
+
# Sidecar.forEgress): what direct mode does to an exemplar before it leaves
|
|
8
|
+
# the process, so leaving out the sidecar does not loosen redaction
|
|
9
|
+
# (DECISIONS #160). Held to testdata/redaction_vectors.json
|
|
10
|
+
# (test/redaction_vectors_test.rb).
|
|
11
|
+
#
|
|
12
|
+
# Strict mode is the only mode here: template (values the format marks:
|
|
13
|
+
# quoted strings, numbers, durations), then scrub (values with a shape:
|
|
14
|
+
# emails, cards, tokens, credentials, IPs), then the prose heuristic
|
|
15
|
+
# (runs of capitalised words that are not framework vocabulary).
|
|
16
|
+
#
|
|
17
|
+
# Patterns are Go's, with Go's \b and \s (see Fingerprint).
|
|
18
|
+
module Scrub
|
|
19
|
+
WS = Fingerprint::GO_WS
|
|
20
|
+
NOT_WS = Fingerprint::GO_NOT_WS
|
|
21
|
+
|
|
22
|
+
def self.go_re(source, options = 0)
|
|
23
|
+
Regexp.new(source.gsub('\b', Fingerprint::GO_B), options)
|
|
24
|
+
end
|
|
25
|
+
private_class_method :go_re
|
|
26
|
+
|
|
27
|
+
# Ordered most-specific first, and the order is load-bearing: see the
|
|
28
|
+
# comments on internal/scrub's rules (a credential containing an email,
|
|
29
|
+
# a phone pattern eating an SSN).
|
|
30
|
+
RULES = [
|
|
31
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/, '<redacted:private-key>'],
|
|
32
|
+
[go_re('\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b'), '<redacted:jwt>'],
|
|
33
|
+
[go_re('([a-zA-Z][a-zA-Z0-9+.\-]*://)[^\t\n\f\r :/@]+:[^\t\n\f\r /@]+@'), '\1<redacted:credentials>@'],
|
|
34
|
+
[go_re("\\b(authorization|proxy-authorization)(#{WS}*[:=]#{WS}*)(?:#{NOT_WS}+[ \\t]+)?#{NOT_WS}+",
|
|
35
|
+
Regexp::IGNORECASE), '\1\2<redacted:authorization>'],
|
|
36
|
+
[go_re("\\b(bearer|basic)#{WS}+[A-Za-z0-9._\\-=/+]{8,}", Regexp::IGNORECASE), '\1 <redacted:token>'],
|
|
37
|
+
[go_re('\bAKIA[0-9A-Z]{16}\b'), '<redacted:aws-key>'],
|
|
38
|
+
[go_re('\b(password|passwd|pwd|secret|api[_\-]?key|access[_\-]?token|refresh[_\-]?token|' \
|
|
39
|
+
'client[_\-]?secret|private[_\-]?key|session[_\-]?id|csrf[_\-]?token)' \
|
|
40
|
+
"(#{WS}*[:=]#{WS}*)(\"[^\"]*\"|'[^']*'|#{NOT_WS}+)", Regexp::IGNORECASE), '\1\2<redacted:secret>'],
|
|
41
|
+
[go_re('\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<redacted:email>'],
|
|
42
|
+
[go_re('\b\d{3}-\d{2}-\d{4}\b'), '<redacted:ssn>'],
|
|
43
|
+
[go_re('\b(?:\d[ \-]*?){13,19}\b'), '<redacted:card>'],
|
|
44
|
+
[go_re('\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'), '<redacted:ip>'],
|
|
45
|
+
[/\+?\d[\d\t\n\f\r ().\-]{7,}\d/, '<redacted:phone>']
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# internal/sidecar/trace.go's traceRe.
|
|
49
|
+
TRACE = go_re('\bv1/([A-Za-z0-9_-]{1,64})/([A-Za-z0-9_-]{1,64})/(\d{1,3})\b')
|
|
50
|
+
|
|
51
|
+
# What Rails, Go and HTTP say, lowercased (internal/scrub/prose.go).
|
|
52
|
+
# Masking any of these would destroy diagnostic content.
|
|
53
|
+
VOCABULARY = %w[
|
|
54
|
+
ok created accepted content moved permanently found modified bad request
|
|
55
|
+
unauthorized payment required forbidden not method allowed acceptable
|
|
56
|
+
timeout conflict gone unsupported media type unprocessable entity too many
|
|
57
|
+
requests internal server error implemented gateway service unavailable no
|
|
58
|
+
see other temporary redirect
|
|
59
|
+
started completed processing rendered rendering redirected filter chain
|
|
60
|
+
halted parameters views load exists destroy update create transaction
|
|
61
|
+
rollback commit cache performed performing enqueued retrying rescued
|
|
62
|
+
validation failed invalid email has already been taken
|
|
63
|
+
panic goroutine runtime fatal context deadline exceeded canceled cancelled
|
|
64
|
+
connection refused such host timeout_ closed reset peer broken pipe
|
|
65
|
+
temporarily
|
|
66
|
+
warning warn info debug trace exception failure retry skipping unknown
|
|
67
|
+
missing expired denied true false nil null none
|
|
68
|
+
].to_h { |w| [w, true] }.freeze
|
|
69
|
+
|
|
70
|
+
FIELD = /[^#{Fingerprint::GO_SPACE[1..-2]}]+/o
|
|
71
|
+
TRAILING_PUNCT = /[.,;:!?)"']+\z/
|
|
72
|
+
CODE_CHARS = /[:#\/()_=<>@\[\]{}"']/
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# internal/scrub.Text: every shape rule, in order.
|
|
76
|
+
def text(line)
|
|
77
|
+
RULES.reduce(utf8(line)) { |out, (re, with)| out.gsub(re, with) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# internal/scrub.Prose: runs of two or more capitalised words that are
|
|
81
|
+
# not framework vocabulary become <redacted:name>.
|
|
82
|
+
def prose(line)
|
|
83
|
+
return line if line.empty?
|
|
84
|
+
|
|
85
|
+
fields = line.scan(FIELD)
|
|
86
|
+
return line if fields.length < 2
|
|
87
|
+
|
|
88
|
+
out = []
|
|
89
|
+
run = []
|
|
90
|
+
flush = lambda do
|
|
91
|
+
if run.length >= 2
|
|
92
|
+
out << '<redacted:name>'
|
|
93
|
+
elsif run.length == 1
|
|
94
|
+
out << run.first
|
|
95
|
+
end
|
|
96
|
+
run.clear
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
fields.each do |field|
|
|
100
|
+
if proper_noun?(field)
|
|
101
|
+
run << field
|
|
102
|
+
else
|
|
103
|
+
flush.call
|
|
104
|
+
out << field
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
flush.call
|
|
108
|
+
out.join(' ')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The sidecar's forEgress in strict mode, for one line: any trace kept
|
|
112
|
+
# as is, the rest templated, scrubbed and prose-masked.
|
|
113
|
+
def egress(line)
|
|
114
|
+
line = utf8(line)
|
|
115
|
+
match = TRACE.match(line)
|
|
116
|
+
body = match ? line.gsub(TRACE, '<trace>') : line
|
|
117
|
+
redacted = strict(body)
|
|
118
|
+
match ? "[#{match[0]}] #{redacted}" : redacted
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A bundle's template text: as the sidecar's answerEvidenceRequests
|
|
122
|
+
# does, strict redaction and then a second shape pass.
|
|
123
|
+
def template_text(text)
|
|
124
|
+
self.text(strict(utf8(text)))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def strict(body)
|
|
130
|
+
prose(text(Fingerprint.template(body)))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def proper_noun?(token)
|
|
134
|
+
trimmed = token.sub(TRAILING_PUNCT, '')
|
|
135
|
+
return false if trimmed.bytesize < 2
|
|
136
|
+
return false if CODE_CHARS.match?(trimmed)
|
|
137
|
+
return false if trimmed == trimmed.upcase
|
|
138
|
+
|
|
139
|
+
first = trimmed.getbyte(0)
|
|
140
|
+
return false if first < 65 || first > 90 # 'A'..'Z'
|
|
141
|
+
return false if trimmed.match?(/[0-9]/)
|
|
142
|
+
|
|
143
|
+
!VOCABULARY.key?(trimmed.downcase)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def utf8(text)
|
|
147
|
+
text = text.to_s
|
|
148
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
149
|
+
|
|
150
|
+
text.encode('UTF-8', invalid: :replace, undef: :replace).scrub
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# Session tokens prove a browser was served by the customer's real backend.
|
|
8
|
+
#
|
|
9
|
+
# The ingest key is public — it ships in a JavaScript bundle, so anyone can
|
|
10
|
+
# read it and post with it. A session token closes that gap without the
|
|
11
|
+
# customer writing anything: the Rack middleware they already install for
|
|
12
|
+
# trace propagation mints one and injects it into HTML responses.
|
|
13
|
+
#
|
|
14
|
+
# This must agree byte for byte with the Go SDK, because a token minted by a
|
|
15
|
+
# Rails app is verified by ingest. Divergence produces a request that succeeds
|
|
16
|
+
# locally and is rejected in production, with nothing to say why —
|
|
17
|
+
# testdata/session_vectors.json is what holds the two together.
|
|
18
|
+
module Session
|
|
19
|
+
HEADER = 'x-adt-session'
|
|
20
|
+
META_NAME = 'adt-session'
|
|
21
|
+
PREFIX = 'adts1'
|
|
22
|
+
|
|
23
|
+
# Deliberately long: the token asserts only "served by the real app", so a
|
|
24
|
+
# long life costs little, and a single-page application does full page loads
|
|
25
|
+
# rarely — a short TTL would expire mid-session and force a refresh endpoint,
|
|
26
|
+
# which is more surface in the customer's app for no security gain.
|
|
27
|
+
DEFAULT_TTL = 3600
|
|
28
|
+
MAX_SUBJECT = 128
|
|
29
|
+
|
|
30
|
+
class Expired < StandardError; end
|
|
31
|
+
class Invalid < StandardError; end
|
|
32
|
+
|
|
33
|
+
Token = Struct.new(:tenant, :subject, :expires)
|
|
34
|
+
|
|
35
|
+
# Mints a token. subject is optional — pass a stable user identifier for
|
|
36
|
+
# per-user attribution, or leave it nil.
|
|
37
|
+
def self.mint(secret, tenant, subject: nil, ttl: DEFAULT_TTL)
|
|
38
|
+
raise ArgumentError, 'secret is required' if secret.nil? || secret.strip.empty?
|
|
39
|
+
raise ArgumentError, 'tenant is required' if tenant.nil? || tenant.strip.empty?
|
|
40
|
+
|
|
41
|
+
subject = subject.to_s[0, MAX_SUBJECT]
|
|
42
|
+
ttl = DEFAULT_TTL if ttl.nil? || ttl <= 0
|
|
43
|
+
|
|
44
|
+
# The payload is pipe-delimited, so a pipe in a field would let a crafted
|
|
45
|
+
# subject forge a different tenant.
|
|
46
|
+
if tenant.include?('|') || subject.include?('|')
|
|
47
|
+
raise ArgumentError, "tenant and subject must not contain '|'"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
payload = "#{tenant}|#{Time.now.to_i + ttl}|#{subject}"
|
|
51
|
+
encoded = b64(payload)
|
|
52
|
+
|
|
53
|
+
"#{PREFIX}.#{encoded}.#{sign(secret, encoded)}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Verifies a token, returning a Token or raising Invalid/Expired.
|
|
57
|
+
def self.verify(secret, token)
|
|
58
|
+
raise Invalid if secret.nil? || secret.strip.empty?
|
|
59
|
+
raise Invalid unless token.is_a?(String)
|
|
60
|
+
|
|
61
|
+
parts = token.strip.split('.')
|
|
62
|
+
raise Invalid unless parts.length == 3 && parts[0] == PREFIX
|
|
63
|
+
|
|
64
|
+
# Constant time: a signature check that leaks timing can be brute-forced
|
|
65
|
+
# a byte at a time.
|
|
66
|
+
raise Invalid unless OpenSSL.secure_compare(parts[2], sign(secret, parts[1]))
|
|
67
|
+
|
|
68
|
+
raw = unb64(parts[1])
|
|
69
|
+
raise Invalid if raw.nil?
|
|
70
|
+
|
|
71
|
+
fields = raw.split('|', 3)
|
|
72
|
+
raise Invalid unless fields.length == 3
|
|
73
|
+
|
|
74
|
+
expires = Integer(fields[1], exception: false)
|
|
75
|
+
raise Invalid if expires.nil?
|
|
76
|
+
|
|
77
|
+
# Expiry is checked after the signature, so an unsigned token can never
|
|
78
|
+
# reveal whether a guessed expiry was plausible.
|
|
79
|
+
raise Expired if Time.now.to_i > expires
|
|
80
|
+
|
|
81
|
+
Token.new(fields[0], fields[2], Time.at(expires))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.sign(secret, encoded_payload)
|
|
85
|
+
b64(OpenSSL::HMAC.digest('SHA256', secret, encoded_payload))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Base64url without padding, matching Go's RawURLEncoding.
|
|
89
|
+
def self.b64(bytes)
|
|
90
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.unb64(text)
|
|
94
|
+
Base64.urlsafe_decode64(text)
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'session'
|
|
4
|
+
|
|
5
|
+
module Devbench
|
|
6
|
+
# Rack middleware that injects a session token into HTML responses.
|
|
7
|
+
#
|
|
8
|
+
# Automatic on purpose. The customer already installs Devbench::Middleware for
|
|
9
|
+
# trace propagation; making them also thread a token through every layout
|
|
10
|
+
# would be real work in their codebase for something that can be done here.
|
|
11
|
+
#
|
|
12
|
+
# config.middleware.insert_before 0, Devbench::SessionMiddleware,
|
|
13
|
+
# secret: ENV['ADT_SESSION_SECRET'], tenant: 'acme'
|
|
14
|
+
#
|
|
15
|
+
# Only HTML responses are touched. JSON, images, and downloads pass through.
|
|
16
|
+
class SessionMiddleware
|
|
17
|
+
def initialize(app, secret:, tenant:, ttl: Session::DEFAULT_TTL, subject: nil)
|
|
18
|
+
@app = app
|
|
19
|
+
@secret = secret
|
|
20
|
+
@tenant = tenant
|
|
21
|
+
@ttl = ttl
|
|
22
|
+
@subject = subject
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
status, headers, body = @app.call(env)
|
|
27
|
+
return [status, headers, body] if @secret.to_s.empty? || @tenant.to_s.empty?
|
|
28
|
+
return [status, headers, body] unless html?(headers)
|
|
29
|
+
|
|
30
|
+
token = begin
|
|
31
|
+
Session.mint(@secret, @tenant, subject: @subject&.call(env), ttl: @ttl)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
# Never fail a page over instrumentation.
|
|
34
|
+
return [status, headers, body]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rendered = +''
|
|
38
|
+
body.each { |chunk| rendered << chunk }
|
|
39
|
+
body.close if body.respond_to?(:close)
|
|
40
|
+
|
|
41
|
+
injected = inject(rendered, token)
|
|
42
|
+
|
|
43
|
+
# A stale Content-Length truncates the page in the browser.
|
|
44
|
+
headers = headers.dup
|
|
45
|
+
headers.delete('Content-Length')
|
|
46
|
+
headers.delete('content-length')
|
|
47
|
+
headers['Content-Length'] = injected.bytesize.to_s
|
|
48
|
+
|
|
49
|
+
[status, headers, [injected]]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def html?(headers)
|
|
55
|
+
value = headers.find { |k, _| k.to_s.downcase == 'content-type' }&.last
|
|
56
|
+
value.to_s.downcase.include?('text/html')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inject(document, token)
|
|
60
|
+
tag = %(<meta name="#{Session::META_NAME}" content="#{escape(token)}">)
|
|
61
|
+
|
|
62
|
+
idx = document =~ /<head[^>]*>/i
|
|
63
|
+
return document if idx.nil?
|
|
64
|
+
|
|
65
|
+
insert_at = idx + Regexp.last_match(0).length
|
|
66
|
+
document[0...insert_at] + tag + document[insert_at..]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def escape(text)
|
|
70
|
+
text.gsub('&', '&').gsub('"', '"').gsub('<', '<').gsub('>', '>')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# Sidecar mode: each report is one line of JSON on the local sidecar's
|
|
8
|
+
# unix socket (docs/SERVER_SDK_SPEC.md, "Control socket — v1"). Used when no
|
|
9
|
+
# DSN is set; unchanged from 0.4.
|
|
10
|
+
#
|
|
11
|
+
# Never blocks for long: this runs inside rescue blocks and exception
|
|
12
|
+
# paths in the customer's request, and a diagnostics call that can hang
|
|
13
|
+
# there would turn a failure into an outage.
|
|
14
|
+
module SidecarTransport
|
|
15
|
+
MAX_LINE = 256 * 1024
|
|
16
|
+
|
|
17
|
+
# The longest a report may hold up the request that produced it. A
|
|
18
|
+
# sidecar that has stopped reading costs this much, once per report, and
|
|
19
|
+
# never more.
|
|
20
|
+
WRITE_TIMEOUT = 0.2
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Connects, writes one line, closes, with a deadline on both. A plain
|
|
24
|
+
# blocking write would hang the request whenever the payload outgrows
|
|
25
|
+
# the socket buffer (8 KiB on macOS) and the sidecar is not reading.
|
|
26
|
+
def deliver(payload)
|
|
27
|
+
line = "#{JSON.generate(payload)}\n"
|
|
28
|
+
return if line.bytesize > MAX_LINE
|
|
29
|
+
|
|
30
|
+
socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM)
|
|
31
|
+
begin
|
|
32
|
+
write(socket, line)
|
|
33
|
+
ensure
|
|
34
|
+
socket.close
|
|
35
|
+
end
|
|
36
|
+
nil
|
|
37
|
+
rescue StandardError, SystemStackError
|
|
38
|
+
# No sidecar, a full socket buffer, a malformed payload — none of it is
|
|
39
|
+
# the application's problem. Losing a report is always preferable to
|
|
40
|
+
# affecting the request that produced it.
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Nothing is buffered here; the sidecar flushes on its own schedule.
|
|
45
|
+
def flush!(**)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def write(socket, line)
|
|
52
|
+
deadline = now + WRITE_TIMEOUT
|
|
53
|
+
address = Socket.sockaddr_un(Reporter.socket_path)
|
|
54
|
+
if socket.connect_nonblock(address, exception: false) == :wait_writable
|
|
55
|
+
return unless writable?(socket, deadline)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
socket.connect_nonblock(address)
|
|
59
|
+
rescue Errno::EISCONN
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
until line.empty?
|
|
65
|
+
written = socket.write_nonblock(line, exception: false)
|
|
66
|
+
if written == :wait_writable
|
|
67
|
+
return unless writable?(socket, deadline)
|
|
68
|
+
else
|
|
69
|
+
line = line.byteslice(written..)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def writable?(socket, deadline)
|
|
75
|
+
remaining = deadline - now
|
|
76
|
+
remaining.positive? && !IO.select(nil, [socket], nil, remaining).nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def now
|
|
80
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reporting is off: DEVBENCH_ENABLED=false, or a DSN that does not parse.
|
|
86
|
+
module NullTransport
|
|
87
|
+
def self.deliver(_payload)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.flush!(**)
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'trace'
|
|
4
|
+
require_relative 'current'
|
|
5
|
+
require_relative 'reporter'
|
|
6
|
+
|
|
7
|
+
module Devbench
|
|
8
|
+
# Native Sidekiq jobs (docs/SERVER_SDK_SPEC.md, Capability 2, `job`).
|
|
9
|
+
#
|
|
10
|
+
# A class that includes Sidekiq::Job never touches ActiveJob, so the
|
|
11
|
+
# perform.active_job hook sees none of its failures. This is the
|
|
12
|
+
# sentry-sidekiq equivalent:
|
|
13
|
+
#
|
|
14
|
+
# * ServerMiddleware — outermost in the server chain. Scopes the trace and
|
|
15
|
+
# identity to the job, reports what escapes #perform (or any middleware
|
|
16
|
+
# inside it), then re-raises the same object, so Sidekiq's retry logic
|
|
17
|
+
# sees exactly what it would have without it.
|
|
18
|
+
# * ClientMiddleware — copies the active trace into the job hash at
|
|
19
|
+
# enqueue, so a job's failure joins the request that enqueued it.
|
|
20
|
+
# * ERROR_HANDLER — Sidekiq's error_handlers also receive failures that
|
|
21
|
+
# never ran inside the middleware: fetch/Redis errors, a job class that
|
|
22
|
+
# will not constantize, a failing death handler. Job failures the
|
|
23
|
+
# middleware already sent reach it too, and are deduped by the
|
|
24
|
+
# Reporter's mark on the exception object.
|
|
25
|
+
#
|
|
26
|
+
# Plain Ruby: no Sidekiq constant is named at load time, so this file loads
|
|
27
|
+
# without Sidekiq. Devbench::Railtie installs it at boot when Sidekiq is loaded;
|
|
28
|
+
# a non-Rails Sidekiq process calls Devbench::SidekiqHooks.install itself.
|
|
29
|
+
#
|
|
30
|
+
# Every entry point runs inside Sidekiq's processor. Anything raised from
|
|
31
|
+
# here would fail, or worse un-acknowledge, a customer's job, so each one is
|
|
32
|
+
# fully guarded — and the job's own exception is always the one that
|
|
33
|
+
# propagates.
|
|
34
|
+
module SidekiqHooks
|
|
35
|
+
# The job-hash key the trace travels in. Sidekiq passes unknown top-level
|
|
36
|
+
# keys through untouched, including across retries.
|
|
37
|
+
TRACE_KEY = 'adt_trace'
|
|
38
|
+
|
|
39
|
+
# Exceptions whose cause chain is followed looking for a shutdown.
|
|
40
|
+
MAX_CAUSES = 10
|
|
41
|
+
|
|
42
|
+
# Wraps every job the server runs.
|
|
43
|
+
class ServerMiddleware
|
|
44
|
+
def call(_job_instance, job, _queue)
|
|
45
|
+
# Scoped even without a trace: identity set inside a job must end
|
|
46
|
+
# with it, and the next job on this thread must start with none.
|
|
47
|
+
Current.with(SidekiqHooks.trace_from(job)) do
|
|
48
|
+
yield
|
|
49
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
50
|
+
# Inside the scope, so the report carries this job's trace and
|
|
51
|
+
# whatever Devbench.set_user the job did. report never raises.
|
|
52
|
+
SidekiqHooks.report(e, job)
|
|
53
|
+
raise e
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runs on every push, in the web process and, for jobs enqueued by jobs,
|
|
59
|
+
# in the server.
|
|
60
|
+
class ClientMiddleware
|
|
61
|
+
def call(_job_class, job, _queue, _redis_pool)
|
|
62
|
+
SidekiqHooks.tag(job)
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Three parameters, the Sidekiq 7 signature; the third is optional so a
|
|
68
|
+
# caller passing two still works.
|
|
69
|
+
ERROR_HANDLER = proc do |error, context, _config = nil|
|
|
70
|
+
SidekiqHooks.handle_error(error, context)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
# Installs the hooks into Sidekiq's global configuration. Safe to call
|
|
75
|
+
# more than once. Returns true when Sidekiq is loaded and the hooks
|
|
76
|
+
# were registered with it.
|
|
77
|
+
#
|
|
78
|
+
# configure_server blocks run only in the Sidekiq process (and for
|
|
79
|
+
# embedded Sidekiq), configure_client blocks only outside it.
|
|
80
|
+
def install
|
|
81
|
+
return false unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
82
|
+
|
|
83
|
+
::Sidekiq.configure_server do |config|
|
|
84
|
+
install_server(config)
|
|
85
|
+
install_client(config)
|
|
86
|
+
end
|
|
87
|
+
::Sidekiq.configure_client { |config| install_client(config) }
|
|
88
|
+
true
|
|
89
|
+
rescue StandardError, SystemStackError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The server middleware, outermost so a failure in another middleware
|
|
94
|
+
# is seen too, and the error handler. Returns true when installed.
|
|
95
|
+
def install_server(config)
|
|
96
|
+
config.server_middleware do |chain|
|
|
97
|
+
# prepend replaces an existing entry for the class, so this is
|
|
98
|
+
# idempotent.
|
|
99
|
+
chain.respond_to?(:prepend) ? chain.prepend(ServerMiddleware) : chain.add(ServerMiddleware)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
handlers = config.error_handlers
|
|
103
|
+
handlers << ERROR_HANDLER unless handlers.any? { |h| h.equal?(ERROR_HANDLER) }
|
|
104
|
+
true
|
|
105
|
+
rescue StandardError, SystemStackError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def install_client(config)
|
|
110
|
+
config.client_middleware { |chain| chain.add(ClientMiddleware) }
|
|
111
|
+
true
|
|
112
|
+
rescue StandardError, SystemStackError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Called from the server middleware's rescue.
|
|
117
|
+
def report(error, job)
|
|
118
|
+
return nil if control_flow?(error)
|
|
119
|
+
|
|
120
|
+
Reporter.capture(error, context: 'job', handled: false, symbol: job_symbol(job))
|
|
121
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Called by Sidekiq's handle_exception. A job failure the middleware
|
|
126
|
+
# sent is already marked and is skipped by Reporter.capture.
|
|
127
|
+
def handle_error(error, context)
|
|
128
|
+
return nil if control_flow?(error)
|
|
129
|
+
|
|
130
|
+
job = context.is_a?(Hash) ? context[:job] : nil
|
|
131
|
+
symbol = job.is_a?(Hash) ? job_symbol(job) : sidekiq_symbol(context)
|
|
132
|
+
|
|
133
|
+
# Outside any job scope: the job's own trace if there is one, and
|
|
134
|
+
# never the identity of whatever this thread last held.
|
|
135
|
+
Current.with(trace_from(job)) do
|
|
136
|
+
Reporter.capture(error, context: 'job', handled: false, symbol: symbol)
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Copies the active trace into a job hash being enqueued. Never
|
|
144
|
+
# replaces one already there: a re-push (a retry, an interrupted
|
|
145
|
+
# iterable job) keeps the request it came from.
|
|
146
|
+
def tag(job)
|
|
147
|
+
return nil unless job.is_a?(Hash) && !job.key?(TRACE_KEY)
|
|
148
|
+
|
|
149
|
+
trace = Current.trace
|
|
150
|
+
job[TRACE_KEY] = trace.to_s unless trace.nil?
|
|
151
|
+
nil
|
|
152
|
+
rescue StandardError, SystemStackError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def trace_from(job)
|
|
157
|
+
return nil unless job.is_a?(Hash)
|
|
158
|
+
|
|
159
|
+
Trace.parse(job[TRACE_KEY])
|
|
160
|
+
rescue StandardError, SystemStackError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Sidekiq's own control flow, never an application failure:
|
|
165
|
+
# Sidekiq::Shutdown is already skipped as a SignalException; the
|
|
166
|
+
# JobRetry::Handled/Skip and Job::Interrupted it raises through the
|
|
167
|
+
# middleware are on Reporter::NEVER_REPORTED. What remains is an
|
|
168
|
+
# exception *caused* by a hard shutdown — Sidekiq itself treats that
|
|
169
|
+
# as a shutdown and re-queues the job (JobRetry#exception_caused_by_shutdown?).
|
|
170
|
+
def control_flow?(error)
|
|
171
|
+
cause = error
|
|
172
|
+
MAX_CAUSES.times do
|
|
173
|
+
return false if cause.nil?
|
|
174
|
+
return true if cause.class.ancestors.any? { |mod| mod.name == 'Sidekiq::Shutdown' }
|
|
175
|
+
|
|
176
|
+
cause = cause.cause
|
|
177
|
+
end
|
|
178
|
+
false
|
|
179
|
+
rescue StandardError, SystemStackError
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# "InvoiceSyncJob#perform". An ActiveJob run through Sidekiq's adapter
|
|
184
|
+
# arrives as a JobWrapper with the real class in "wrapped".
|
|
185
|
+
def job_symbol(job)
|
|
186
|
+
return nil unless job.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
name = job['wrapped'] || job['class']
|
|
189
|
+
name = name.name if name.is_a?(Module)
|
|
190
|
+
return nil unless name.is_a?(String) && !name.empty?
|
|
191
|
+
|
|
192
|
+
"#{name}#perform"
|
|
193
|
+
rescue StandardError, SystemStackError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# Sidekiq's description of where it caught an error outside a job
|
|
200
|
+
# ("Error calling death handler"), or just "Sidekiq" (a fetch error
|
|
201
|
+
# carries none).
|
|
202
|
+
def sidekiq_symbol(context)
|
|
203
|
+
label = context.is_a?(Hash) ? context[:context] : nil
|
|
204
|
+
label.is_a?(String) && !label.empty? ? "Sidekiq: #{label}" : 'Sidekiq'
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|