realuptime-errors 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cfb6b588d319a4a1ceb6dd9a50d0520b494b4e1066056ce4b87261c97237c130
4
+ data.tar.gz: a5026d3e87519f3f6ddd140da1e25433b410c970548499f89a5e439d8de96ea8
5
+ SHA512:
6
+ metadata.gz: 970d101abb8765cff0170a9a19fcf5a1b82697349334c6a463d26ed56f7520e0fb623e91e80b80c27090dee79b0076f55e2b13921103056e186ac5021e67cddc
7
+ data.tar.gz: c05b0bffcd6ca1079e4c278c132fc8a090b73caf4796b5c7a90bcca78fcedf6598cf72b65760bb281e4ee1b2030dbedd72e1c2946cdf213cc900bc52a17cee25
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RealUptime
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
13
+ all 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,94 @@
1
+ # realuptime-errors (Ruby)
2
+
3
+ Zero-dependency error tracking SDK for Ruby, with Rails, Rack and Sidekiq
4
+ integrations: part of [RealUptime Errors](https://realuptime.io/error-tracking).
5
+
6
+ This is a **mirror**. It is published from `packages/errors-ruby` in the
7
+ [realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
8
+ `scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
9
+ and PRs against this repo, but expect source changes to land here after
10
+ they merge upstream.
11
+
12
+ ## Install
13
+
14
+ Installs straight from GitHub today. npm, PyPI, RubyGems, and Packagist packages are coming; this note disappears the day they ship.
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem "realuptime-errors", git: "https://github.com/RealUptimeHQ/realuptime-errors-ruby"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```ruby
24
+ require "realuptime/errors"
25
+
26
+ Realuptime::Errors.init(
27
+ dsn: "https://realuptime.io/api/errors/v1/ingest/rue_...", # from your project's dashboard
28
+ release: "v2.4.1",
29
+ environment: "production"
30
+ )
31
+
32
+ Realuptime::Errors.capture_exception(some_error)
33
+ ```
34
+
35
+ Rails: `gem "realuptime-errors", git: "...", require: "realuptime/errors/rails"`,
36
+ then set `Rails.application.config.realuptime_errors.dsn` (or
37
+ `ENV["REALUPTIME_ERRORS_DSN"]`); the Railtie installs the Rack middleware
38
+ and the ActiveJob hook.
39
+ Rack: `use Realuptime::Errors::RackMiddleware`
40
+ Sidekiq: `require "realuptime/errors/sidekiq"; Realuptime::Errors::Sidekiq.install`
41
+
42
+ ## What this SDK actually does
43
+
44
+ - **Never raises out of a public method.** Every entry point catches
45
+ everything; a broken SDK logs once to stderr (prefixed
46
+ `[realuptime-errors]`) and goes quiet. An error tracker that crashes the
47
+ app it watches is worse than none.
48
+ - **Scrubs PII by default, client-side**, before anything serializes:
49
+ `Authorization`/`Proxy-Authorization`/`Cookie`/`Set-Cookie` headers are
50
+ replaced whole; card-shaped digit runs (Luhn-checked), JWTs, prefixed API
51
+ keys, and long hex/base64 runs are pattern-scrubbed anywhere they appear
52
+ in the message, request context, breadcrumbs, tags and context values.
53
+ `user.email` and `user.username` are removed unless opted back in by
54
+ name with `allow_fields: ["user.email"]`; there is no global "disable
55
+ scrubbing" switch, by design. The vectors in `scrub-vectors.json` are
56
+ the same file the JS and Python SDKs and the server are tested against.
57
+ - **Never silently drops.** The in-memory buffer (200 events) evicts the
58
+ oldest event when full, counts every eviction, and reports the count on
59
+ the next successful batch, so drops show up on your dashboard instead of
60
+ disappearing.
61
+ - **Standard library only** (`json`, `net/http`, `uri`, `time`,
62
+ `rbconfig`), checked by `test/wire_contract_test.rb`'s require
63
+ allow-list scan; the gemspec declares zero runtime dependencies.
64
+ - **Background delivery** on one thread per process (re-spawned after a
65
+ fork), exponential backoff 5s to 300s, over-quota pause until the window
66
+ resets, revoked key disables for the process. `Realuptime::Errors.flush`
67
+ is synchronous and runs at exit.
68
+
69
+ ## API
70
+
71
+ | Method | Notes |
72
+ | --- | --- |
73
+ | `init(dsn:, release: nil, environment: nil, allow_fields: nil, capture_unhandled: true, send_device_info: true, background: true)` | Installs an `at_exit` reporter unless `capture_unhandled: false`. Safe to call twice; a missing DSN logs once and stays inert. |
74
+ | `capture_exception(exc, request: nil, fingerprint: nil, release: nil, environment: nil, user: nil, tags: nil, context: nil)` | Reports an exception. |
75
+ | `capture_message(message, ...)` | Reports a plain message. |
76
+ | `set_user(id:, email:, username:)` / `set_tag(k, v)` / `set_tags(h)` / `set_context(k, v)` | Sticky identity, tags and context for every later event. |
77
+ | `add_breadcrumb(message, category: nil, data: nil)` | Bounded trail (last 20), rides the next event. |
78
+ | `flush` | Delivers anything buffered, synchronously. |
79
+ | `RackMiddleware` / `Rails::RailsMiddleware` / `Sidekiq::ServerMiddleware` | Capture unhandled exceptions with minimal request or job context, then re-raise. |
80
+
81
+ ## Tests
82
+
83
+ ```bash
84
+ ruby -Ilib -Itest -e 'Dir["test/*_test.rb"].each { |f| require "./#{f}" }'
85
+ ```
86
+
87
+ ## Version
88
+
89
+ This mirror tracks SDK_VERSION `0.1.0` in `lib/realuptime/errors/version.rb`,
90
+ the string every event actually carries on the wire.
91
+
92
+ ## License
93
+
94
+ MIT, see [LICENSE](./LICENSE).
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realuptime
4
+ module Errors
5
+ # Plain Rack middleware: captures an exception escaping the app below it
6
+ # with the default minimal request context (method, path; the status is
7
+ # unknown mid-flight and omitted rather than guessed), then RE-RAISES.
8
+ # The middleware reports; the server keeps deciding what a 500 looks
9
+ # like. Never headers, never cookies, never bodies: the request capture
10
+ # is the same minimal default the JS and Python SDKs use.
11
+ #
12
+ # use Realuptime::Errors::RackMiddleware
13
+ #
14
+ # Also reports an exception a downstream framework swallowed into
15
+ # env["rack.exception"] / env["action_dispatch.exception"] (Rails's
16
+ # ShowExceptions sets the latter when it renders its own error page),
17
+ # so a Rails 500 that never propagates still gets captured. The same
18
+ # exception object is reported once, not twice.
19
+ #
20
+ # Zero-dependency: this file never requires "rack"; a Rack app is any
21
+ # object answering call(env).
22
+ class RackMiddleware
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def call(env)
28
+ response = @app.call(env)
29
+ swallowed = env["action_dispatch.exception"] || env["rack.exception"]
30
+ report(swallowed, env) if swallowed.is_a?(Exception)
31
+ response
32
+ rescue Exception => e # rubocop:disable Lint/RescueException
33
+ report(e, env)
34
+ raise
35
+ end
36
+
37
+ private
38
+
39
+ def report(exc, env)
40
+ return if already_reported?(exc)
41
+
42
+ Realuptime::Errors.capture_exception(exc, request: RackMiddleware.request_context(env))
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def already_reported?(exc)
48
+ return true if exc.instance_variable_get(:@__realuptime_reported)
49
+
50
+ exc.instance_variable_set(:@__realuptime_reported, true)
51
+ false
52
+ rescue StandardError
53
+ false
54
+ end
55
+
56
+ # Method + path from a Rack env, nothing else. Shared with the Rails
57
+ # and Sidekiq integrations so every entry point captures identically.
58
+ def self.request_context(env)
59
+ return nil unless env.is_a?(Hash)
60
+
61
+ request = {}
62
+ method = env["REQUEST_METHOD"]
63
+ request["method"] = method.to_s if method
64
+ path = env["PATH_INFO"]
65
+ path = "#{env["SCRIPT_NAME"]}#{path}" if env["SCRIPT_NAME"] && !env["SCRIPT_NAME"].to_s.empty?
66
+ request["path"] = path.to_s if path
67
+ request.empty? ? nil : request
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "realuptime/errors"
4
+
5
+ module Realuptime
6
+ module Errors
7
+ # Rails integration (REA-255). Feature-detected: this file never
8
+ # requires "rails"; the Railtie at the bottom is defined only when
9
+ # ::Rails::Railtie already exists, so requiring this file outside Rails
10
+ # is harmless and the helpers here are testable without it.
11
+ #
12
+ # # Gemfile
13
+ # gem "realuptime-errors", require: "realuptime/errors/rails"
14
+ #
15
+ # # config/initializers/realuptime_errors.rb (or ENV REALUPTIME_ERRORS_DSN)
16
+ # Rails.application.config.realuptime_errors.dsn = "https://realuptime.io/api/errors/v1/ingest/rue_..."
17
+ # Rails.application.config.realuptime_errors.release = ENV["GIT_SHA"]
18
+ #
19
+ # What the Railtie installs, all through the ONE capture/scrub/transport
20
+ # path a plain init gives:
21
+ # - RailsMiddleware at the outermost position of the Rack stack: an
22
+ # exception that escapes, or one Rails swallowed into its own error
23
+ # page (action_dispatch.exception), is reported ONCE with method,
24
+ # path and the matched controller#action as the route template.
25
+ # - an ActiveJob around_perform hook that reports a job's unhandled
26
+ # exception with the job class as the fingerprint key and re-raises,
27
+ # so retry_on / discard_on keep working exactly as before. A job that
28
+ # retries reports each failed attempt; that is the honest count.
29
+ # - init itself from config.realuptime_errors / ENV when the app did
30
+ # not call Realuptime::Errors.init in an initializer.
31
+ module Rails
32
+ FRAMEWORK_TAG = { "framework" => "rails" }.freeze
33
+
34
+ # Rack-level context plus the matched controller#action, the grouping
35
+ # key Rails exposes without any user-supplied bytes. path_parameters
36
+ # VALUES (the :id etc.) are never forwarded; only the controller and
37
+ # action names are.
38
+ def self.request_context(env)
39
+ request = RackMiddleware.request_context(env) || {}
40
+ params = env.is_a?(Hash) ? env["action_dispatch.request.path_parameters"] : nil
41
+ if params.is_a?(Hash)
42
+ controller = params[:controller] || params["controller"]
43
+ action = params[:action] || params["action"]
44
+ request["route"] = "#{controller}##{action}" if controller && action
45
+ end
46
+ request.empty? ? nil : request
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ class RailsMiddleware < RackMiddleware
52
+ private
53
+
54
+ def report(exc, env)
55
+ return if already_reported?(exc)
56
+
57
+ Realuptime::Errors.capture_exception(exc, request: Realuptime::Errors::Rails.request_context(env),
58
+ tags: FRAMEWORK_TAG)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+
64
+ # Reports a job's failure and re-raises. Usable without ActiveJob
65
+ # (tests, or a hand-rolled job runner): yield the work.
66
+ def self.perform_reporting(job)
67
+ yield
68
+ rescue Exception => e # rubocop:disable Lint/RescueException
69
+ begin
70
+ job_name = job.class.name.to_s
71
+ Realuptime::Errors.capture_exception(
72
+ e,
73
+ fingerprint: ["active_job", job_name, e.class.name.to_s],
74
+ tags: { "framework" => "active_job", "job" => job_name }
75
+ )
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ raise
80
+ end
81
+
82
+ # `include`d into ActiveJob::Base by the Railtie.
83
+ module ActiveJobExtension
84
+ def self.included(base)
85
+ return unless base.respond_to?(:around_perform)
86
+
87
+ base.around_perform do |job, block|
88
+ Realuptime::Errors::Rails.perform_reporting(job) { block.call }
89
+ end
90
+ end
91
+ end
92
+
93
+ # Resolves init options from config.realuptime_errors (any object
94
+ # answering [] or the dotted accessors) and ENV. Returns nil when no
95
+ # DSN is configured anywhere, so the Railtie stays inert rather than
96
+ # logging on every boot of an app that has not opted in.
97
+ def self.options_from(config, env = ENV, rails_env = nil)
98
+ read = lambda do |key|
99
+ value = nil
100
+ if config.respond_to?(:[])
101
+ value = begin
102
+ config[key]
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ end
107
+ value = config.public_send(key) if value.nil? && config.respond_to?(key)
108
+ value
109
+ end
110
+ dsn = read.call(:dsn) || env["REALUPTIME_ERRORS_DSN"]
111
+ return nil if dsn.nil? || dsn.to_s.empty?
112
+
113
+ {
114
+ dsn: dsn.to_s,
115
+ release: read.call(:release) || env["REALUPTIME_RELEASE"],
116
+ environment: read.call(:environment) || rails_env,
117
+ allow_fields: read.call(:allow_fields),
118
+ send_device_info: read.call(:send_device_info).nil? ? true : read.call(:send_device_info)
119
+ }
120
+ end
121
+
122
+ if defined?(::Rails::Railtie)
123
+ class Railtie < ::Rails::Railtie
124
+ config.realuptime_errors = ActiveSupport::OrderedOptions.new if defined?(ActiveSupport::OrderedOptions)
125
+
126
+ initializer "realuptime_errors.middleware" do |app|
127
+ app.config.middleware.insert_before(0, Realuptime::Errors::Rails::RailsMiddleware)
128
+ end
129
+
130
+ initializer "realuptime_errors.active_job" do
131
+ ActiveSupport.on_load(:active_job) { include Realuptime::Errors::Rails::ActiveJobExtension }
132
+ end
133
+
134
+ config.after_initialize do |app|
135
+ next if Realuptime::Errors.initialized?
136
+
137
+ options = Realuptime::Errors::Rails.options_from(app.config.realuptime_errors, ENV, ::Rails.env.to_s)
138
+ Realuptime::Errors.init(**options) if options
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realuptime
4
+ module Errors
5
+ # PII scrub-by-default, client-side (docs/errors-plan.md, "PII
6
+ # scrub-by-default, specified"). A byte-for-byte port of
7
+ # packages/errors-js/scrub.ts and packages/errors-py/realuptime_errors.py's
8
+ # scrub half; the shared vectors (scrub-vectors.json, vendored into this
9
+ # gem and asserted byte-identical to packages/errors-js/scrub-vectors.json
10
+ # by test/vendored_vectors_test.rb) pin all four implementations -- JS,
11
+ # Python, this one, and the server's second net -- to identical output.
12
+ #
13
+ # The five pattern rules, in the shared order:
14
+ # 1. JWT three-dot shape
15
+ # 2. prefixed keys (sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_)
16
+ # 3. card-shaped digit runs, 13-19 digits, Luhn-checked
17
+ # 4. long hex runs (32+)
18
+ # 5. long base64-ish runs (40+)
19
+ #
20
+ # REMOVAL (value replaced whole) unless allow-listed by name:
21
+ # - headers authorization / proxy-authorization / cookie / set-cookie
22
+ # - identity (wire v2): user.email and user.username. user.id is NOT
23
+ # removed; an opaque id in the customer's own key space is not
24
+ # contact data, and it is what makes "how many users hit this"
25
+ # answerable.
26
+ module Scrub
27
+ SCRUBBED = "[scrubbed]"
28
+
29
+ REMOVED_HEADERS = %w[authorization proxy-authorization cookie set-cookie].freeze
30
+ REMOVED_USER_FIELDS = %w[user.email user.username].freeze
31
+
32
+ JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/.freeze
33
+ PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/.freeze
34
+ CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/.freeze
35
+ HEX_RE = /\b[0-9a-fA-F]{32,}\b/.freeze
36
+ BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/.freeze
37
+
38
+ module_function
39
+
40
+ def luhn_valid?(digits)
41
+ total = 0
42
+ double = false
43
+ digits.reverse.each_char do |ch|
44
+ d = ch.ord - 48
45
+ if double
46
+ d *= 2
47
+ d -= 9 if d > 9
48
+ end
49
+ total += d
50
+ double = !double
51
+ end
52
+ (total % 10).zero?
53
+ end
54
+
55
+ # The five pattern rules over one string. Pure.
56
+ def scrub_string(value)
57
+ out = value.gsub(JWT_RE, SCRUBBED)
58
+ out = out.gsub(PREFIXED_KEY_RE, SCRUBBED)
59
+ out = out.gsub(CARD_RE) do |run|
60
+ digits = run.delete(" -")
61
+ digits.length >= 13 && digits.length <= 19 && luhn_valid?(digits) ? SCRUBBED : run
62
+ end
63
+ out = out.gsub(HEX_RE, SCRUBBED)
64
+ out.gsub(BASE64_RE, SCRUBBED)
65
+ end
66
+
67
+ # A flat string map (tags, custom context, device, frame locals):
68
+ # VALUES pass the pattern rules, KEYS are left alone (a key is a label
69
+ # the integrator chose).
70
+ def scrub_string_map(value)
71
+ return value unless value.is_a?(Hash)
72
+
73
+ value.each_with_object({}) do |(name, entry), out|
74
+ out[name] = entry.is_a?(String) ? scrub_string(entry) : entry
75
+ end
76
+ end
77
+
78
+ def scrub_breadcrumb(crumb)
79
+ return crumb unless crumb.is_a?(Hash)
80
+
81
+ out = crumb.dup
82
+ out["message"] = scrub_string(out["message"]) if out["message"].is_a?(String)
83
+ out["data"] = scrub_string_map(out["data"]) if out["data"].is_a?(Hash)
84
+ out
85
+ end
86
+
87
+ def scrub_user(user, allowed)
88
+ return user unless user.is_a?(Hash)
89
+
90
+ out = user.dup
91
+ out["id"] = scrub_string(out["id"]) if out["id"].is_a?(String)
92
+ REMOVED_USER_FIELDS.each do |field|
93
+ key = field.sub("user.", "")
94
+ value = out[key]
95
+ next unless value.is_a?(String)
96
+
97
+ out[key] = allowed.include?(field) ? scrub_string(value) : SCRUBBED
98
+ end
99
+ out
100
+ end
101
+
102
+ def scrub_frame(frame)
103
+ return frame unless frame.is_a?(Hash)
104
+ return frame unless frame["contextLine"] || frame["preContext"] || frame["postContext"] || frame["vars"]
105
+
106
+ out = frame.dup
107
+ out["contextLine"] = scrub_string(out["contextLine"]) if out["contextLine"].is_a?(String)
108
+ %w[preContext postContext].each do |key|
109
+ lines = out[key]
110
+ out[key] = lines.map { |line| line.is_a?(String) ? scrub_string(line) : line } if lines.is_a?(Array)
111
+ end
112
+ out["vars"] = scrub_string_map(out["vars"]) if out["vars"].is_a?(Hash)
113
+ out
114
+ end
115
+
116
+ # Scrubs one wire event (string-keyed Hash). Returns a new Hash; never
117
+ # mutates the input.
118
+ def scrub_event(event, allow_fields = nil)
119
+ allowed = (allow_fields || []).map { |f| f.to_s.downcase }
120
+ out = event.dup
121
+ out["message"] = scrub_string(out["message"]) if out["message"].is_a?(String)
122
+ out["breadcrumbs"] = out["breadcrumbs"].map { |c| scrub_breadcrumb(c) } if out["breadcrumbs"].is_a?(Array)
123
+ out["frames"] = out["frames"].map { |f| scrub_frame(f) } if out["frames"].is_a?(Array)
124
+ %w[tags context device].each do |key|
125
+ out[key] = scrub_string_map(out[key]) if out[key].is_a?(Hash)
126
+ end
127
+ out["user"] = scrub_user(out["user"], allowed) if out["user"].is_a?(Hash)
128
+ request = out["request"]
129
+ if request.is_a?(Hash)
130
+ request = request.dup
131
+ request["path"] = scrub_string(request["path"]) if request["path"].is_a?(String)
132
+ request["route"] = scrub_string(request["route"]) if request["route"].is_a?(String)
133
+ headers = request["headers"]
134
+ if headers.is_a?(Hash)
135
+ request["headers"] = headers.each_with_object({}) do |(name, value), scrubbed|
136
+ lower = name.to_s.downcase
137
+ scrubbed[name] = if REMOVED_HEADERS.include?(lower) && !allowed.include?(lower)
138
+ SCRUBBED
139
+ else
140
+ value.is_a?(String) ? scrub_string(value) : value
141
+ end
142
+ end
143
+ end
144
+ out["request"] = request
145
+ end
146
+ out
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "realuptime/errors"
4
+
5
+ module Realuptime
6
+ module Errors
7
+ # Sidekiq server middleware (REA-255). Feature-detected: this file never
8
+ # requires "sidekiq"; `install` only touches ::Sidekiq when it is
9
+ # already loaded and raises a clear message otherwise, the same shape as
10
+ # the Python SDK's Celery hook.
11
+ #
12
+ # # config/initializers/sidekiq.rb
13
+ # require "realuptime/errors/sidekiq"
14
+ # Realuptime::Errors::Sidekiq.install
15
+ #
16
+ # Or add the middleware by hand:
17
+ #
18
+ # Sidekiq.configure_server do |config|
19
+ # config.server_middleware { |chain| chain.add Realuptime::Errors::Sidekiq::ServerMiddleware }
20
+ # end
21
+ #
22
+ # A job's unhandled exception is reported with the worker class as the
23
+ # fingerprint key (so "ArgumentError in ChargeWorker" and "ArgumentError
24
+ # in MailWorker" never collapse into one issue), a framework tag, the
25
+ # worker class and queue as tags, then RE-RAISED so Sidekiq's own retry
26
+ # and dead-set handling is untouched. Job ARGUMENTS are never captured:
27
+ # that is where the customer's users' data lives. Sidekiq's retry count
28
+ # rides as a tag so "first failure" and "24th retry" are tellable apart.
29
+ # Every retry reports its own event; that is the honest count.
30
+ module Sidekiq
31
+ class ServerMiddleware
32
+ def call(worker, job, queue)
33
+ yield
34
+ rescue Exception => e # rubocop:disable Lint/RescueException
35
+ Realuptime::Errors::Sidekiq.report(e, worker, job, queue)
36
+ raise
37
+ end
38
+ end
39
+
40
+ def self.report(exc, worker, job, queue)
41
+ worker_name = job.is_a?(Hash) && job["wrapped"] ? job["wrapped"].to_s : worker.class.name.to_s
42
+ tags = { "framework" => "sidekiq", "worker" => worker_name }
43
+ tags["queue"] = queue.to_s if queue
44
+ if job.is_a?(Hash) && job.key?("retry_count")
45
+ tags["retry_count"] = job["retry_count"].to_s
46
+ end
47
+ Realuptime::Errors.capture_exception(
48
+ exc,
49
+ fingerprint: ["sidekiq", worker_name, exc.class.name.to_s],
50
+ tags: tags
51
+ )
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ # Adds the middleware to Sidekiq's server chain. Raises a RuntimeError
57
+ # naming the fix if Sidekiq is not loaded, rather than failing at
58
+ # require time. Idempotent: Sidekiq's chain dedupes by class.
59
+ def self.install
60
+ unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
61
+ raise "Realuptime::Errors::Sidekiq.install requires Sidekiq to be loaded first (require \"sidekiq\")."
62
+ end
63
+
64
+ ::Sidekiq.configure_server do |config|
65
+ config.server_middleware { |chain| chain.add ServerMiddleware }
66
+ # Flush what the worker buffered before the process exits; Sidekiq
67
+ # fires :shutdown on a clean stop (TSTP/TERM), which is where a
68
+ # last in-flight batch would otherwise be lost.
69
+ config.on(:shutdown) { Realuptime::Errors.flush } if config.respond_to?(:on)
70
+ end
71
+ true
72
+ end
73
+ end
74
+ end
75
+ end