errsight 0.2.2
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 +163 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/errsight.gemspec +26 -0
- data/lib/errsight/backtrace.rb +117 -0
- data/lib/errsight/capture_middleware.rb +95 -0
- data/lib/errsight/client.rb +241 -0
- data/lib/errsight/configuration.rb +57 -0
- data/lib/errsight/hub.rb +53 -0
- data/lib/errsight/integrations/active_job.rb +175 -0
- data/lib/errsight/integrations/active_record.rb +94 -0
- data/lib/errsight/integrations/rails_error_reporter.rb +107 -0
- data/lib/errsight/logger.rb +85 -0
- data/lib/errsight/middleware.rb +16 -0
- data/lib/errsight/railtie.rb +198 -0
- data/lib/errsight/scope.rb +166 -0
- data/lib/errsight/sidekiq.rb +248 -0
- data/lib/errsight/source_context.rb +107 -0
- data/lib/errsight/version.rb +3 -0
- data/lib/errsight.rb +193 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6a7079dd5ddd0a725362a26c5d27610b7b45211ba86d3536bd47bae927a63880
|
|
4
|
+
data.tar.gz: 4b8343248a41207ff06a94785dda20f055c9fef026904ee9fc37ff9ccc45e45b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 35f7f650aa33896bb8438481f0982f7d9222e9027e29a481505f33f17b6c4100e4de9d654e044bd82554788f74d1362b5a615eb941637a6ab06291d5040bb597
|
|
7
|
+
data.tar.gz: dd8dc8cdff1cdfedce818896e6c1deb8c871969ed01f4cafe639eb6d6b4067bc90ae208b9523c80adef46ed2f655645f753209b75859298200da4b7f3835be04
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-05-05
|
|
9
|
+
|
|
10
|
+
A foundational release that hardens the SDK for production Rails apps and
|
|
11
|
+
adds the integrations that distinguish ErrSight as a Rails-native error
|
|
12
|
+
tracker. Several long-standing bugs that silently dropped events on
|
|
13
|
+
Puma cluster mode and during shutdown are fixed, and PII handling is
|
|
14
|
+
significantly stricter.
|
|
15
|
+
|
|
16
|
+
Recommended for everyone on `0.1.x` — there are no breaking API changes
|
|
17
|
+
beyond a default behavior flip on `attach_to_rails_logger` (see Changed).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Sidekiq integration.** Server middleware captures job exceptions with
|
|
22
|
+
structured context (`sidekiq.worker`, `sidekiq.queue`, `sidekiq.jid`,
|
|
23
|
+
`sidekiq.retry_count`) plus filtered job args and timestamps. Client
|
|
24
|
+
middleware propagates `user`, `tags`, and manual breadcrumbs from the
|
|
25
|
+
enqueueing request into the job payload so a job's errors carry the
|
|
26
|
+
right context. Auto-wired by the Railtie when Sidekiq is loaded; no
|
|
27
|
+
manual configuration required. Job args are scrubbed through
|
|
28
|
+
`ActiveSupport::ParameterFilter` honoring the host's
|
|
29
|
+
`Rails.application.config.filter_parameters`. Args > 4 KB and runs of
|
|
30
|
+
arbitrary objects are truncated defensively.
|
|
31
|
+
|
|
32
|
+
- **SQL breadcrumbs via `sql.active_record`.** Every non-cached,
|
|
33
|
+
non-schema query becomes a breadcrumb on the current scope, so a
|
|
34
|
+
captured `ActiveRecord::RecordNotFound` ships with the queries that
|
|
35
|
+
ran before it. SQL is truncated at 2 KB. Bind values are *off* by
|
|
36
|
+
default — they often carry PII (emails, tokens, customer IDs) — and
|
|
37
|
+
can be enabled with `breadcrumbs_active_record_capture_binds = true`.
|
|
38
|
+
Disable entirely with `breadcrumbs_active_record = false`.
|
|
39
|
+
|
|
40
|
+
- **`Rails.error.subscribe` integration on Rails 7+.** Catches errors
|
|
41
|
+
from Active Job (after retries are exhausted), Active Storage, Action
|
|
42
|
+
Mailer, Action Cable, and any explicit `Rails.error.handle / .record /
|
|
43
|
+
.report` call in app code. Tags each capture with
|
|
44
|
+
`rails.error.severity`, `rails.error.handled`, `rails.error.source`.
|
|
45
|
+
Caller-supplied `context:` flows into `metadata[:rails_error_context]`.
|
|
46
|
+
Deduplicated against the existing middleware/notifications path via the
|
|
47
|
+
thread-local seen-set so a single 500 doesn't fan out into multiple
|
|
48
|
+
issues.
|
|
49
|
+
|
|
50
|
+
- **Exception cause chain.** `capture_exception` now walks
|
|
51
|
+
`Exception#cause` (depth-capped at 5, cycle-protected) and ships each
|
|
52
|
+
cause as `metadata[:exception_causes]` with `class`, `message`, and
|
|
53
|
+
the first 20 backtrace frames. A `RuntimeError` rescued from a
|
|
54
|
+
`Net::ReadTimeout` now reports both, not just the outer wrapper.
|
|
55
|
+
|
|
56
|
+
- **`before_send` callback** for final-mile filtering. Receives the
|
|
57
|
+
event hash, returns a (possibly modified) hash to send, or `nil` to
|
|
58
|
+
drop. If the callback raises, the event is sent through unmodified
|
|
59
|
+
rather than silently dropped — a buggy filter shouldn't mask
|
|
60
|
+
production errors. Enables PII scrubbing, error suppression, and
|
|
61
|
+
per-tenant routing without touching the SDK.
|
|
62
|
+
|
|
63
|
+
Errsight.configure do |c|
|
|
64
|
+
c.before_send = ->(event) {
|
|
65
|
+
event[:metadata].delete(:credit_card)
|
|
66
|
+
event
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
- **Per-thread scope stack with `Errsight.with_scope { … }`.** Push/pop
|
|
71
|
+
primitive that lets callers isolate `set_user` / `set_tag` /
|
|
72
|
+
`add_breadcrumb` to a block. The Rails request middleware and Sidekiq
|
|
73
|
+
server middleware use this internally to scope state to a single
|
|
74
|
+
request or job.
|
|
75
|
+
|
|
76
|
+
- **`Errsight::Scope`** and **`Errsight::Hub`** as public classes for
|
|
77
|
+
scope management, with `Scope#to_h` / `Scope.from_h` for cross-process
|
|
78
|
+
propagation.
|
|
79
|
+
|
|
80
|
+
- **`configuration.shutdown_timeout`** (default `5` seconds) — bound on
|
|
81
|
+
how long `Errsight.client.shutdown!` will wait for the flush thread
|
|
82
|
+
to drain before killing it.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **`attach_to_rails_logger` now defaults to `false`.** Previously
|
|
87
|
+
`true`, which forwarded every `Rails.logger.warn` call as an event.
|
|
88
|
+
In practice this buried genuine errors under framework deprecation
|
|
89
|
+
noise and burned customer event quota for what belongs in a log
|
|
90
|
+
aggregator, not an error tracker. Customers who want log forwarding
|
|
91
|
+
can opt back in:
|
|
92
|
+
|
|
93
|
+
Errsight.configure { |c| c.attach_to_rails_logger = true }
|
|
94
|
+
|
|
95
|
+
- **Breadcrumbs split into two ring buffers** (50 manual + 30 DB)
|
|
96
|
+
instead of a single 50-cap ring. A request that runs 500 queries can
|
|
97
|
+
no longer evict the user's manual breadcrumbs. `Scope#breadcrumbs`
|
|
98
|
+
returns a merged, timestamp-sorted view; the split is internal.
|
|
99
|
+
|
|
100
|
+
- **`set_user` / `set_tag` / `add_breadcrumb` operate on the current
|
|
101
|
+
scope** (top of the hub stack) rather than `Thread.current` directly.
|
|
102
|
+
Existing code keeps working — the public API is unchanged — but state
|
|
103
|
+
no longer leaks across requests on long-lived Puma threads.
|
|
104
|
+
|
|
105
|
+
- **Cross-process scope propagation only ships manual breadcrumbs.** DB
|
|
106
|
+
breadcrumbs stay process-local — the receiving worker collects its
|
|
107
|
+
own from its own queries. Affects Sidekiq job payloads.
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
- **PII leak across requests.** `Errsight.set_user(user)` previously
|
|
112
|
+
stored on `Thread.current` and was never auto-cleared. On long-lived
|
|
113
|
+
Puma threads, request B's exceptions could be tagged with request A's
|
|
114
|
+
user. Now scoped to a per-request scope frame that's pushed by the
|
|
115
|
+
Rack middleware and popped on exit — even if the controller raises
|
|
116
|
+
and never calls `clear_user`. **This was a real cross-user PII bug
|
|
117
|
+
in `0.1.x`.**
|
|
118
|
+
|
|
119
|
+
- **Puma cluster mode silently dropped all events.** The SDK's flush
|
|
120
|
+
thread is started in `Client#initialize`, which runs in the Puma
|
|
121
|
+
primary at boot. Threads don't survive `fork()`, so worker processes
|
|
122
|
+
inherited a dead thread reference and never delivered any captured
|
|
123
|
+
event. The client now detects fork via `Process.pid` change at
|
|
124
|
+
`enqueue` time and rebuilds the queue, mutexes, HTTP connection, and
|
|
125
|
+
flush thread. Verified end-to-end with a real `fork()` test.
|
|
126
|
+
|
|
127
|
+
- **Brutal shutdown via `flush_thread.exit`.** On `SIGTERM`, the old
|
|
128
|
+
code killed the flush thread mid-execution — possibly mid-HTTP
|
|
129
|
+
request — and called `flush!` on whatever queue state was left. Now
|
|
130
|
+
signals shutdown via a `Mutex + ConditionVariable`, joins with a
|
|
131
|
+
`shutdown_timeout` cap, falls back to `kill` only if a send is hung,
|
|
132
|
+
and runs a final drain on the way out so events queued during the
|
|
133
|
+
last flush interval aren't lost.
|
|
134
|
+
|
|
135
|
+
- **429 rate-limit response parked the flush thread for up to 60 s.**
|
|
136
|
+
`sleep retry_after` blocked the only worker; new events filled the
|
|
137
|
+
queue past `max_queue_size` and were silently dropped via
|
|
138
|
+
`enqueue`'s overflow path. The 429 path now sets a
|
|
139
|
+
`@rate_limited_until` timestamp; the flush thread keeps ticking and
|
|
140
|
+
stays responsive to shutdown. `Retry-After` is capped at 600 s as
|
|
141
|
+
defense against an upstream returning an absurd value.
|
|
142
|
+
|
|
143
|
+
- **`shutdown!` raised `NoMethodError`** when no HTTP request had ever
|
|
144
|
+
fired (the lazily-initialized `@http_mutex` was nil). Now goes
|
|
145
|
+
through the `http_mutex` accessor that lazy-inits.
|
|
146
|
+
|
|
147
|
+
- **Sidekiq client middleware** was building its own scope snapshot and
|
|
148
|
+
would have leaked DB breadcrumbs across process boundaries. Now
|
|
149
|
+
delegates to `Scope#to_h`, which is the canonical serializer.
|
|
150
|
+
|
|
151
|
+
### Security
|
|
152
|
+
|
|
153
|
+
- **PII scrubbing hook** (`before_send`) lets compliance-conscious
|
|
154
|
+
customers strip sensitive fields before events leave the process.
|
|
155
|
+
- **Sidekiq job args** are filtered through
|
|
156
|
+
`ActiveSupport::ParameterFilter` so passwords, tokens, and other
|
|
157
|
+
filtered fields configured by the host don't leak into error reports.
|
|
158
|
+
- **Bind values in SQL breadcrumbs** are off by default. They often
|
|
159
|
+
carry PII; opt in only when the customer has confirmed the use case.
|
|
160
|
+
|
|
161
|
+
## [0.1.6] - 2026-04-XX
|
|
162
|
+
|
|
163
|
+
Earlier releases — see git history.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Errsight
|
|
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,120 @@
|
|
|
1
|
+
# errsight-ruby
|
|
2
|
+
|
|
3
|
+
Ruby/Rails client for [ErrSight](https://errsight.com) error tracking. Captures exceptions and log entries and ships them to the ErrSight API in a background thread.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby >= 3.0
|
|
8
|
+
- Rails 6+ (optional — also works in plain Ruby)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Add to your `Gemfile`:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "errsight"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then run:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
### Rails
|
|
27
|
+
|
|
28
|
+
Create an initializer at `config/initializers/errsight.rb`:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Errsight.configure do |config|
|
|
32
|
+
config.api_key = ENV["ERRSIGHT_API_KEY"] # required
|
|
33
|
+
config.environment = Rails.env # default: ENV["ERRSIGHT_ENV"] || "production"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That is all you need. The Railtie handles the rest automatically:
|
|
38
|
+
|
|
39
|
+
- Attaches to `Rails.logger` so every log line is forwarded to ErrSight.
|
|
40
|
+
- Subscribes to `process_action.action_controller` notifications to capture unhandled exceptions with request context (controller, action, path, params, current user).
|
|
41
|
+
- Calls `Errsight.client.shutdown!` on process exit to drain the queue.
|
|
42
|
+
|
|
43
|
+
### Plain Ruby
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
require "errsight"
|
|
47
|
+
|
|
48
|
+
Errsight.configure do |config|
|
|
49
|
+
config.api_key = ENV["ERRSIGHT_API_KEY"]
|
|
50
|
+
config.environment = "production"
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Environment variables
|
|
55
|
+
|
|
56
|
+
| Variable | Default | Description |
|
|
57
|
+
|-------------------|--------------------------|--------------------------------------|
|
|
58
|
+
| `ERRSIGHT_API_KEY` | — | Your project API key (required) |
|
|
59
|
+
| `ERRSIGHT_ENV` | `"production"` | Environment tag attached to events |
|
|
60
|
+
|
|
61
|
+
### All configuration options
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Errsight.configure do |config|
|
|
65
|
+
config.api_key = ENV["ERRSIGHT_API_KEY"]
|
|
66
|
+
config.environment = "production"
|
|
67
|
+
config.min_level = :warning # :debug | :info | :warning | :error | :fatal
|
|
68
|
+
config.host = "https://errsight.com"
|
|
69
|
+
config.timeout = 5 # HTTP timeout in seconds
|
|
70
|
+
config.batch_size = 10 # events per HTTP request
|
|
71
|
+
config.flush_interval = 2 # background flush cadence in seconds
|
|
72
|
+
config.max_queue_size = 1_000 # drop events when queue exceeds this
|
|
73
|
+
config.attach_to_rails_logger = true # broadcast Rails.logger to ErrSight
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Capture an exception
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
begin
|
|
83
|
+
do_something_risky
|
|
84
|
+
rescue => e
|
|
85
|
+
Errsight.capture_exception(e, metadata: { user_id: current_user.id })
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Log a message directly
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Errsight.log(level: :error, message: "Payment gateway timeout", metadata: { order_id: 42 })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Use as a Logger sink
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
logger = Errsight::Logger.new # standalone — forwards to API only
|
|
99
|
+
logger = Errsight::Logger.new($stdout) # tee — writes to $stdout AND the API
|
|
100
|
+
|
|
101
|
+
logger.warn "Cache miss"
|
|
102
|
+
logger.error "Unprocessable entity"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Rails — automatic exception capture
|
|
106
|
+
|
|
107
|
+
In a Rails app the Railtie automatically captures every unhandled exception raised during a controller action. Each event ships with:
|
|
108
|
+
|
|
109
|
+
- **User context** (top-level `user` block): `id`, `email`, `username` from the signed-in Devise/Warden user (any scope, including ActiveAdmin), plus `ip_address` from `request.remote_ip` — populated even for anonymous requests.
|
|
110
|
+
- **Tags** (filterable in the UI): `controller`, `action`, `request_method`, `status`, `ruby_version`, `rails_version`, `hostname`.
|
|
111
|
+
- **Metadata**: `path`, `full_path`, `format`, `duration`, filtered `params` (respects Rails' `filter_parameters`), `exception_class`.
|
|
112
|
+
- **Backtrace**: full `exception.backtrace`.
|
|
113
|
+
|
|
114
|
+
No additional code is required.
|
|
115
|
+
|
|
116
|
+
## How it works
|
|
117
|
+
|
|
118
|
+
Events are pushed onto an in-memory queue and flushed to the API in batches by a background thread every `flush_interval` seconds (default: 2 s). A flush also triggers immediately when the queue reaches `batch_size`. On process exit the queue is drained synchronously before the process terminates.
|
|
119
|
+
|
|
120
|
+
The HTTP transport uses `Net::HTTP` with no extra dependencies beyond `concurrent-ruby` for thread-safe queue operations.
|
data/errsight.gemspec
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require_relative "lib/errsight/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "errsight"
|
|
5
|
+
spec.version = Errsight::VERSION
|
|
6
|
+
spec.authors = ["Errsight"]
|
|
7
|
+
spec.email = ["support@errsight.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "Ruby/Rails client for Errsight error tracking"
|
|
10
|
+
spec.description = "A lightweight Ruby gem that hooks into Rails.logger and sends logs/errors to the Errsight API."
|
|
11
|
+
spec.homepage = "https://errsight.com"
|
|
12
|
+
spec.license = "MIT"
|
|
13
|
+
spec.required_ruby_version = ">= 3.0"
|
|
14
|
+
|
|
15
|
+
spec.metadata = {
|
|
16
|
+
"homepage_uri" => "https://errsight.com",
|
|
17
|
+
"source_code_uri" => "https://github.com/errsight/errsight-ruby",
|
|
18
|
+
"bug_tracker_uri" => "https://github.com/errsight/errsight-ruby/issues",
|
|
19
|
+
"changelog_uri" => "https://github.com/errsight/errsight-ruby/blob/main/CHANGELOG.md"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spec.files = Dir["lib/**/*", "LICENSE", "README.md", "CHANGELOG.md", "errsight.gemspec"]
|
|
23
|
+
spec.require_paths = ["lib"]
|
|
24
|
+
|
|
25
|
+
spec.add_dependency "concurrent-ruby", "~> 1.0"
|
|
26
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Parses Ruby backtrace strings into structured frames and classifies each
|
|
3
|
+
# frame as in_app (customer code) vs framework/gem.
|
|
4
|
+
#
|
|
5
|
+
# Sentry-ruby was the reference for the parser regex and in_app strategy
|
|
6
|
+
# (study + reimplement, not copy). Their `Sentry::Backtrace` handles the
|
|
7
|
+
# same edge cases — JIT frames, eval'd code, native extensions — so the
|
|
8
|
+
# patterns are similar by necessity. The implementation is ours; the bug
|
|
9
|
+
# surface is ours to maintain.
|
|
10
|
+
module Backtrace
|
|
11
|
+
# Ruby 3.4+ formats methods with single quotes — "in 'method'".
|
|
12
|
+
# Earlier Ruby uses backticks — "in `method'". The character class
|
|
13
|
+
# accepts either opener; the closer is always a single quote.
|
|
14
|
+
RUBY_FRAME = %r{
|
|
15
|
+
\A
|
|
16
|
+
(?<file>.+?)
|
|
17
|
+
:(?<line>\d+)
|
|
18
|
+
(?::in\s+
|
|
19
|
+
['`](?<method>.+)'
|
|
20
|
+
)?
|
|
21
|
+
\z
|
|
22
|
+
}x
|
|
23
|
+
|
|
24
|
+
# Cap on frames per event. A pathological infinite-recursion crash can
|
|
25
|
+
# produce 10k+ frames; without a cap the event blows past the 512 KB
|
|
26
|
+
# ingestion limit and gets rejected. Sentry caps at 50; we match.
|
|
27
|
+
MAX_FRAMES = 50
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Parses an Array<String> backtrace into Array<Hash> structured frames,
|
|
31
|
+
# most-recent-first (matching exception.backtrace's natural order).
|
|
32
|
+
def parse(lines, project_root: nil, gem_paths: nil)
|
|
33
|
+
return [] unless lines.is_a?(Array)
|
|
34
|
+
project_root ||= default_project_root
|
|
35
|
+
gem_paths ||= default_gem_paths
|
|
36
|
+
|
|
37
|
+
lines.first(MAX_FRAMES).filter_map do |line|
|
|
38
|
+
parse_line(line, project_root: project_root, gem_paths: gem_paths)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse_line(line, project_root:, gem_paths:)
|
|
43
|
+
return nil unless line.is_a?(String)
|
|
44
|
+
match = RUBY_FRAME.match(line)
|
|
45
|
+
return nil unless match
|
|
46
|
+
|
|
47
|
+
abs_path = match[:file]
|
|
48
|
+
{
|
|
49
|
+
filename: relative_filename(abs_path, project_root),
|
|
50
|
+
abs_path: abs_path,
|
|
51
|
+
lineno: match[:line].to_i,
|
|
52
|
+
function: match[:method],
|
|
53
|
+
in_app: in_app?(abs_path, project_root, gem_paths)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An "in_app" frame is customer code — the kind of frame the issue UI
|
|
58
|
+
# should highlight, expand, and show source context for. Framework
|
|
59
|
+
# frames (Rails, gems) collapse into a "show 14 framework frames"
|
|
60
|
+
# group so the eye lands on what actually broke.
|
|
61
|
+
#
|
|
62
|
+
# The order of checks matters: gem_paths first because Bundler can
|
|
63
|
+
# vendor gems inside the project tree (vendor/bundle), so a frame at
|
|
64
|
+
# /app/vendor/bundle/gems/foo/foo.rb starts with project_root but is
|
|
65
|
+
# not in_app.
|
|
66
|
+
def in_app?(abs_path, project_root, gem_paths)
|
|
67
|
+
return false if abs_path.nil? || abs_path.empty?
|
|
68
|
+
# Internal frames (<internal:...>, <main>, (eval)) aren't files we
|
|
69
|
+
# can show source context for — treat as not-in-app.
|
|
70
|
+
return false if abs_path.start_with?("<", "(")
|
|
71
|
+
return false if gem_paths.any? { |p| p && abs_path.start_with?(p) }
|
|
72
|
+
return false if project_root.nil? || project_root.empty?
|
|
73
|
+
abs_path.start_with?(project_root)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def relative_filename(abs_path, project_root)
|
|
77
|
+
return abs_path if abs_path.nil?
|
|
78
|
+
return abs_path if project_root.nil? || project_root.empty?
|
|
79
|
+
return abs_path unless abs_path.start_with?(project_root)
|
|
80
|
+
# Strip "<project_root>/" prefix; leaves "app/models/user.rb" etc.
|
|
81
|
+
rest = abs_path[project_root.size..]
|
|
82
|
+
rest.sub(%r{\A/}, "")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_project_root
|
|
86
|
+
if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
87
|
+
::Rails.root.to_s
|
|
88
|
+
else
|
|
89
|
+
Dir.pwd
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Bundler.bundle_path can sit inside the project (vendor/bundle), so
|
|
94
|
+
# we include it AND Gem.path. Anything inside any of these is "not
|
|
95
|
+
# customer code." Memoization is per-process; these paths don't move
|
|
96
|
+
# at runtime.
|
|
97
|
+
def default_gem_paths
|
|
98
|
+
return @default_gem_paths if defined?(@default_gem_paths)
|
|
99
|
+
paths = []
|
|
100
|
+
paths.concat(Gem.path.map(&:to_s)) if defined?(::Gem)
|
|
101
|
+
begin
|
|
102
|
+
paths << ::Bundler.bundle_path.to_s if defined?(::Bundler) && ::Bundler.respond_to?(:bundle_path)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
# Bundler.bundle_path can raise if Bundler isn't fully loaded
|
|
105
|
+
# (some test harnesses, gem-from-source setups). Ignore.
|
|
106
|
+
end
|
|
107
|
+
@default_gem_paths = paths.compact.uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Test/dev only: drop the memoized paths so a test that mutates
|
|
111
|
+
# Rails.root or Bundler can re-derive.
|
|
112
|
+
def reset_defaults!
|
|
113
|
+
remove_instance_variable(:@default_gem_paths) if defined?(@default_gem_paths)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Errsight
|
|
2
|
+
# Catches exceptions raised by any inner middleware/controller before
|
|
3
|
+
# ActionDispatch::DebugExceptions converts them into an HTTP response, so
|
|
4
|
+
# errors that occur outside the controller (e.g. ActiveRecord::Migration::
|
|
5
|
+
# CheckPending, Rack middleware, routing) are still reported with a real
|
|
6
|
+
# backtrace. Inserted *after* DebugExceptions in the stack so the dev/error
|
|
7
|
+
# page still renders normally on re-raise.
|
|
8
|
+
class CaptureMiddleware
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
# Push a per-request scope so set_user/set_tag/add_breadcrumb calls made
|
|
16
|
+
# by app code are isolated to this request. Capture happens *inside* the
|
|
17
|
+
# block so the exception is tagged with the request's scope before pop.
|
|
18
|
+
Errsight.with_scope do
|
|
19
|
+
begin
|
|
20
|
+
@app.call(env)
|
|
21
|
+
rescue Exception => exception
|
|
22
|
+
capture(exception, env, started)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
ensure
|
|
27
|
+
Thread.current[:errsight_captured_exceptions] = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def capture(exception, env, started_monotonic)
|
|
33
|
+
seen = Thread.current[:errsight_captured_exceptions] ||= []
|
|
34
|
+
return if seen.include?(exception.object_id)
|
|
35
|
+
seen << exception.object_id
|
|
36
|
+
|
|
37
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_monotonic) * 1000).round(2)
|
|
38
|
+
request = build_request(env)
|
|
39
|
+
|
|
40
|
+
metadata = build_metadata(request, env, duration_ms)
|
|
41
|
+
user_ctx = (Errsight::Railtie.build_user_context(request) if defined?(Errsight::Railtie) && request)
|
|
42
|
+
tags = build_tags(request, env)
|
|
43
|
+
|
|
44
|
+
Errsight.capture_exception(exception, metadata: metadata, user: user_ctx, tags: tags)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
# Never let our own capture failure suppress the original exception.
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_request(env)
|
|
50
|
+
return nil unless defined?(ActionDispatch::Request)
|
|
51
|
+
ActionDispatch::Request.new(env)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_metadata(request, env, duration_ms)
|
|
57
|
+
meta = { duration: duration_ms }
|
|
58
|
+
|
|
59
|
+
if request
|
|
60
|
+
meta[:path] = request.path
|
|
61
|
+
meta[:full_path] = (request.url rescue nil)
|
|
62
|
+
meta[:format] = (request.formats.first&.to_s rescue nil)
|
|
63
|
+
|
|
64
|
+
if %w[POST PATCH PUT DELETE].include?(request.request_method)
|
|
65
|
+
begin
|
|
66
|
+
filtered = request.filtered_parameters
|
|
67
|
+
.except("controller", "action", "format", "authenticity_token")
|
|
68
|
+
meta[:params] = filtered unless filtered.empty?
|
|
69
|
+
rescue StandardError
|
|
70
|
+
# filtered_parameters can raise on malformed bodies — ignore
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
meta[:path] = env["PATH_INFO"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
meta.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_tags(request, env)
|
|
81
|
+
params = env["action_dispatch.request.path_parameters"] || {}
|
|
82
|
+
tags = {
|
|
83
|
+
"controller" => params[:controller],
|
|
84
|
+
"action" => params[:action],
|
|
85
|
+
"request_method" => request&.request_method || env["REQUEST_METHOD"],
|
|
86
|
+
"ruby_version" => RUBY_VERSION
|
|
87
|
+
}
|
|
88
|
+
tags["rails_version"] = Rails.version if defined?(Rails) && Rails.respond_to?(:version)
|
|
89
|
+
if defined?(Errsight::Railtie) && Errsight::Railtie::HOSTNAME
|
|
90
|
+
tags["hostname"] = Errsight::Railtie::HOSTNAME
|
|
91
|
+
end
|
|
92
|
+
tags.compact.reject { |_, v| v.to_s.strip.empty? }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|