forge_ops_tracker 0.2.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/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/lib/forge_ops_tracker/client.rb +49 -0
- data/lib/forge_ops_tracker/configuration.rb +68 -0
- data/lib/forge_ops_tracker/delivery_queue.rb +57 -0
- data/lib/forge_ops_tracker/error_subscriber.rb +27 -0
- data/lib/forge_ops_tracker/event_builder.rb +73 -0
- data/lib/forge_ops_tracker/pii_scrubber.rb +70 -0
- data/lib/forge_ops_tracker/railtie.rb +14 -0
- data/lib/forge_ops_tracker/version.rb +3 -0
- data/lib/forge_ops_tracker.rb +21 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 073a3b64414a46750408cc7824f0edd39ecdfa1a27a9b6ad6aee7c747c90f334
|
|
4
|
+
data.tar.gz: c5a4c430bfc726d62f45475dcc33eb453651cc99a575d3606433036afb51adf5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cb8dbef976d942b852e8f0cfa84a46277534f0835df4ab33a3c515a8a5ce1d1accf90f1f72ad8841130fb977e8a2a7e2cfdc97757b8fe98e883f57691082b642
|
|
7
|
+
data.tar.gz: 200daa5942120bb078c0203914684dd42c0722db024639188528b569a6a2661a0b3276c7c32108f31685985f27fbe942b136feac7795f16432f2666f76b6e238
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Client-side PII scrubbing: payloads are scrubbed (email addresses, formatted SSNs/credit
|
|
6
|
+
cards, known API key/token formats, and any field whose name suggests a secret) before they
|
|
7
|
+
ever leave the host app's process, not just on arrival at the ForgeOps server. On by default;
|
|
8
|
+
`config.scrub_pii = false` opts out.
|
|
9
|
+
|
|
10
|
+
## 0.1.0
|
|
11
|
+
|
|
12
|
+
- Initial release: a Railtie subscribing to `Rails.error`, so unhandled exceptions and job
|
|
13
|
+
failures report automatically with no further wiring. Delivery runs on a small background
|
|
14
|
+
thread with a bounded queue and short HTTP timeouts; every failure mode (network errors,
|
|
15
|
+
timeouts, a full queue, a malformed DSN) is caught and dropped rather than raised, so a broken
|
|
16
|
+
or unreachable tracker can never take down the host app.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ForgeOps
|
|
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 all
|
|
13
|
+
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,85 @@
|
|
|
1
|
+
# ForgeOpsTracker
|
|
2
|
+
|
|
3
|
+
Rails exception reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "forge_ops_tracker", path: "gems/forge_ops_tracker" # or git: "..." once split into its own repo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Set a DSN (from a project's settings page in ForgeOps) via an initializer or environment variable:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# config/initializers/forge_ops_tracker.rb
|
|
17
|
+
ForgeOpsTracker.configure do |config|
|
|
18
|
+
config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@forgeops.example.com/api/v1/events"
|
|
19
|
+
config.release = ENV["HEROKU_SLUG_COMMIT"] || `git rev-parse HEAD`.strip
|
|
20
|
+
config.enabled_environments = %w[production staging] # default; reporting is a no-op elsewhere
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Delivery happens on a small background thread with a bounded queue and short HTTP timeouts. Every
|
|
25
|
+
failure mode -- network errors, timeouts, a full queue, a malformed DSN -- is caught and dropped
|
|
26
|
+
rather than raised, so a broken or unreachable tracker can never take down the host app.
|
|
27
|
+
|
|
28
|
+
## What gets reported automatically, and what doesn't
|
|
29
|
+
|
|
30
|
+
**Unhandled exceptions need no further wiring at all.** The gem's Railtie subscribes to
|
|
31
|
+
`Rails.error` automatically, and Rails itself reports anything that crashes a request or job
|
|
32
|
+
through that same channel -- install the gem, set a DSN, and those show up in ForgeOps with zero
|
|
33
|
+
other code changes.
|
|
34
|
+
|
|
35
|
+
**Handled exceptions -- code that catches its own error to keep running -- are a different story.**
|
|
36
|
+
A plain `rescue` the gem never hears about, no matter what:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
begin
|
|
40
|
+
charge_card(order)
|
|
41
|
+
rescue Stripe::CardError => e
|
|
42
|
+
logger.warn("card declined: #{e.message}")
|
|
43
|
+
# ForgeOps never sees this -- nothing here goes through Rails.error at all.
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To report it *and* keep swallowing it, swap the bare `rescue` for Rails' own built-in
|
|
48
|
+
`Rails.error.handle` -- this isn't a ForgeOps-specific API, it's Rails' own error-reporting
|
|
49
|
+
convention (Rails 7+), which the gem just happens to already be subscribed to:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Rails.error.handle(fallback: -> { nil }) do
|
|
53
|
+
charge_card(order)
|
|
54
|
+
end
|
|
55
|
+
# Reported to every Rails.error subscriber, including this gem, then swallowed --
|
|
56
|
+
# execution continues past the block either way.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or `Rails.error.record`, if you want it reported *and* still raised (e.g. so a background job's
|
|
60
|
+
own retry logic still sees the failure):
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Rails.error.record { charge_card(order) } # reports, then re-raises
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Bottom line: if an exception would otherwise crash something, you're already covered. If your own
|
|
67
|
+
code already catches and handles it, route that specific `rescue` through `Rails.error.handle`/
|
|
68
|
+
`.record` instead of a bare one wherever you want ForgeOps to know about it.
|
|
69
|
+
|
|
70
|
+
## PII scrubbing
|
|
71
|
+
|
|
72
|
+
By default, the message, backtrace, and any context/tags you attach are scanned for likely
|
|
73
|
+
personal data -- email addresses, formatted SSNs/credit cards, known API key/token formats, and
|
|
74
|
+
anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar) -- and redacted
|
|
75
|
+
before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so
|
|
76
|
+
this is a second, earlier layer, not the only one.
|
|
77
|
+
|
|
78
|
+
To disable it (e.g. if your app already scrubs its own error context, or you have your own reasons
|
|
79
|
+
to want the raw payload):
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
ForgeOpsTracker.configure do |config|
|
|
83
|
+
config.scrub_pii = false
|
|
84
|
+
end
|
|
85
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ForgeOpsTracker
|
|
6
|
+
# Delivers one payload over HTTP. Every failure mode -- DNS, connection,
|
|
7
|
+
# timeout, TLS, a non-2xx response -- is caught here and turned into a
|
|
8
|
+
# `false` return rather than a raised exception, since a broken or
|
|
9
|
+
# unreachable tracker must never be able to break the host app.
|
|
10
|
+
class Client
|
|
11
|
+
def initialize(configuration)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def deliver(payload)
|
|
16
|
+
uri = configuration.ingestion_uri
|
|
17
|
+
return false unless uri
|
|
18
|
+
|
|
19
|
+
response = http_for(uri).request(build_request(uri, payload))
|
|
20
|
+
response.is_a?(Net::HTTPSuccess)
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
log { "delivery failed: #{e.class}: #{e.message}" }
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
attr_reader :configuration
|
|
28
|
+
|
|
29
|
+
def http_for(uri)
|
|
30
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
|
+
http.use_ssl = uri.scheme == "https"
|
|
32
|
+
http.open_timeout = configuration.open_timeout
|
|
33
|
+
http.read_timeout = configuration.read_timeout
|
|
34
|
+
http
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_request(uri, payload)
|
|
38
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
39
|
+
request["Authorization"] = "Bearer #{configuration.api_key}"
|
|
40
|
+
request["Content-Type"] = "application/json"
|
|
41
|
+
request.body = JSON.generate(payload)
|
|
42
|
+
request
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def log
|
|
46
|
+
configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module ForgeOpsTracker
|
|
5
|
+
class Configuration
|
|
6
|
+
# A single Sentry-style DSN string carries both the ingestion URL and
|
|
7
|
+
# the project's api_key: "https://<api_key>@host/api/v1/events".
|
|
8
|
+
attr_accessor :dsn, :environment, :release, :server_name, :app_root, :logger
|
|
9
|
+
attr_accessor :enabled_environments, :queue_size, :open_timeout, :read_timeout, :scrub_pii
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@dsn = ENV["FORGE_OPS_DSN"]
|
|
13
|
+
@environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
|
|
14
|
+
@release = ENV["FORGE_OPS_RELEASE"]
|
|
15
|
+
@server_name = safe_hostname
|
|
16
|
+
@enabled_environments = %w[production staging]
|
|
17
|
+
@queue_size = 1000
|
|
18
|
+
@open_timeout = 2
|
|
19
|
+
@read_timeout = 2
|
|
20
|
+
@logger = nil
|
|
21
|
+
# See PiiScrubber -- redacts likely-sensitive content (emails,
|
|
22
|
+
# credit cards, known API key formats, anything under a
|
|
23
|
+
# suspiciously-named key) before a payload ever leaves this
|
|
24
|
+
# process. ForgeOps itself scrubs again on arrival regardless, so
|
|
25
|
+
# this is a second, earlier layer, not the only one -- but "on" is
|
|
26
|
+
# the only sane default.
|
|
27
|
+
@scrub_pii = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def api_key
|
|
31
|
+
parsed_dsn&.user
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The ingestion URL with credentials stripped out (they travel as the
|
|
35
|
+
# Authorization header instead, not embedded in the request URI).
|
|
36
|
+
def ingestion_uri
|
|
37
|
+
return nil unless parsed_dsn
|
|
38
|
+
|
|
39
|
+
uri = parsed_dsn.dup
|
|
40
|
+
uri.user = nil
|
|
41
|
+
uri.password = nil
|
|
42
|
+
uri
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled?
|
|
46
|
+
!blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def parsed_dsn
|
|
51
|
+
return nil if blank?(dsn)
|
|
52
|
+
|
|
53
|
+
@parsed_dsn ||= URI.parse(dsn)
|
|
54
|
+
rescue URI::InvalidURIError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def blank?(value)
|
|
59
|
+
value.nil? || value.to_s.strip.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def safe_hostname
|
|
63
|
+
Socket.gethostname
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# A small in-process background thread + bounded queue, so delivery never
|
|
5
|
+
# blocks the request that raised the error and never depends on the host
|
|
6
|
+
# app having any particular job backend configured. The worker thread is
|
|
7
|
+
# started lazily on first push (not at load time), so it's created fresh
|
|
8
|
+
# in each forked Puma/Passenger worker process rather than being carried
|
|
9
|
+
# across a fork, which would leave it dead in the child.
|
|
10
|
+
class DeliveryQueue
|
|
11
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@client = client
|
|
14
|
+
@queue = SizedQueue.new(configuration.queue_size)
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@thread = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Enqueues a payload, dropping it silently (never blocking the caller)
|
|
20
|
+
# if the queue is already full -- a burst of exceptions must never apply
|
|
21
|
+
# backpressure to the host app.
|
|
22
|
+
def push(payload)
|
|
23
|
+
ensure_worker_started
|
|
24
|
+
@queue.push(payload, true)
|
|
25
|
+
true
|
|
26
|
+
rescue ThreadError
|
|
27
|
+
log { "delivery queue full, dropping event" }
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
attr_reader :configuration, :client
|
|
33
|
+
|
|
34
|
+
def ensure_worker_started
|
|
35
|
+
return if @thread&.alive?
|
|
36
|
+
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
return if @thread&.alive?
|
|
39
|
+
|
|
40
|
+
@thread = Thread.new { run }
|
|
41
|
+
@thread.abort_on_exception = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
loop do
|
|
47
|
+
client.deliver(@queue.pop)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
log { "delivery thread error: #{e.class}: #{e.message}" }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log
|
|
54
|
+
configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
# Implements the subscriber interface Rails.error.subscribe expects
|
|
3
|
+
# (#report). An error reporter that itself raises while reporting an
|
|
4
|
+
# error is the worst possible failure mode, so every path here is
|
|
5
|
+
# wrapped to guarantee this never propagates an exception back into the
|
|
6
|
+
# host app's error-handling cycle.
|
|
7
|
+
class ErrorSubscriber
|
|
8
|
+
def initialize(configuration, delivery_queue: DeliveryQueue.new(configuration), event_builder: EventBuilder.new(configuration))
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
@delivery_queue = delivery_queue
|
|
11
|
+
@event_builder = event_builder
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report(error, handled: true, severity: nil, context: {}, source: nil)
|
|
15
|
+
return unless configuration.enabled?
|
|
16
|
+
|
|
17
|
+
delivery_queue.push(event_builder.build(error, context: context))
|
|
18
|
+
nil
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
configuration.logger&.debug { "[ForgeOpsTracker] report failed: #{e.class}: #{e.message}" }
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
attr_reader :configuration, :delivery_queue, :event_builder
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Turns a raised exception into the JSON-able payload shape the ingestion
|
|
5
|
+
# API expects. Backtrace parsing is a simple regex, not a full parser --
|
|
6
|
+
# good enough for standard MRI backtrace lines across Ruby versions
|
|
7
|
+
# (both the older `in \`method'` and newer `in 'method'` quoting styles).
|
|
8
|
+
class EventBuilder
|
|
9
|
+
LINE_PATTERN = /\A(?<file>.+?):(?<line>\d+)(?::in\s+(?<method>.+))?\z/
|
|
10
|
+
MAX_FRAMES = 500
|
|
11
|
+
|
|
12
|
+
def initialize(configuration)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build(error, context: {})
|
|
17
|
+
payload = {
|
|
18
|
+
exception_class: error.class.name,
|
|
19
|
+
message: error.message.to_s,
|
|
20
|
+
backtrace: backtrace_frames(error),
|
|
21
|
+
occurred_at: Time.now.utc.iso8601,
|
|
22
|
+
environment: configuration.environment.to_s,
|
|
23
|
+
release: configuration.release,
|
|
24
|
+
server_name: configuration.server_name,
|
|
25
|
+
context: context || {},
|
|
26
|
+
tags: {}
|
|
27
|
+
}
|
|
28
|
+
scrub(payload)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
attr_reader :configuration
|
|
33
|
+
|
|
34
|
+
# exception_class/occurred_at/environment/release/server_name are
|
|
35
|
+
# left alone -- structured fields this gem or the host app sets
|
|
36
|
+
# deliberately, not free text an exception or its context could
|
|
37
|
+
# accidentally spill sensitive data into.
|
|
38
|
+
def scrub(payload)
|
|
39
|
+
return payload unless configuration.scrub_pii
|
|
40
|
+
|
|
41
|
+
payload.merge(
|
|
42
|
+
message: PiiScrubber.scrub(payload[:message]),
|
|
43
|
+
backtrace: PiiScrubber.scrub(payload[:backtrace]),
|
|
44
|
+
context: PiiScrubber.scrub(payload[:context]),
|
|
45
|
+
tags: PiiScrubber.scrub(payload[:tags])
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def backtrace_frames(error)
|
|
50
|
+
Array(error.backtrace).first(MAX_FRAMES).filter_map { |line| parse_backtrace_line(line) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parse_backtrace_line(line)
|
|
54
|
+
match = LINE_PATTERN.match(line.to_s)
|
|
55
|
+
return nil unless match
|
|
56
|
+
|
|
57
|
+
file = match[:file]
|
|
58
|
+
{
|
|
59
|
+
file: file,
|
|
60
|
+
line: match[:line].to_i,
|
|
61
|
+
method: match[:method]&.gsub(/\A[`'"]|['"]\z/, ""),
|
|
62
|
+
in_app: in_app?(file)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def in_app?(file)
|
|
67
|
+
root = configuration.app_root
|
|
68
|
+
return false if root.nil? || root.to_s.empty?
|
|
69
|
+
|
|
70
|
+
file.start_with?(root.to_s) && !file.include?("/gems/") && !file.include?("/bundle/")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
# Redacts likely-sensitive content out of a payload before it ever leaves
|
|
3
|
+
# this process -- the same patterns ForgeOps itself applies again on
|
|
4
|
+
# arrival (defense in depth: this layer keeps the data off the wire and
|
|
5
|
+
# out of any request logging in between; the server-side layer is what
|
|
6
|
+
# actually protects the database, and doesn't depend on every reporting
|
|
7
|
+
# app running an up-to-date version of this gem). See the main
|
|
8
|
+
# application's PiiScrubber for the shared design rationale -- kept as a
|
|
9
|
+
# separate, dependency-free implementation here rather than requiring the
|
|
10
|
+
# private app's code, since this gem has to work standalone in any host
|
|
11
|
+
# app regardless of what's reporting into it.
|
|
12
|
+
#
|
|
13
|
+
# Can be turned off via configuration.scrub_pii = false for a host app
|
|
14
|
+
# that already scrubs its own data before it ever reaches error context,
|
|
15
|
+
# or that has its own reasons to want the raw payload. Off by default is
|
|
16
|
+
# not an option: the safe default has to be "on."
|
|
17
|
+
module PiiScrubber
|
|
18
|
+
REDACTED = "[FILTERED]"
|
|
19
|
+
|
|
20
|
+
SENSITIVE_KEYS = %w[
|
|
21
|
+
password passwd pwd
|
|
22
|
+
secret apisecret clientsecret secretkey
|
|
23
|
+
token accesstoken refreshtoken apikey apitoken authorization authtoken bearer sessiontoken csrftoken
|
|
24
|
+
creditcard cardnumber cardnum cvv cvv2 cvc
|
|
25
|
+
ssn socialsecuritynumber socialsecurity
|
|
26
|
+
privatekey
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
PATTERNS = {
|
|
30
|
+
"EMAIL" => /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
|
|
31
|
+
"SSN" => /\b\d{3}-\d{2}-\d{4}\b/,
|
|
32
|
+
"CREDIT CARD" => /\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b/,
|
|
33
|
+
"BEARER TOKEN" => %r{\bBearer\s+[A-Za-z0-9\-._~+/]+=*}i,
|
|
34
|
+
"JWT" => /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
35
|
+
"AWS KEY" => /\bAKIA[0-9A-Z]{16}\b/,
|
|
36
|
+
"STRIPE KEY" => /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/,
|
|
37
|
+
"GITHUB TOKEN" => /\bgh[pousr]_[A-Za-z0-9]{20,}\b/
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def self.scrub(value, key: nil)
|
|
41
|
+
if sensitive_key?(key) && !value.nil?
|
|
42
|
+
REDACTED
|
|
43
|
+
else
|
|
44
|
+
case value
|
|
45
|
+
when Hash
|
|
46
|
+
value.each_with_object({}) { |(k, v), out| out[k] = scrub(v, key: k) }
|
|
47
|
+
when Array
|
|
48
|
+
value.map { |v| scrub(v, key: key) }
|
|
49
|
+
when String
|
|
50
|
+
scrub_string(value)
|
|
51
|
+
else
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.sensitive_key?(key)
|
|
58
|
+
return false if key.nil?
|
|
59
|
+
|
|
60
|
+
normalized = key.to_s.downcase.gsub(/[^a-z0-9]/, "")
|
|
61
|
+
SENSITIVE_KEYS.any? { |sensitive| normalized.include?(sensitive) }
|
|
62
|
+
end
|
|
63
|
+
private_class_method :sensitive_key?
|
|
64
|
+
|
|
65
|
+
def self.scrub_string(string)
|
|
66
|
+
PATTERNS.reduce(string) { |scrubbed, (label, pattern)| scrubbed.gsub(pattern, "[#{label} FILTERED]") }
|
|
67
|
+
end
|
|
68
|
+
private_class_method :scrub_string
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
|
|
6
|
+
configuration = ForgeOpsTracker.configuration
|
|
7
|
+
configuration.app_root ||= app.root.to_s
|
|
8
|
+
configuration.environment = Rails.env.to_s
|
|
9
|
+
configuration.logger ||= Rails.logger
|
|
10
|
+
|
|
11
|
+
Rails.error.subscribe(ForgeOpsTracker::ErrorSubscriber.new(configuration))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "forge_ops_tracker/version"
|
|
2
|
+
require "forge_ops_tracker/configuration"
|
|
3
|
+
require "forge_ops_tracker/pii_scrubber"
|
|
4
|
+
require "forge_ops_tracker/event_builder"
|
|
5
|
+
require "forge_ops_tracker/client"
|
|
6
|
+
require "forge_ops_tracker/delivery_queue"
|
|
7
|
+
require "forge_ops_tracker/error_subscriber"
|
|
8
|
+
|
|
9
|
+
module ForgeOpsTracker
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield configuration
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require "forge_ops_tracker/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: forge_ops_tracker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ForgeOps
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
description: Hooks Rails' error reporter and reports exceptions to a private ForgeOps
|
|
27
|
+
exception tracker instance over HTTP, without ever raising back into the host application.
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/forge_ops_tracker.rb
|
|
36
|
+
- lib/forge_ops_tracker/client.rb
|
|
37
|
+
- lib/forge_ops_tracker/configuration.rb
|
|
38
|
+
- lib/forge_ops_tracker/delivery_queue.rb
|
|
39
|
+
- lib/forge_ops_tracker/error_subscriber.rb
|
|
40
|
+
- lib/forge_ops_tracker/event_builder.rb
|
|
41
|
+
- lib/forge_ops_tracker/pii_scrubber.rb
|
|
42
|
+
- lib/forge_ops_tracker/railtie.rb
|
|
43
|
+
- lib/forge_ops_tracker/version.rb
|
|
44
|
+
homepage: https://getforgeops.net
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
homepage_uri: https://getforgeops.net
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.2'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.11
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: Rails exception reporting client for a self-hosted ForgeOps tracker
|
|
67
|
+
test_files: []
|