goodsorcery 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/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +299 -0
- data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
- data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
- data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
- data/lib/goodsorcery/backtrace/code_context.rb +27 -0
- data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
- data/lib/goodsorcery/backtrace/parser.rb +38 -0
- data/lib/goodsorcery/backtrace/paths.rb +42 -0
- data/lib/goodsorcery/bundler_plugin.rb +117 -0
- data/lib/goodsorcery/client.rb +180 -0
- data/lib/goodsorcery/configuration/validation.rb +59 -0
- data/lib/goodsorcery/configuration.rb +204 -0
- data/lib/goodsorcery/defaults.rb +40 -0
- data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
- data/lib/goodsorcery/delivery/async.rb +122 -0
- data/lib/goodsorcery/delivery/backoff.rb +20 -0
- data/lib/goodsorcery/delivery/custom.rb +27 -0
- data/lib/goodsorcery/delivery/event_queue.rb +124 -0
- data/lib/goodsorcery/delivery/sync.rb +27 -0
- data/lib/goodsorcery/delivery/test.rb +30 -0
- data/lib/goodsorcery/delivery/worker.rb +71 -0
- data/lib/goodsorcery/delivery.rb +39 -0
- data/lib/goodsorcery/delivery_job.rb +26 -0
- data/lib/goodsorcery/env_flag.rb +24 -0
- data/lib/goodsorcery/environment_gate.rb +61 -0
- data/lib/goodsorcery/errors.rb +14 -0
- data/lib/goodsorcery/event/breadcrumb.rb +32 -0
- data/lib/goodsorcery/event/builder.rb +77 -0
- data/lib/goodsorcery/event/cause_chain.rb +39 -0
- data/lib/goodsorcery/event/exception_section.rb +68 -0
- data/lib/goodsorcery/event/fields.rb +61 -0
- data/lib/goodsorcery/event/job_section.rb +34 -0
- data/lib/goodsorcery/event/request_section.rb +41 -0
- data/lib/goodsorcery/event/runtime_context.rb +52 -0
- data/lib/goodsorcery/event/tag_set.rb +41 -0
- data/lib/goodsorcery/event/user_section.rb +36 -0
- data/lib/goodsorcery/event_filter.rb +54 -0
- data/lib/goodsorcery/event_pipeline.rb +35 -0
- data/lib/goodsorcery/event_serializer.rb +25 -0
- data/lib/goodsorcery/exception_coercion.rb +18 -0
- data/lib/goodsorcery/extra_filter_probe.rb +32 -0
- data/lib/goodsorcery/ignore_list.rb +15 -0
- data/lib/goodsorcery/install_check.rb +58 -0
- data/lib/goodsorcery/integrations/action_cable.rb +75 -0
- data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
- data/lib/goodsorcery/integrations/active_job.rb +39 -0
- data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
- data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
- data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
- data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
- data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
- data/lib/goodsorcery/integrations/rails.rb +56 -0
- data/lib/goodsorcery/integrations/rake.rb +68 -0
- data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
- data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
- data/lib/goodsorcery/integrations.rb +27 -0
- data/lib/goodsorcery/job_arguments.rb +77 -0
- data/lib/goodsorcery/key_filter.rb +99 -0
- data/lib/goodsorcery/log.rb +55 -0
- data/lib/goodsorcery/masked_key.rb +29 -0
- data/lib/goodsorcery/rack/middleware.rb +57 -0
- data/lib/goodsorcery/rack/request_details.rb +120 -0
- data/lib/goodsorcery/rack/request_enricher.rb +20 -0
- data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
- data/lib/goodsorcery/railtie.rb +29 -0
- data/lib/goodsorcery/release_detector.rb +42 -0
- data/lib/goodsorcery/reported_mark.rb +19 -0
- data/lib/goodsorcery/sanitizer.rb +71 -0
- data/lib/goodsorcery/scope.rb +106 -0
- data/lib/goodsorcery/status_report.rb +50 -0
- data/lib/goodsorcery/tasks.rb +46 -0
- data/lib/goodsorcery/test_event.rb +46 -0
- data/lib/goodsorcery/text.rb +35 -0
- data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
- data/lib/goodsorcery/transport/connection.rb +87 -0
- data/lib/goodsorcery/transport/result.rb +103 -0
- data/lib/goodsorcery/transport.rb +172 -0
- data/lib/goodsorcery/verification.rb +47 -0
- data/lib/goodsorcery/version.rb +6 -0
- data/lib/goodsorcery.rb +176 -0
- data/plugins.rb +6 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2f90528aa71a8593f6598013ea44bc011417b7ab8a860cfaa8e51b64dd377c81
|
|
4
|
+
data.tar.gz: bd17559564af24057ca1101b7e3fa18e57c88d8b32ae407db68200e744e3222c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d194c88af6ca25c1f7e833834f5006d7b8350b928aab29f216c9db2c2374cb374add228d50fc39b03f791961295e118d0942921a8e3f3fe55cd786d8aecff88
|
|
7
|
+
data.tar.gz: 06bbe2bca8e871cd41af581cb078782eecc4ddd925272bac872078a369f4f39b8d83b92529934657a6e6a6fa805921df41c9b54c60c365a6a33d567b1baaa144
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-10)
|
|
4
|
+
|
|
5
|
+
- First release.
|
|
6
|
+
- `GoodSorcery.notify` with scoped context, user, tags, breadcrumbs and transaction.
|
|
7
|
+
- Client-side filtering of sensitive keys (strings, symbols and regexps; values masked as `[FILTERED]`), including URL query strings.
|
|
8
|
+
- Events trimmed to the server's limits before sending; events over 512 KiB are dropped.
|
|
9
|
+
- Deliveries: `:async` (batched background thread, the default), `:sync`, `:active_job`, `:test` and custom objects.
|
|
10
|
+
- Keep-alive, gzip-compressed HTTP transport with retries, `Retry-After` handling and a fallback for servers without the batch endpoint.
|
|
11
|
+
- `GoodSorcery.verify`, the `goodsorcery:verify` and `goodsorcery:test` rake tasks and an opt-in Bundler plugin (`plugin "goodsorcery"`) that runs the install check after `bundle install`.
|
|
12
|
+
- Integrations: Rack middleware (Rails, Sinatra, plain Rack), Railtie with Rails defaults and `filter_parameters`, `Rails.error` subscriber, Action Controller, Active Job, Action Mailer and Action Cable layers, notification breadcrumbs, Sidekiq middleware and error handler, and Rake reporting.
|
|
13
|
+
- A `job` event section with filtered job arguments; `send_job_arguments`, `goodsorcery_arguments false` and `sidekiq_options goodsorcery_arguments: false`.
|
|
14
|
+
- `send_request_params`, `send_request_headers` and `send_session` switches.
|
|
15
|
+
- `bin/rails generate goodsorcery:install [--api-key=KEY]`.
|
|
16
|
+
- Hardening from review: `inspect` never shows the API key; `config.endpoint` must be an http(s) URL without credentials; Rails `filter_parameters` also filter the URL query string; filter regexps also match keys with `-` read as `_` (headers); SQL breadcrumbs have literal values replaced by `?`; OpenStruct and Set values are filtered by key.
|
|
17
|
+
- Robustness from review: a forked child opens its own keep-alive connection; a batch refused with `413` is split in halves; reports raised while a report is being built are dropped instead of recursing; `:active_job` falls back to the background thread when Rails refuses the enqueue; only the 250 sent backtrace frames are parsed; reconfiguring retires the old transport outside the client lock.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Q Nguyen
|
|
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,299 @@
|
|
|
1
|
+
# goodsorcery
|
|
2
|
+
|
|
3
|
+
The Ruby client for the [Good Sorcery](https://app.goodsorcery.com) error tracker. It reports exceptions from Ruby and Rails apps without slowing them down:
|
|
4
|
+
|
|
5
|
+
- Events are built and filtered in the calling thread, then handed to **one background thread** that sends them in **batches** over a keep-alive, gzip-compressed HTTPS connection.
|
|
6
|
+
- Sensitive data is **filtered in-process** before anything leaves your app (the server scrubs again as a second layer).
|
|
7
|
+
- The queue is bounded and **drops rather than blocks** when it is full. Rate limits (`429` + `Retry-After`) and server errors are retried with backoff.
|
|
8
|
+
- It has no runtime dependencies (standard library only). It needs Ruby 3.2 or newer and works with or without Rails.
|
|
9
|
+
- Rails, Rack/Sinatra, Active Job, Action Mailer, Action Cable, Sidekiq and Rake are covered out of the box (see [Integrations](#integrations)).
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "goodsorcery"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bundle install
|
|
20
|
+
export GOODSORCERY_API_KEY=gs_live_... # or use Rails credentials, see below
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
In a Rails app, run the generator. It writes `config/initializers/goodsorcery.rb` (every option, commented out) and, with `--api-key`, stores the key in your encrypted credentials. It never prints the key.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bin/rails generate goodsorcery:install --api-key=gs_live_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Outside Rails, `require "goodsorcery"` and call `GoodSorcery.configure` once at boot.
|
|
30
|
+
|
|
31
|
+
## Reporting errors
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
begin
|
|
35
|
+
charge_card!
|
|
36
|
+
rescue => e
|
|
37
|
+
GoodSorcery.notify(e, context: { order_id: order.id }, tags: { provider: "stripe" })
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
GoodSorcery.notify("Inventory sync skipped 3 SKUs", level: :warning) # a message works too
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`notify` returns the event id (a String) or `nil` when nothing was sent. It **never raises**: internal errors are logged and swallowed.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
GoodSorcery.notify(exception_or_message, context: {}, tags: {}, user: nil, level: nil,
|
|
48
|
+
fingerprint: nil, transaction: nil) # => event_id or nil
|
|
49
|
+
GoodSorcery.with_context(job: "Import") { ... } # context for the block only
|
|
50
|
+
GoodSorcery.context(account_id: 42) # merge into the current scope
|
|
51
|
+
GoodSorcery.user = { id: 1, email: "mara@example.com" } # or a User object
|
|
52
|
+
GoodSorcery.tags(region: "eu")
|
|
53
|
+
GoodSorcery.add_breadcrumb("Cart loaded", category: "custom", level: "info", data: { items: 3 })
|
|
54
|
+
GoodSorcery.flush(5) # wait up to 5 s for the queue to drain
|
|
55
|
+
GoodSorcery.verify # => GoodSorcery::Verification (ok?, message)
|
|
56
|
+
GoodSorcery.test_events / GoodSorcery.clear_test_events # with delivery :test
|
|
57
|
+
GoodSorcery.enabled? # is reporting on in this environment?
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Context, user, tags, transaction and breadcrumbs belong to the **current scope**, which is local to the thread (and fiber). `GoodSorcery.with_scope { |scope| ... }` runs a block with a fresh scope; `GoodSorcery.clear_scope` resets it. Integrations open one scope per request or job.
|
|
61
|
+
|
|
62
|
+
The same exception object is only reported once, even when several integrations see it. `level` is one of `fatal`, `error` (the default), `warning` or `info`. `fingerprint` (up to 10 strings) overrides how events are grouped into issues.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# config/initializers/goodsorcery.rb
|
|
68
|
+
GoodSorcery.configure do |config|
|
|
69
|
+
config.api_key = Rails.application.credentials.dig(:goodsorcery, :api_key)
|
|
70
|
+
config.endpoint = "https://errors.example.com"
|
|
71
|
+
config.filter_params += %i[card_token]
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Change settings inside `GoodSorcery.configure`: it recompiles the filters and rebuilds the delivery when a delivery setting changed. Invalid values raise `ArgumentError` with a clear message.
|
|
76
|
+
|
|
77
|
+
| Setting | Default | Notes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `api_key` | `ENV["GOODSORCERY_API_KEY"]`, then `Rails.application.credentials.dig(:goodsorcery, :api_key)` | Per-environment ingest key. Never logged in full (`inspect` shows it masked). A missing key disables delivery with one warning. |
|
|
80
|
+
| `endpoint` | `ENV["GOODSORCERY_ENDPOINT"]` or `https://app.goodsorcery.com` | Your self-hosted URL (`http://` or `https://`; credentials in the URL are refused). `http://` is fine for localhost. |
|
|
81
|
+
| `environment` | `ENV["GOODSORCERY_ENV"]`, `Rails.env`, `RACK_ENV`, `APP_ENV`, else `"development"` | Used for gating only; the server takes the environment from the key. |
|
|
82
|
+
| `disabled_environments` | `%w[development test]` | Environments that don't report. |
|
|
83
|
+
| `enabled_environments` | `nil` | Allow-list; when set, only these report. |
|
|
84
|
+
| `enabled` | `ENV["GOODSORCERY_ENABLED"]` (`1`/`0`), else `nil` | Hard override of the environment check. |
|
|
85
|
+
| `release` | auto-detected (see below) | Version or commit, up to 64 chars. |
|
|
86
|
+
| `root` | `Dir.pwd` (`Rails.root` in Rails) | Makes backtrace paths app-relative and decides which frames are "in app". |
|
|
87
|
+
| `filter_params` | see [Filtering](#filtering) | Strings/symbols match whole keys; regexps match any part. |
|
|
88
|
+
| `use_rails_filter_parameters` | `true` | Also apply `Rails.application.config.filter_parameters` (Rails integration). |
|
|
89
|
+
| `extra_filters` | `[]` | Objects responding to `filter(hash) -> hash`; see [Extension points](#extension-points). |
|
|
90
|
+
| `ignore_exceptions` | Rails/Rack routing and 4xx errors, `SignalException`, `SystemExit` | Class names; subclasses are ignored too. |
|
|
91
|
+
| `before_send` | `nil` | `->(event) { event }`. Return a (changed) hash, or `nil` to drop. If it raises, the event is dropped and the error class logged. |
|
|
92
|
+
| `tags` | `{}` | Global tags on every event. |
|
|
93
|
+
| `user_method` | `:current_user` | Controller method the Rails integration uses to find the user. |
|
|
94
|
+
| `send_code_context` | `true` | Source lines (±5) around the top 10 in-app frames. |
|
|
95
|
+
| `breadcrumbs` / `max_breadcrumbs` | `true` / `40` | Up to 100. |
|
|
96
|
+
| `report_handled_errors` | `true` | Rails integration: also report `Rails.error.handle` errors. |
|
|
97
|
+
| `send_request_params` / `send_request_headers` / `send_session` | `true` | Leave those parts of the request out entirely. |
|
|
98
|
+
| `send_job_arguments` | `true` | `false` never sends Active Job, mailer or Sidekiq arguments. |
|
|
99
|
+
| `install_check` | `:verify` | `:verify`, `:test_event` or `false`; see [Install check](#install-check). |
|
|
100
|
+
| `logger` | `Logger.new($stderr)` at `WARN` (`Rails.logger` in Rails) | The gem's own messages, prefixed `[goodsorcery]`. |
|
|
101
|
+
| `delivery` | `:async` | See [Delivery](#delivery). |
|
|
102
|
+
| `batch_size` | `25` | Events per request, 1–100. |
|
|
103
|
+
| `flush_interval` | `2.0` s | Longest an event waits in the queue. |
|
|
104
|
+
| `max_queue_size` | `1_000` | Events beyond this are dropped and counted. |
|
|
105
|
+
| `max_retries` | `3` | For `5xx`/network failures (exponential backoff with jitter, capped at 60 s). |
|
|
106
|
+
| `shutdown_timeout` | `5` s | How long the exit hook waits for the queue to drain. |
|
|
107
|
+
| `open_timeout` / `read_timeout` | `2` s / `5` s | HTTP timeouts (writes use `read_timeout`). |
|
|
108
|
+
| `active_job_queue` | `"default"` | Queue for `delivery = :active_job`. |
|
|
109
|
+
|
|
110
|
+
### Delivery
|
|
111
|
+
|
|
112
|
+
| `config.delivery` | What it does | When to use |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `:async` (default) | Pushes events onto a bounded in-memory queue. One background thread (`goodsorcery-worker`) sends them to `POST /api/v1/events/batch` when `batch_size` events are waiting or `flush_interval` has passed. Restarts after `fork`; drains at exit. | Web apps and job workers (almost everyone). |
|
|
115
|
+
| `:sync` | Sends each event immediately in the calling thread. | Scripts, rake tasks, serverless functions, debugging. |
|
|
116
|
+
| `:active_job` | Enqueues `GoodSorcery::DeliveryJob` with the filtered event as JSON; the job sends it and retries `429`/`5xx` (5 attempts). Falls back to `:async` if enqueueing fails. **The filtered event is stored in your job backend.** | Apps that want delivery to survive a crash and already run a durable queue. |
|
|
117
|
+
| `:test` | Keeps events in memory (`GoodSorcery.test_events`). Never touches the network and ignores the environment check. | Your app's test suite. |
|
|
118
|
+
| any object | Must respond to `deliver(event_hash)`; may respond to `flush(timeout)` and `shutdown(timeout)`. | Custom transports. |
|
|
119
|
+
|
|
120
|
+
### Environments
|
|
121
|
+
|
|
122
|
+
Reporting is on when, in this order:
|
|
123
|
+
|
|
124
|
+
1. `config.enabled` (or `GOODSORCERY_ENABLED`) is set: that value wins;
|
|
125
|
+
2. else, if `enabled_environments` is set: `environment` is in it;
|
|
126
|
+
3. else: `environment` is not in `disabled_environments` (default `development` and `test`).
|
|
127
|
+
|
|
128
|
+
When reporting is off, `notify` returns `nil` straight away: no event is built and no thread starts.
|
|
129
|
+
|
|
130
|
+
### Credentials
|
|
131
|
+
|
|
132
|
+
Use one API key per environment (`gs_live_…` for production, `gs_test_…` otherwise). Keep it in an environment variable or Rails credentials, never in the browser:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# bin/rails credentials:edit
|
|
136
|
+
goodsorcery:
|
|
137
|
+
api_key: gs_live_...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Logs and the install check only ever show a masked key, such as `gs_live_…e81a`.
|
|
141
|
+
|
|
142
|
+
### Release
|
|
143
|
+
|
|
144
|
+
`config.release`, else the first present of `GOODSORCERY_RELEASE`, `KAMAL_VERSION`, `HEROKU_SLUG_COMMIT`, `HEROKU_BUILD_COMMIT`, `RENDER_GIT_COMMIT`, `GIT_COMMIT`, `GIT_SHA` and `SOURCE_VERSION`, else the `REVISION` file in the app root (Capistrano). It never shells out to git.
|
|
145
|
+
|
|
146
|
+
## Filtering
|
|
147
|
+
|
|
148
|
+
Filtering applies at any depth to request params, headers, session, the URL query string, job arguments, context, tags and breadcrumb data. The `user` section (`id`, `email`, `username`, `name`) is sent as given, because it identifies affected users; only set the fields you want sent.
|
|
149
|
+
|
|
150
|
+
The defaults mirror Rails' generated `filter_parameters` plus the server's scrub list:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
GoodSorcery::Defaults::FILTER_PARAMS
|
|
154
|
+
# => [/passw/i, /secret/i, /token/i, /_key/i, /api-?key/i, /crypt/i, /salt/i, /certificate/i,
|
|
155
|
+
# /otp/i, /ssn/i, /cvv/i, /cvc/i, /authorization/i, /cookie/i, /csrf/i, /xsrf/i,
|
|
156
|
+
# /session[-_]?id/i, /credit[-_]?card/i, /card[-_]?number/i, /signature/i, /email/i]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- **Strings and symbols** match a whole key, case-insensitively, with `-` and `_` treated as equal: `"secret_key"` matches `secret_key` and `Secret-Key`, but not `my_secret_key`.
|
|
160
|
+
- **Regexps** match any part of a key, as spelled or with `-` read as `_`: `/_key/i` matches `api_key`, `secret_key_base` and the `X-Access-Key` header.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
config.filter_params += %i[card_token ssn] # add exact keys
|
|
164
|
+
config.filter_params += [/_key\z/i, /secret_key/i] # add patterns
|
|
165
|
+
config.filter_params -= [/email/i] # remove a default
|
|
166
|
+
config.filter_params = [/password/i] # replace the defaults entirely
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
In Rails apps, `Rails.application.config.filter_parameters` is applied as well (to the URL query string too), with Rails' own matching rules (`ActiveSupport::ParameterFilter`), unless `use_rails_filter_parameters = false`.
|
|
170
|
+
|
|
171
|
+
Key filtering can't protect a secret passed as a bare positional job argument (`perform(user_id, "s3cr3t")`). Turn arguments off for such jobs, or everywhere with `config.send_job_arguments = false`:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class ChargeCustomerJob < ApplicationJob
|
|
175
|
+
goodsorcery_arguments false # this job and its subclasses
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class LegacyWorker
|
|
179
|
+
include Sidekiq::Job
|
|
180
|
+
sidekiq_options goodsorcery_arguments: false
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Integrations
|
|
185
|
+
|
|
186
|
+
Each integration opens a **fresh scope** for one request, job or channel action, so context, user, tags and breadcrumbs never leak from one to the next. Request and job details are only read **when an error is reported**, so a request or job that succeeds pays almost nothing. Each exception object is reported once, however many integrations see it.
|
|
187
|
+
|
|
188
|
+
| Where the error happened | `transaction` | What is sent |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| Controller / Rack request | `UsersController#update` | `request`: URL (query filtered), method, format, request id, remote IP, params (as Rails parsed them), headers, session (only if the request already loaded it); `context.controller`; the user |
|
|
191
|
+
| Active Job (any adapter) | `ChargeCustomerJob` | `job`: `framework` (`active_job`), `class`, `id`, `provider_job_id`, `queue`, `priority`, `executions`, `enqueued_at`, `scheduled_at`, `arguments`; tag `queue` |
|
|
192
|
+
| Action Mailer `deliver_later` | `UserMailer#welcome` | as Active Job, plus `mailer` and `action`; `arguments` holds the mailer method's `args` and `params` |
|
|
193
|
+
| Sidekiq (jobs not wrapped by Active Job) | worker class | `job`: `framework` (`sidekiq`), `class`, `id` (jid), `queue`, `retry_count`, `enqueued_at`, `arguments`; tag `queue` |
|
|
194
|
+
| Action Cable | `ChatChannel#speak` | `request`: the connection's URL, request id, remote IP and headers; params `{ "channel" => subscription params, "data" => action data }` |
|
|
195
|
+
| Rake | `rake db:migrate` | `context.rake`: the failing task and its arguments |
|
|
196
|
+
| `Rails.error.report` / `handle` | unchanged | `context.rails_error`: the reporter's `context:` plus `source` |
|
|
197
|
+
|
|
198
|
+
Job arguments are serialised the way the job system sees them, without loading anything: records become their GlobalID (`"gid://shop/Customer/88123"`), hashes keep their keys (so filtering applies), symbols become strings, times ISO 8601, and any other object a `"#<ClassName>"` placeholder. `#inspect` is never called. Sizes and depth are capped like request params.
|
|
199
|
+
|
|
200
|
+
### Rails
|
|
201
|
+
|
|
202
|
+
Nothing to set up beyond the initializer. The Railtie:
|
|
203
|
+
|
|
204
|
+
- uses `Rails.root`, `Rails.env`, `Rails.logger` and the `REVISION` file in `Rails.root` as defaults, and applies `filter_parameters`;
|
|
205
|
+
- inserts `GoodSorcery::Rack::Middleware` right after `ActionDispatch::ShowExceptions`, so it sees exceptions before Rails renders the error page. Errors that a lower layer turned into a 5xx response (`env["action_dispatch.exception"]`) are reported too;
|
|
206
|
+
- subscribes to `Rails.error`: unhandled errors are reported at level `error`, handled ones (`Rails.error.handle`, `Rails.error.report(handled: true)`) at their severity (`warning` by default). Set `report_handled_errors = false` to skip handled ones;
|
|
207
|
+
- finds the user with the controller's `user_method` (default `current_user`, private methods too), else `Current.user`, and sends only `id`, `email`, `username` and `name`;
|
|
208
|
+
- wraps Active Job's `perform_now` outside the job's own `rescue_from`/`retry_on`, so a job is reported once when it finally fails, not on every attempt it retries itself. `GoodSorcery::DeliveryJob` is never reported;
|
|
209
|
+
- wraps Action Cable channel subscribe, unsubscribe and actions; errors your channel's `rescue_from` handles are not reported;
|
|
210
|
+
- records breadcrumbs from `ActiveSupport::Notifications` inside requests, jobs and channel actions: `start_processing.action_controller`, `sql.active_record` (without `SCHEMA`, `CACHE` and `TRANSACTION` queries; SQL capped at 1,000 chars, with quoted strings and numbers replaced by `?` so inlined values never leave the app), `perform.active_job` and `deliver.action_mailer`. Turn them off with `config.breadcrumbs = false`;
|
|
211
|
+
- loads the `goodsorcery:verify` and `goodsorcery:test` tasks and reports exceptions that end a rake task.
|
|
212
|
+
|
|
213
|
+
### Rack and Sinatra
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# config.ru
|
|
217
|
+
require "goodsorcery"
|
|
218
|
+
GoodSorcery.configure { |config| config.api_key = ENV["GOODSORCERY_API_KEY"] }
|
|
219
|
+
use GoodSorcery::Rack::Middleware
|
|
220
|
+
run MyApp
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The middleware reports exceptions that propagate (and re-raises them) and errors Sinatra turned into a 5xx (`env["sinatra.error"]`). Params are the query string plus a form body only if the app already parsed it: the middleware never reads the request body and never loads a session that wasn't loaded.
|
|
224
|
+
|
|
225
|
+
### Sidekiq
|
|
226
|
+
|
|
227
|
+
Hooked in automatically when Sidekiq is loaded before `goodsorcery` or in a Rails app. Otherwise call `GoodSorcery::Integrations::Sidekiq.install` after requiring both. A server middleware reports jobs that fail (on every attempt, as Sidekiq retries them) and an error handler catches anything raised outside it. Jobs enqueued through Active Job are left to the Active Job layer.
|
|
228
|
+
|
|
229
|
+
### Rake
|
|
230
|
+
|
|
231
|
+
Rails apps get this automatically; elsewhere `require "goodsorcery/tasks"` in your Rakefile. An exception that ends a rake run is reported with `transaction = "rake <tasks>"` (task arguments stay out of the name and go, filtered, to `context.rake`), and the queue is flushed, for up to `shutdown_timeout`, before rake exits.
|
|
232
|
+
|
|
233
|
+
## Install check
|
|
234
|
+
|
|
235
|
+
`bin/rails goodsorcery:verify` (or `rake goodsorcery:verify`) checks your setup on demand; see the end of this section.
|
|
236
|
+
|
|
237
|
+
The gem can also run that check after every `bundle install`. It's a Bundler plugin, so it's opt-in: add `plugin "goodsorcery"` to your Gemfile. Each `bundle install` then boots your app and calls the server, so leave it out if that's too slow or noisy. With the plugin, every `bundle install` runs a short check, but only when `RAILS_ENV`/`RACK_ENV`/`APP_ENV` are unset or `development`, `CI` is unset, `GOODSORCERY_SKIP_INSTALL_CHECK` is unset and the Gemfile depends on `goodsorcery`. It runs `bundle exec ruby` in the app root with a 30-second limit, boots the app (`config/environment.rb` when present, so your initializer applies) and, per `config.install_check`:
|
|
238
|
+
|
|
239
|
+
- `:verify` (default): prints the endpoint, environment (and why reporting is on or off), delivery and masked key, then calls `GET /api/v1/verify`. No event is created.
|
|
240
|
+
- `:test_event`: also sends a real `GoodSorcery::TestError`, even in development.
|
|
241
|
+
- `false`: skipped.
|
|
242
|
+
|
|
243
|
+
It never fails or blocks `bundle install`: problems are printed as single `[goodsorcery]` lines.
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
[goodsorcery] endpoint: https://errors.example.com
|
|
247
|
+
[goodsorcery] environment: development (reporting disabled: environment "development" is in disabled_environments)
|
|
248
|
+
[goodsorcery] delivery: async
|
|
249
|
+
[goodsorcery] api key: gs_test_…e81a
|
|
250
|
+
[goodsorcery] verify: ok, API key gs_test_…e81a is valid for environment "staging" at https://errors.example.com
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The same checks run on demand. Rails apps get the tasks automatically; elsewhere, add `require "goodsorcery/tasks"` to your Rakefile.
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
bin/rails goodsorcery:verify # report + verify, sends nothing
|
|
257
|
+
bin/rails goodsorcery:test # always sends a test exception and prints its id
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Testing your app
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
# test/test_helper.rb
|
|
264
|
+
GoodSorcery.configure { |config| config.delivery = :test }
|
|
265
|
+
|
|
266
|
+
# in a test
|
|
267
|
+
GoodSorcery.clear_test_events
|
|
268
|
+
post checkouts_path, params: { ... }
|
|
269
|
+
event = GoodSorcery.test_events.last
|
|
270
|
+
assert_equal "Payments::DeclinedError", event["exception"]["class"]
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The `:test` delivery ignores the environment check, stores the filtered event hashes exactly as they would be sent, and never touches the network.
|
|
274
|
+
|
|
275
|
+
## Self-hosted servers
|
|
276
|
+
|
|
277
|
+
Point `endpoint` (or `GOODSORCERY_ENDPOINT`) at your instance, for example `https://errors.example.com`. A path prefix such as `https://example.com/tracker` is kept. Servers without the batch endpoint (`404`) are detected once, and the gem then sends one event per request. A batch refused as too large (`413`, for example by a proxy with a smaller body limit) is split in halves and resent.
|
|
278
|
+
|
|
279
|
+
## Extension points
|
|
280
|
+
|
|
281
|
+
These are for integrations and advanced setups:
|
|
282
|
+
|
|
283
|
+
- `config.extra_filters`: objects responding to `filter(hash) -> hash`, applied after the key filter to every filtered section (URL query parameters are checked by filtering a one-value hash at their key path). `ActiveSupport::ParameterFilter` fits as is; the Rails integration adds one for `filter_parameters`.
|
|
284
|
+
- `config.request_extractor`: an object responding to `extract(rack_env) -> Hash` (keys `url`, `method`, `format`, `request_id`, `remote_ip`, `params`, `headers`, `session`). Integrations keep the env in `GoodSorcery.scope.request_env`, so the request is only read when an error is reported. Alternatively they can set `GoodSorcery.scope.request` to a ready hash.
|
|
285
|
+
- `GoodSorcery.scope`, `GoodSorcery.with_scope` and `GoodSorcery.clear_scope` manage the per-thread scope.
|
|
286
|
+
- `scope.enricher`: an object responding to `enrich(scope)`, run once, just before the first event in that scope is built. Integrations use it to fill in `scope.request`, `scope.job`, `scope.user` and `scope.transaction` only when something is reported. `scope.job` takes the `job` section's fields (see [Integrations](#integrations)).
|
|
287
|
+
|
|
288
|
+
## Development
|
|
289
|
+
|
|
290
|
+
```sh
|
|
291
|
+
bin/setup # bundle install, then the whole test suite
|
|
292
|
+
bundle exec rake test # core, then Rack/Sidekiq/Rake, then Rails tests, each in its own process
|
|
293
|
+
bundle exec rubocop
|
|
294
|
+
bin/console
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
Created by [Philip Q Nguyen](https://github.com/philipqnguyen). MIT licensed; see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module GoodSorcery
|
|
6
|
+
module Generators
|
|
7
|
+
# Adds goodsorcery.api_key to the app's encrypted credentials when they
|
|
8
|
+
# can be decrypted. Returns :added, :present (a key is already stored;
|
|
9
|
+
# it is left alone) or :unavailable. Never logs or echoes the key.
|
|
10
|
+
class CredentialsApiKey
|
|
11
|
+
def initialize(credentials)
|
|
12
|
+
@credentials = credentials
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(api_key)
|
|
16
|
+
return :unavailable unless decryptable?
|
|
17
|
+
|
|
18
|
+
yaml = @credentials.read.to_s
|
|
19
|
+
data = parse(yaml)
|
|
20
|
+
return :present if stored_key?(data)
|
|
21
|
+
|
|
22
|
+
@credentials.write(updated_yaml(yaml, data, api_key))
|
|
23
|
+
:added
|
|
24
|
+
rescue StandardError
|
|
25
|
+
:unavailable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def path
|
|
29
|
+
@credentials.content_path.to_s
|
|
30
|
+
rescue StandardError
|
|
31
|
+
"config/credentials.yml.enc"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def decryptable?
|
|
37
|
+
!@credentials.key.to_s.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse(yaml)
|
|
41
|
+
data = YAML.safe_load(yaml, aliases: true, permitted_classes: [Symbol, Date, Time])
|
|
42
|
+
data.is_a?(Hash) ? data : {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def section(data)
|
|
46
|
+
data["goodsorcery"] || data[:goodsorcery]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stored_key?(data)
|
|
50
|
+
section = section(data)
|
|
51
|
+
section.is_a?(Hash) && !(section["api_key"] || section[:api_key]).to_s.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Appends a new section as text, so comments in the file survive; an
|
|
55
|
+
# existing goodsorcery section is merged instead.
|
|
56
|
+
def updated_yaml(yaml, data, api_key)
|
|
57
|
+
return YAML.dump(data.merge("goodsorcery" => section(data).merge("api_key" => api_key))) if section(data)
|
|
58
|
+
|
|
59
|
+
addition = YAML.dump("goodsorcery" => { "api_key" => api_key }).delete_prefix("---\n")
|
|
60
|
+
yaml.empty? || yaml.end_with?("\n") ? "#{yaml}#{addition}" : "#{yaml}\n#{addition}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require_relative "credentials_api_key"
|
|
5
|
+
|
|
6
|
+
module GoodSorcery
|
|
7
|
+
module Generators
|
|
8
|
+
# bin/rails generate goodsorcery:install [--api-key=KEY]
|
|
9
|
+
#
|
|
10
|
+
# Writes config/initializers/goodsorcery.rb and stores the API key in the
|
|
11
|
+
# encrypted credentials when one is given and they can be decrypted. Never
|
|
12
|
+
# prints the key. The Bundler plugin stays opt-in (README, Install check).
|
|
13
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
14
|
+
namespace "goodsorcery:install"
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
desc "Set up Good Sorcery error reporting"
|
|
17
|
+
|
|
18
|
+
class_option :api_key, type: :string, desc: "Ingest API key to store in Rails credentials"
|
|
19
|
+
|
|
20
|
+
KEY_HINT = <<~TEXT
|
|
21
|
+
Add your API key with `bin/rails credentials:edit`:
|
|
22
|
+
|
|
23
|
+
goodsorcery:
|
|
24
|
+
api_key: <your gs_live_... or gs_test_... key>
|
|
25
|
+
|
|
26
|
+
or set the GOODSORCERY_API_KEY environment variable.
|
|
27
|
+
TEXT
|
|
28
|
+
NEXT_STEPS = <<~TEXT
|
|
29
|
+
Check the key and endpoint with `bin/rails goodsorcery:verify` (sends nothing),
|
|
30
|
+
then send a test error with `bin/rails goodsorcery:test`.
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
def create_initializer
|
|
34
|
+
copy_file "initializer.rb", "config/initializers/goodsorcery.rb"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def store_api_key
|
|
38
|
+
api_key = options[:api_key].to_s.strip
|
|
39
|
+
return say(KEY_HINT) if api_key.empty?
|
|
40
|
+
|
|
41
|
+
credentials = CredentialsApiKey.new(::Rails.application.credentials)
|
|
42
|
+
report_credentials(credentials.add(api_key), credentials.path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def print_next_steps
|
|
46
|
+
say(NEXT_STEPS)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def report_credentials(status, path)
|
|
52
|
+
case status
|
|
53
|
+
when :added then say_status(:credentials, "added goodsorcery.api_key to #{relative(path)}")
|
|
54
|
+
when :present then say_status(:credentials, "goodsorcery.api_key is already set; left unchanged", :yellow)
|
|
55
|
+
else say("The credentials could not be decrypted, so the API key was not stored.\n#{KEY_HINT}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def relative(path)
|
|
60
|
+
path.delete_prefix("#{destination_root}/")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Good Sorcery error reporting. Every setting is optional; the defaults are
|
|
4
|
+
# shown. See https://app.goodsorcery.com and the goodsorcery README.
|
|
5
|
+
GoodSorcery.configure do |config|
|
|
6
|
+
# --- Credentials -----------------------------------------------------------
|
|
7
|
+
# Looked up in ENV["GOODSORCERY_API_KEY"], then in Rails credentials
|
|
8
|
+
# (goodsorcery.api_key). Use one key per environment. Never commit it.
|
|
9
|
+
# config.api_key = Rails.application.credentials.dig(:goodsorcery, :api_key)
|
|
10
|
+
# config.endpoint = "https://app.goodsorcery.com" # or ENV["GOODSORCERY_ENDPOINT"]
|
|
11
|
+
|
|
12
|
+
# --- Environments ----------------------------------------------------------
|
|
13
|
+
# config.environment = Rails.env # or ENV["GOODSORCERY_ENV"]
|
|
14
|
+
# config.disabled_environments = %w[development test]
|
|
15
|
+
# config.enabled_environments = %w[production staging] # allow-list instead
|
|
16
|
+
# config.enabled = true # hard override; or GOODSORCERY_ENABLED=1/0
|
|
17
|
+
|
|
18
|
+
# --- Filtering -------------------------------------------------------------
|
|
19
|
+
# Strings/symbols match whole keys (case-insensitive, "-" and "_" equal);
|
|
20
|
+
# regexps match any part of a key. Rails' filter_parameters apply too.
|
|
21
|
+
# config.filter_params += %i[card_token ssn]
|
|
22
|
+
# config.filter_params += [/_key\z/i]
|
|
23
|
+
# config.use_rails_filter_parameters = true
|
|
24
|
+
# config.send_request_params = true
|
|
25
|
+
# config.send_request_headers = true
|
|
26
|
+
# config.send_session = true
|
|
27
|
+
# config.send_job_arguments = true # false = never send job arguments
|
|
28
|
+
|
|
29
|
+
# --- Delivery --------------------------------------------------------------
|
|
30
|
+
# config.delivery = :async # :sync, :active_job, :test or a custom object
|
|
31
|
+
# config.batch_size = 25
|
|
32
|
+
# config.flush_interval = 2.0
|
|
33
|
+
# config.max_queue_size = 1_000
|
|
34
|
+
# config.max_retries = 3
|
|
35
|
+
# config.shutdown_timeout = 5
|
|
36
|
+
# config.open_timeout = 2
|
|
37
|
+
# config.read_timeout = 5
|
|
38
|
+
# config.active_job_queue = "default" # with delivery = :active_job
|
|
39
|
+
|
|
40
|
+
# --- What gets reported ----------------------------------------------------
|
|
41
|
+
# config.release = ENV["GIT_SHA"] # auto-detected from deploy env vars or REVISION
|
|
42
|
+
# config.ignore_exceptions += ["MyApp::NotFound"] # class names; subclasses too
|
|
43
|
+
# config.before_send = ->(event) { event } # change the event, or return nil to drop it
|
|
44
|
+
# config.tags = { region: "eu" }
|
|
45
|
+
# config.user_method = :current_user # controller method that returns the user
|
|
46
|
+
# config.report_handled_errors = true # Rails.error.handle / report
|
|
47
|
+
# config.send_code_context = true
|
|
48
|
+
# config.breadcrumbs = true
|
|
49
|
+
# config.max_breadcrumbs = 40
|
|
50
|
+
|
|
51
|
+
# --- Development -----------------------------------------------------------
|
|
52
|
+
# config.install_check = :verify # after bundle install: :verify, :test_event or false
|
|
53
|
+
# config.logger = Rails.logger
|
|
54
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Backtrace
|
|
5
|
+
# The source lines around a frame: up to 5 before and 5 after.
|
|
6
|
+
class CodeContext
|
|
7
|
+
RADIUS = 5
|
|
8
|
+
MAX_LINE_LENGTH = 500
|
|
9
|
+
|
|
10
|
+
def initialize(line_cache)
|
|
11
|
+
@line_cache = line_cache
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def around(path, line)
|
|
15
|
+
lines = line ? @line_cache.lines(path) : nil
|
|
16
|
+
return nil unless lines && line.between?(1, lines.size)
|
|
17
|
+
|
|
18
|
+
first = [line - RADIUS, 1].max
|
|
19
|
+
last = [line + RADIUS, lines.size].min
|
|
20
|
+
{
|
|
21
|
+
"start_line" => first,
|
|
22
|
+
"lines" => lines[(first - 1)..(last - 1)].map { |text| Text.limit(text, MAX_LINE_LENGTH) }
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Backtrace
|
|
5
|
+
# A small thread-safe LRU cache of source file lines. It only reads files
|
|
6
|
+
# under the app root that are smaller than 1 MB, and remembers misses too.
|
|
7
|
+
class LineCache
|
|
8
|
+
MAX_BYTES = 1_048_576
|
|
9
|
+
MISS = Object.new.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(paths, max_files: 64)
|
|
12
|
+
@paths = paths
|
|
13
|
+
@max_files = max_files
|
|
14
|
+
@entries = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def lines(path)
|
|
19
|
+
cached = fetch(path)
|
|
20
|
+
return cached unless cached.equal?(MISS)
|
|
21
|
+
|
|
22
|
+
store(path, read(path))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def fetch(path)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
next MISS unless @entries.key?(path)
|
|
30
|
+
|
|
31
|
+
@entries[path] = @entries.delete(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def store(path, lines)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@entries[path] = lines
|
|
38
|
+
@entries.shift while @entries.size > @max_files
|
|
39
|
+
end
|
|
40
|
+
lines
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read(path)
|
|
44
|
+
return nil unless readable?(path)
|
|
45
|
+
|
|
46
|
+
File.readlines(path, chomp: true)
|
|
47
|
+
rescue SystemCallError, IOError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def readable?(path)
|
|
52
|
+
path.start_with?("/") && File.expand_path(path) == path && @paths.under_root?(path) &&
|
|
53
|
+
File.file?(path) && File.size(path) < MAX_BYTES
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Backtrace
|
|
5
|
+
# One raw stack frame: absolute (or as-given) path, line number and label.
|
|
6
|
+
Frame = Struct.new(:path, :line, :label)
|
|
7
|
+
|
|
8
|
+
# Reads frames from Thread::Backtrace::Location objects when Ruby kept
|
|
9
|
+
# them, else parses the backtrace strings. Both label formats are
|
|
10
|
+
# understood: Ruby 3.4+ ("in 'Class#method'") and older ("in `method'").
|
|
11
|
+
class Parser
|
|
12
|
+
LINE = /\A(?<path>.+?):(?<line>\d+)(?::in [`'](?<label>.*)')?\z/
|
|
13
|
+
|
|
14
|
+
# The first +limit+ frames; the rest are never parsed, so a deep
|
|
15
|
+
# SystemStackError backtrace costs no more than a short one.
|
|
16
|
+
def frames(exception, limit: nil)
|
|
17
|
+
locations = exception.backtrace_locations
|
|
18
|
+
return locations.first(limit || locations.size).map { |location| from_location(location) } if locations
|
|
19
|
+
|
|
20
|
+
lines = Array(exception.backtrace)
|
|
21
|
+
lines.first(limit || lines.size).filter_map { |line| parse(line.to_s) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse(line)
|
|
25
|
+
match = LINE.match(line)
|
|
26
|
+
return Frame.new(line, nil, nil) unless match
|
|
27
|
+
|
|
28
|
+
Frame.new(match[:path], match[:line].to_i, match[:label])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def from_location(location)
|
|
34
|
+
Frame.new(location.absolute_path || location.path, location.lineno, location.label)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|