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 +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/lib/realuptime/errors/rack.rb +73 -0
- data/lib/realuptime/errors/rails.rb +144 -0
- data/lib/realuptime/errors/scrub.rb +150 -0
- data/lib/realuptime/errors/sidekiq.rb +75 -0
- data/lib/realuptime/errors/transport.rb +257 -0
- data/lib/realuptime/errors/version.rb +10 -0
- data/lib/realuptime/errors.rb +489 -0
- data/scrub-vectors.json +506 -0
- metadata +60 -0
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
|