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.
@@ -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('&', '&amp;').gsub('"', '&quot;').gsub('<', '&lt;').gsub('>', '&gt;')
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