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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE +21 -0
  4. data/README.md +299 -0
  5. data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
  6. data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
  7. data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
  8. data/lib/goodsorcery/backtrace/code_context.rb +27 -0
  9. data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
  10. data/lib/goodsorcery/backtrace/parser.rb +38 -0
  11. data/lib/goodsorcery/backtrace/paths.rb +42 -0
  12. data/lib/goodsorcery/bundler_plugin.rb +117 -0
  13. data/lib/goodsorcery/client.rb +180 -0
  14. data/lib/goodsorcery/configuration/validation.rb +59 -0
  15. data/lib/goodsorcery/configuration.rb +204 -0
  16. data/lib/goodsorcery/defaults.rb +40 -0
  17. data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
  18. data/lib/goodsorcery/delivery/async.rb +122 -0
  19. data/lib/goodsorcery/delivery/backoff.rb +20 -0
  20. data/lib/goodsorcery/delivery/custom.rb +27 -0
  21. data/lib/goodsorcery/delivery/event_queue.rb +124 -0
  22. data/lib/goodsorcery/delivery/sync.rb +27 -0
  23. data/lib/goodsorcery/delivery/test.rb +30 -0
  24. data/lib/goodsorcery/delivery/worker.rb +71 -0
  25. data/lib/goodsorcery/delivery.rb +39 -0
  26. data/lib/goodsorcery/delivery_job.rb +26 -0
  27. data/lib/goodsorcery/env_flag.rb +24 -0
  28. data/lib/goodsorcery/environment_gate.rb +61 -0
  29. data/lib/goodsorcery/errors.rb +14 -0
  30. data/lib/goodsorcery/event/breadcrumb.rb +32 -0
  31. data/lib/goodsorcery/event/builder.rb +77 -0
  32. data/lib/goodsorcery/event/cause_chain.rb +39 -0
  33. data/lib/goodsorcery/event/exception_section.rb +68 -0
  34. data/lib/goodsorcery/event/fields.rb +61 -0
  35. data/lib/goodsorcery/event/job_section.rb +34 -0
  36. data/lib/goodsorcery/event/request_section.rb +41 -0
  37. data/lib/goodsorcery/event/runtime_context.rb +52 -0
  38. data/lib/goodsorcery/event/tag_set.rb +41 -0
  39. data/lib/goodsorcery/event/user_section.rb +36 -0
  40. data/lib/goodsorcery/event_filter.rb +54 -0
  41. data/lib/goodsorcery/event_pipeline.rb +35 -0
  42. data/lib/goodsorcery/event_serializer.rb +25 -0
  43. data/lib/goodsorcery/exception_coercion.rb +18 -0
  44. data/lib/goodsorcery/extra_filter_probe.rb +32 -0
  45. data/lib/goodsorcery/ignore_list.rb +15 -0
  46. data/lib/goodsorcery/install_check.rb +58 -0
  47. data/lib/goodsorcery/integrations/action_cable.rb +75 -0
  48. data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
  49. data/lib/goodsorcery/integrations/active_job.rb +39 -0
  50. data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
  51. data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
  52. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
  53. data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
  54. data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
  55. data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
  56. data/lib/goodsorcery/integrations/rails.rb +56 -0
  57. data/lib/goodsorcery/integrations/rake.rb +68 -0
  58. data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
  59. data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
  60. data/lib/goodsorcery/integrations.rb +27 -0
  61. data/lib/goodsorcery/job_arguments.rb +77 -0
  62. data/lib/goodsorcery/key_filter.rb +99 -0
  63. data/lib/goodsorcery/log.rb +55 -0
  64. data/lib/goodsorcery/masked_key.rb +29 -0
  65. data/lib/goodsorcery/rack/middleware.rb +57 -0
  66. data/lib/goodsorcery/rack/request_details.rb +120 -0
  67. data/lib/goodsorcery/rack/request_enricher.rb +20 -0
  68. data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
  69. data/lib/goodsorcery/railtie.rb +29 -0
  70. data/lib/goodsorcery/release_detector.rb +42 -0
  71. data/lib/goodsorcery/reported_mark.rb +19 -0
  72. data/lib/goodsorcery/sanitizer.rb +71 -0
  73. data/lib/goodsorcery/scope.rb +106 -0
  74. data/lib/goodsorcery/status_report.rb +50 -0
  75. data/lib/goodsorcery/tasks.rb +46 -0
  76. data/lib/goodsorcery/test_event.rb +46 -0
  77. data/lib/goodsorcery/text.rb +35 -0
  78. data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
  79. data/lib/goodsorcery/transport/connection.rb +87 -0
  80. data/lib/goodsorcery/transport/result.rb +103 -0
  81. data/lib/goodsorcery/transport.rb +172 -0
  82. data/lib/goodsorcery/verification.rb +47 -0
  83. data/lib/goodsorcery/version.rb +6 -0
  84. data/lib/goodsorcery.rb +176 -0
  85. data/plugins.rb +6 -0
  86. 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