goodsorcery 0.1.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +35 -7
- data/lib/generators/goodsorcery/install/templates/initializer.rb +8 -2
- data/lib/goodsorcery/client.rb +13 -4
- data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
- data/lib/goodsorcery/configuration/validation.rb +10 -0
- data/lib/goodsorcery/configuration.rb +24 -10
- data/lib/goodsorcery/defaults.rb +4 -0
- data/lib/goodsorcery/delivery.rb +3 -12
- data/lib/goodsorcery/errors.rb +0 -4
- data/lib/goodsorcery/integrations/active_job.rb +4 -2
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +1 -5
- data/lib/goodsorcery/integrations/rails.rb +3 -2
- data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
- data/lib/goodsorcery/integrations.rb +0 -6
- data/lib/goodsorcery/performance/agent.rb +103 -0
- data/lib/goodsorcery/performance/aggregator.rb +63 -0
- data/lib/goodsorcery/performance/call_site.rb +61 -0
- data/lib/goodsorcery/performance/flusher.rb +224 -0
- data/lib/goodsorcery/performance/histogram.rb +23 -0
- data/lib/goodsorcery/performance/job_timing.rb +106 -0
- data/lib/goodsorcery/performance/measurement.rb +117 -0
- data/lib/goodsorcery/performance/net_http.rb +37 -0
- data/lib/goodsorcery/performance/payloads.rb +110 -0
- data/lib/goodsorcery/performance/process_label.rb +22 -0
- data/lib/goodsorcery/performance/recorder.rb +202 -0
- data/lib/goodsorcery/performance/span_list.rb +156 -0
- data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
- data/lib/goodsorcery/performance/subscribers.rb +140 -0
- data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
- data/lib/goodsorcery/performance/trace_builder.rb +121 -0
- data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
- data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
- data/lib/goodsorcery/performance.rb +158 -0
- data/lib/goodsorcery/rack/middleware.rb +10 -3
- data/lib/goodsorcery/status_report.rb +1 -2
- data/lib/goodsorcery/transport.rb +12 -1
- data/lib/goodsorcery/version.rb +1 -1
- data/lib/goodsorcery.rb +17 -0
- metadata +21 -4
- data/lib/goodsorcery/delivery/active_job_queue.rb +0 -71
- data/lib/goodsorcery/delivery/custom.rb +0 -27
- data/lib/goodsorcery/delivery_job.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edbecd397465b4b9853689dcaeb10b832770545b51c4801f3f3dd22d6c126b08
|
|
4
|
+
data.tar.gz: fd3b3fdf18956d5e9dd9db3af4e042a5385459f531ad6791851551949590ab6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dcb53d171bd4f36a48c7208a9cd2b3950bd1be25741b9946945da0db0cba745b75ee8159a6058a12288fda63258d5cdcd3fe413d25f538c9c7d7601592c2fbb8
|
|
7
|
+
data.tar.gz: 9767d439220b934647dc06cb805fca3d0ba808f97438cb320efc3749e220e0b9d7487fc613cfbb711781a795029cd9b0cfd834d559d2417d79af8f65a176819b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-07)
|
|
4
|
+
|
|
5
|
+
- The `:active_job` delivery: `GoodSorcery::DeliveryJob`, `config.active_job_queue` and `GoodSorcery::RetryableDeliveryError`. Use `:async` (the default). Errors raised in your own jobs are still reported, and jobs are still measured by performance monitoring.
|
|
6
|
+
- Custom delivery objects (`config.delivery = <object responding to #deliver>`).
|
|
7
|
+
- `config.delivery` now accepts only `:async`, `:sync` and `:test`; anything else, including `:active_job` or an object, raises `ArgumentError` from `GoodSorcery.configure` with a message saying what to use instead. Setting `config.active_job_queue` raises `NoMethodError`.
|
|
8
|
+
|
|
9
|
+
## 0.2.0 (2026-11-07)
|
|
10
|
+
|
|
11
|
+
- **Performance monitoring** (opt-in, `config.performance_monitoring = true` or `GOODSORCERY_PERFORMANCE_MONITORING=1`). Records every Rails request and every Active Job and Sidekiq job: count, failures, total time and a shared log-scale histogram (`gs-v1`) with database, external HTTP, view and Ruby time per bucket, plus queue wait for jobs. Metrics are aggregated per transaction per minute in process and sent once a minute to `POST /api/v1/apm/metrics` from a background thread (`goodsorcery-apm`).
|
|
12
|
+
- **Traces** of the slowest request per transaction per minute (`config.performance_traces`, default on), sent to `POST /api/v1/apm/traces`: SQL, `Net::HTTP`, render and enqueue spans; repeated queries grouped with their count, total and app call site (with code context under `send_code_context`); SQL normalised so no literal values leave the process; HTTP spans and the request path without query strings; GC, allocations, host and process; the id of the error reported during the request.
|
|
13
|
+
- `config.performance_ignore_transactions` (default `["Rails::HealthController#show"]`). `GoodSorcery::DeliveryJob` is never recorded.
|
|
14
|
+
- `GoodSorcery.flush_performance` and, with the `:test` delivery, `GoodSorcery.test_performance_payloads`.
|
|
15
|
+
- The gem's own HTTP requests are never measured. Payloads keep their `batch_id` across retries; `429`, `5xx`, `404` (server without performance endpoints: monitoring turns off) and rejected payloads are handled as documented; data is bounded to five minutes and flushed at exit; fork-safe.
|
|
16
|
+
- `bundle exec rake bench:performance` measures the per-request cost.
|
|
17
|
+
|
|
3
18
|
## 0.1.1 (2026-10-11)
|
|
4
19
|
|
|
5
20
|
- `Sidekiq::JobRetry::Handled` and `Sidekiq::JobRetry::Skip` are ignored by default. Sidekiq raises them internally after its retry handling, so reporting them only duplicated the original error.
|
data/README.md
CHANGED
|
@@ -7,6 +7,7 @@ The Ruby client for the [Good Sorcery](https://app.goodsorcery.com) error tracke
|
|
|
7
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
8
|
- It has no runtime dependencies (standard library only). It needs Ruby 3.2 or newer and works with or without Rails.
|
|
9
9
|
- Rails, Rack/Sinatra, Active Job, Action Mailer, Action Cable, Sidekiq and Rake are covered out of the box (see [Integrations](#integrations)).
|
|
10
|
+
- Optional, opt-in [performance monitoring](#performance-monitoring) for Rails apps: response times of every request and job, and timelines of the slowest ones.
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
12
13
|
|
|
@@ -98,24 +99,26 @@ Change settings inside `GoodSorcery.configure`: it recompiles the filters and re
|
|
|
98
99
|
| `send_job_arguments` | `true` | `false` never sends Active Job, mailer or Sidekiq arguments. |
|
|
99
100
|
| `install_check` | `:verify` | `:verify`, `:test_event` or `false`; see [Install check](#install-check). |
|
|
100
101
|
| `logger` | `Logger.new($stderr)` at `WARN` (`Rails.logger` in Rails) | The gem's own messages, prefixed `[goodsorcery]`. |
|
|
101
|
-
| `delivery` | `:async` |
|
|
102
|
+
| `delivery` | `:async` | `:async`, `:sync` or `:test`; see [Delivery](#delivery). |
|
|
102
103
|
| `batch_size` | `25` | Events per request, 1–100. |
|
|
103
104
|
| `flush_interval` | `2.0` s | Longest an event waits in the queue. |
|
|
104
105
|
| `max_queue_size` | `1_000` | Events beyond this are dropped and counted. |
|
|
105
106
|
| `max_retries` | `3` | For `5xx`/network failures (exponential backoff with jitter, capped at 60 s). |
|
|
106
107
|
| `shutdown_timeout` | `5` s | How long the exit hook waits for the queue to drain. |
|
|
107
108
|
| `open_timeout` / `read_timeout` | `2` s / `5` s | HTTP timeouts (writes use `read_timeout`). |
|
|
108
|
-
| `
|
|
109
|
+
| `performance_monitoring` | `false` (or `ENV["GOODSORCERY_PERFORMANCE_MONITORING"]` `1`/`0`) | Records every request and job and sends metrics once a minute; see [Performance monitoring](#performance-monitoring). Read when the app boots. |
|
|
110
|
+
| `performance_traces` | `true` | Also sends the timeline of the slowest request per transaction per minute. `false` sends metrics only. |
|
|
111
|
+
| `performance_ignore_transactions` | `["Rails::HealthController#show"]` | Transaction names (exact strings) or regexps never recorded. |
|
|
109
112
|
|
|
110
113
|
### Delivery
|
|
111
114
|
|
|
112
115
|
| `config.delivery` | What it does | When to use |
|
|
113
116
|
|---|---|---|
|
|
114
117
|
| `: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
|
|
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. |
|
|
118
|
+
| `:sync` | Sends each event in the calling thread, before `notify` returns. | Serverless functions and other short-lived processes where a background thread doesn't get to run, such as AWS Lambda (the process is frozen between invocations). **Not for normal web apps:** every report adds an HTTPS round trip to the request that hit the error. |
|
|
117
119
|
| `:test` | Keeps events in memory (`GoodSorcery.test_events`). Never touches the network and ignores the environment check. | Your app's test suite. |
|
|
118
|
-
|
|
120
|
+
|
|
121
|
+
Any other value raises an `ArgumentError` from `GoodSorcery.configure`, when the app boots. The `:active_job` delivery and custom delivery objects were removed; use `:async`.
|
|
119
122
|
|
|
120
123
|
### Environments
|
|
121
124
|
|
|
@@ -205,7 +208,7 @@ Nothing to set up beyond the initializer. The Railtie:
|
|
|
205
208
|
- 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
209
|
- 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
210
|
- 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
|
|
211
|
+
- 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;
|
|
209
212
|
- wraps Action Cable channel subscribe, unsubscribe and actions; errors your channel's `rescue_from` handles are not reported;
|
|
210
213
|
- 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
214
|
- loads the `goodsorcery:verify` and `goodsorcery:test` tasks and reports exceptions that end a rake task.
|
|
@@ -230,6 +233,30 @@ Hooked in automatically when Sidekiq is loaded before `goodsorcery` or in a Rail
|
|
|
230
233
|
|
|
231
234
|
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
235
|
|
|
236
|
+
## Performance monitoring
|
|
237
|
+
|
|
238
|
+
Off by default. Turn it on in the initializer (or with `GOODSORCERY_PERFORMANCE_MONITORING=1`) and restart:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
GoodSorcery.configure do |config|
|
|
242
|
+
config.performance_monitoring = true
|
|
243
|
+
# config.performance_traces = true # false = metrics only
|
|
244
|
+
# config.performance_ignore_transactions += ["HealthController#show", /\AAdmin::/]
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
It follows the same environment rules as error reporting and needs an API key: in a disabled environment nothing is subscribed, no thread starts and a request costs one boolean check.
|
|
249
|
+
|
|
250
|
+
**What is measured.** Each controller action (`CheckoutsController#create`, with the HTTP method and the route pattern such as `/checkout`, never the real path) and each job (Active Job with any adapter, and Sidekiq jobs not wrapped by Active Job; with the adapter, queue and queue wait). A request or job counts as failed when an exception escapes it (for a request, one Rails answers with a 5xx: a `RecordNotFound` is recorded as a 404, not a failure), when a request answers with a 5xx status, or when Active Job's `retry_on`/`discard_on` handles a job's exception. Time is split into database (`sql.active_record`, without `SCHEMA` and cached queries), external HTTP (`Net::HTTP` calls), views (template renders, without the queries and HTTP calls made from them) and Ruby (the rest). Requests that never reach a controller (routing 404s, assets) and a job run inline inside a request are not separate transactions.
|
|
251
|
+
|
|
252
|
+
**How it is sent.** Numbers are aggregated in your process per transaction per minute (count, failures, total time and a log-scale histogram with the time per layer) and sent once a minute from one background thread (`goodsorcery-apm`) as a small gzip'd request. The slowest request per transaction per minute also sends its **trace** (built after the response has been sent, on servers such as Puma that allow it): a timeline of its SQL queries, HTTP calls, template renders and job enqueues. Identical queries are grouped with their count, total time and the line of your code that ran them, so N+1 queries stand out. Unsent data is kept for at most five minutes; the payloads are retried with the same batch id.
|
|
253
|
+
|
|
254
|
+
**What a trace never contains.** No params, headers, cookies, session, request or response bodies, query strings, job arguments or user data. SQL is normalised before it leaves the process: string and number literals become `?`, `IN (…)` lists and `VALUES` rows collapse, comments are removed; bind placeholders such as `$1` stay. Quotes are read the way your database reads them (from the connection's adapter), and when that cannot be told, everything after a string holding a backslash becomes `?`. HTTP calls are method, host and path only (never credentials, even when the app passes a whole URL). Code lines around a repeated query's call site are sent only with `send_code_context` (the default). `before_send` and `filter_params` don't apply, because there is nothing key/value-shaped to filter.
|
|
255
|
+
|
|
256
|
+
**Cost.** Per request, a few microseconds of bookkeeping plus about 2 µs per query (`bundle exec rake bench:performance` measures it on your machine). Memory is bounded: at most 2,000 spans and 4 MiB of SQL text per running request, 500 transaction names per minute and kind (the rest are counted as `(other)`), five minutes of metrics and 30 traces per minute.
|
|
257
|
+
|
|
258
|
+
Outside Rails, call `GoodSorcery::Performance.install` after `GoodSorcery.configure`; jobs and `Net::HTTP` are then measured, but web requests need Rails to get their names. In your test suite, the `:test` delivery keeps the payloads in memory: `GoodSorcery.flush_performance` builds them into `GoodSorcery.test_performance_payloads` (`[{ "path" => "/api/v1/apm/metrics", "body" => {...} }]`).
|
|
259
|
+
|
|
233
260
|
## Install check
|
|
234
261
|
|
|
235
262
|
`bin/rails goodsorcery:verify` (or `rake goodsorcery:verify`) checks your setup on demand; see the end of this section.
|
|
@@ -270,7 +297,7 @@ event = GoodSorcery.test_events.last
|
|
|
270
297
|
assert_equal "Payments::DeclinedError", event["exception"]["class"]
|
|
271
298
|
```
|
|
272
299
|
|
|
273
|
-
The `:test` delivery ignores the environment check, stores the filtered event hashes exactly as they would be sent, and never touches the network.
|
|
300
|
+
The `:test` delivery ignores the environment check, stores the filtered event hashes exactly as they would be sent, and never touches the network. With performance monitoring started (`GoodSorcery::Performance.install` after setting `config.performance_monitoring = true`), `GoodSorcery.flush_performance` stores the performance payloads in `GoodSorcery.test_performance_payloads`; `clear_test_events` clears both.
|
|
274
301
|
|
|
275
302
|
## Self-hosted servers
|
|
276
303
|
|
|
@@ -291,6 +318,7 @@ These are for integrations and advanced setups:
|
|
|
291
318
|
bin/setup # bundle install, then the whole test suite
|
|
292
319
|
bundle exec rake test # core, then Rack/Sidekiq/Rake, then Rails tests, each in its own process
|
|
293
320
|
bundle exec rubocop
|
|
321
|
+
bundle exec rake bench:performance # cost of performance monitoring per request (not run in CI)
|
|
294
322
|
bin/console
|
|
295
323
|
```
|
|
296
324
|
|
|
@@ -27,7 +27,7 @@ GoodSorcery.configure do |config|
|
|
|
27
27
|
# config.send_job_arguments = true # false = never send job arguments
|
|
28
28
|
|
|
29
29
|
# --- Delivery --------------------------------------------------------------
|
|
30
|
-
# config.delivery = :async # :sync
|
|
30
|
+
# config.delivery = :async # :sync (serverless, e.g. AWS Lambda) or :test
|
|
31
31
|
# config.batch_size = 25
|
|
32
32
|
# config.flush_interval = 2.0
|
|
33
33
|
# config.max_queue_size = 1_000
|
|
@@ -35,7 +35,6 @@ GoodSorcery.configure do |config|
|
|
|
35
35
|
# config.shutdown_timeout = 5
|
|
36
36
|
# config.open_timeout = 2
|
|
37
37
|
# config.read_timeout = 5
|
|
38
|
-
# config.active_job_queue = "default" # with delivery = :active_job
|
|
39
38
|
|
|
40
39
|
# --- What gets reported ----------------------------------------------------
|
|
41
40
|
# config.release = ENV["GIT_SHA"] # auto-detected from deploy env vars or REVISION
|
|
@@ -48,6 +47,13 @@ GoodSorcery.configure do |config|
|
|
|
48
47
|
# config.breadcrumbs = true
|
|
49
48
|
# config.max_breadcrumbs = 40
|
|
50
49
|
|
|
50
|
+
# --- Performance monitoring --------------------------------------------------
|
|
51
|
+
# Off by default. Records every request and job and sends the slowest
|
|
52
|
+
# requests' timelines once a minute (also ENV["GOODSORCERY_PERFORMANCE_MONITORING"]=1).
|
|
53
|
+
# config.performance_monitoring = true
|
|
54
|
+
# config.performance_traces = true # false = metrics only, no traces
|
|
55
|
+
# config.performance_ignore_transactions += ["HealthController#show", /\AAdmin::/]
|
|
56
|
+
|
|
51
57
|
# --- Development -----------------------------------------------------------
|
|
52
58
|
# config.install_check = :verify # after bundle install: :verify, :test_event or false
|
|
53
59
|
# config.logger = Rails.logger
|
data/lib/goodsorcery/client.rb
CHANGED
|
@@ -4,7 +4,6 @@ module GoodSorcery
|
|
|
4
4
|
# Wires configuration to the event pipeline and the delivery. One client
|
|
5
5
|
# per process, built lazily; GoodSorcery.configure rebuilds its parts.
|
|
6
6
|
class Client
|
|
7
|
-
NETWORK_DELIVERIES = %i[async sync active_job].freeze
|
|
8
7
|
REPORTING_KEY = :__goodsorcery_reporting
|
|
9
8
|
|
|
10
9
|
attr_reader :config, :log
|
|
@@ -69,9 +68,18 @@ module GoodSorcery
|
|
|
69
68
|
end
|
|
70
69
|
|
|
71
70
|
def shutdown(timeout = config.shutdown_timeout)
|
|
71
|
+
Performance.shutdown(timeout)
|
|
72
72
|
@delivery ? @delivery.shutdown(timeout) : true
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Whether performance monitoring may run (DESIGN.md 8.1): with the :test
|
|
76
|
+
# delivery, or in an enabled environment with a key.
|
|
77
|
+
def performance_allowed?
|
|
78
|
+
return true if test_delivery?
|
|
79
|
+
|
|
80
|
+
@gate.enabled? && !config.api_key.to_s.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
def verify
|
|
76
84
|
Verification.run(config, transport)
|
|
77
85
|
end
|
|
@@ -123,7 +131,9 @@ module GoodSorcery
|
|
|
123
131
|
event = prepare_event(exception, options)
|
|
124
132
|
return nil unless event
|
|
125
133
|
|
|
126
|
-
delivery.deliver(event) == false ? nil : event["event_id"]
|
|
134
|
+
event_id = delivery.deliver(event) == false ? nil : event["event_id"]
|
|
135
|
+
Performance.note_error(event_id)
|
|
136
|
+
event_id
|
|
127
137
|
rescue StandardError => e
|
|
128
138
|
log.error("notify failed with #{e.class}; the error was not reported")
|
|
129
139
|
nil
|
|
@@ -144,7 +154,7 @@ module GoodSorcery
|
|
|
144
154
|
end
|
|
145
155
|
|
|
146
156
|
def skip?(exception)
|
|
147
|
-
ReportedMark.reported?(exception) ||
|
|
157
|
+
ReportedMark.reported?(exception) || ignore_list.ignore?(exception)
|
|
148
158
|
end
|
|
149
159
|
|
|
150
160
|
def test_delivery?
|
|
@@ -152,7 +162,6 @@ module GoodSorcery
|
|
|
152
162
|
end
|
|
153
163
|
|
|
154
164
|
def credentials_present?
|
|
155
|
-
return true unless NETWORK_DELIVERIES.include?(config.delivery)
|
|
156
165
|
return true unless config.api_key.to_s.empty?
|
|
157
166
|
|
|
158
167
|
log.warn_once(:missing_api_key,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
class Configuration
|
|
5
|
+
# The performance monitoring settings (DESIGN.md 8.1). Monitoring is off
|
|
6
|
+
# unless set here or with GOODSORCERY_PERFORMANCE_MONITORING=1.
|
|
7
|
+
module PerformanceSettings
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.attr_accessor :performance_ignore_transactions
|
|
10
|
+
base.attr_reader :performance_traces
|
|
11
|
+
base.boolean_setting :performance_traces
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def performance_monitoring
|
|
15
|
+
return @performance_monitoring unless @performance_monitoring.nil?
|
|
16
|
+
|
|
17
|
+
EnvFlag.parse(ENV.fetch("GOODSORCERY_PERFORMANCE_MONITORING", nil)) || false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def performance_monitoring=(value)
|
|
21
|
+
unless [true, false, nil].include?(value)
|
|
22
|
+
raise ArgumentError, "performance_monitoring must be true, false or nil (got #{value.inspect})"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@performance_monitoring = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def assign_performance_defaults
|
|
31
|
+
@performance_monitoring = nil
|
|
32
|
+
@performance_traces = true
|
|
33
|
+
@performance_ignore_transactions = Defaults::PERFORMANCE_IGNORE_TRANSACTIONS.dup
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -44,6 +44,16 @@ module GoodSorcery
|
|
|
44
44
|
false
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def boolean_setting(name)
|
|
48
|
+
define_method(:"#{name}=") do |value|
|
|
49
|
+
unless [true, false].include?(value)
|
|
50
|
+
raise ArgumentError, "#{name} must be true or false (got #{value.inspect})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
instance_variable_set(:"@#{name}", value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
def choice_setting(name, choices)
|
|
48
58
|
define_method(:"#{name}=") do |value|
|
|
49
59
|
normalized = value.is_a?(String) ? value.to_sym : value
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "configuration/validation"
|
|
4
|
+
require_relative "configuration/performance_settings"
|
|
4
5
|
|
|
5
6
|
module GoodSorcery
|
|
6
7
|
# Every setting the gem reads (DESIGN.md 2 and 3). Unset credentials,
|
|
@@ -8,18 +9,19 @@ module GoodSorcery
|
|
|
8
9
|
# environment variables (and Rails) when they are read.
|
|
9
10
|
class Configuration
|
|
10
11
|
extend Validation
|
|
12
|
+
include PerformanceSettings
|
|
11
13
|
|
|
12
|
-
DELIVERIES = %i[async sync
|
|
14
|
+
DELIVERIES = %i[async sync test].freeze
|
|
13
15
|
DELIVERY_SETTINGS = %i[
|
|
14
16
|
delivery api_key endpoint batch_size flush_interval max_queue_size shutdown_timeout
|
|
15
|
-
max_retries open_timeout read_timeout
|
|
17
|
+
max_retries open_timeout read_timeout
|
|
16
18
|
].freeze
|
|
17
19
|
|
|
18
20
|
attr_writer :api_key, :environment, :release, :logger, :root
|
|
19
21
|
attr_accessor :disabled_environments, :enabled_environments, :filter_params,
|
|
20
22
|
:use_rails_filter_parameters, :extra_filters, :ignore_exceptions, :tags,
|
|
21
23
|
:user_method, :send_code_context, :breadcrumbs,
|
|
22
|
-
:report_handled_errors, :
|
|
24
|
+
:report_handled_errors, :request_extractor,
|
|
23
25
|
:send_request_params, :send_request_headers, :send_session, :send_job_arguments
|
|
24
26
|
attr_reader :delivery, :batch_size, :flush_interval, :max_queue_size,
|
|
25
27
|
:shutdown_timeout, :max_retries, :open_timeout, :read_timeout,
|
|
@@ -119,9 +121,8 @@ module GoodSorcery
|
|
|
119
121
|
# Never shows the API key (or endpoint credentials): consoles, error
|
|
120
122
|
# pages and loggers print objects with #inspect.
|
|
121
123
|
def inspect
|
|
122
|
-
delivery_name = delivery.is_a?(Symbol) ? delivery.inspect : delivery.class.name
|
|
123
124
|
"#<#{self.class.name} api_key=#{masked_api_key} endpoint=#{endpoint_label} " \
|
|
124
|
-
"environment=#{environment} delivery=#{
|
|
125
|
+
"environment=#{environment} delivery=#{delivery.inspect}>"
|
|
125
126
|
end
|
|
126
127
|
|
|
127
128
|
private
|
|
@@ -144,7 +145,6 @@ module GoodSorcery
|
|
|
144
145
|
@max_retries = 3
|
|
145
146
|
@open_timeout = 2
|
|
146
147
|
@read_timeout = 5
|
|
147
|
-
@active_job_queue = "default"
|
|
148
148
|
end
|
|
149
149
|
|
|
150
150
|
def assign_behaviour_defaults
|
|
@@ -156,6 +156,7 @@ module GoodSorcery
|
|
|
156
156
|
@install_check = :verify
|
|
157
157
|
@report_handled_errors = true
|
|
158
158
|
assign_capture_defaults
|
|
159
|
+
assign_performance_defaults
|
|
159
160
|
end
|
|
160
161
|
|
|
161
162
|
def assign_capture_defaults
|
|
@@ -190,15 +191,28 @@ module GoodSorcery
|
|
|
190
191
|
end
|
|
191
192
|
end
|
|
192
193
|
|
|
193
|
-
# Normalizes and validates config.delivery.
|
|
194
|
+
# Normalizes and validates config.delivery. The message names a rejected
|
|
195
|
+
# symbol but only the class of anything else, which may hold credentials.
|
|
194
196
|
module DeliverySetting
|
|
197
|
+
REMOVED = {
|
|
198
|
+
active_job: "The :active_job delivery was removed: use :async (the default), " \
|
|
199
|
+
"which sends events from a background thread."
|
|
200
|
+
}.freeze
|
|
201
|
+
CUSTOM_REMOVED = "Custom delivery objects are no longer supported: use :async (the default), " \
|
|
202
|
+
":sync in serverless functions or :test in your test suite."
|
|
203
|
+
|
|
195
204
|
def self.normalize(value)
|
|
196
205
|
symbol = value.is_a?(String) ? value.to_sym : value
|
|
197
206
|
return symbol if Configuration::DELIVERIES.include?(symbol)
|
|
198
|
-
return value if !value.is_a?(Symbol) && value.respond_to?(:deliver)
|
|
199
207
|
|
|
200
|
-
raise ArgumentError,
|
|
201
|
-
|
|
208
|
+
raise ArgumentError, message(symbol, value)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.message(symbol, value)
|
|
212
|
+
got = symbol.is_a?(Symbol) ? symbol.inspect : "an instance of #{value.class}"
|
|
213
|
+
hint = symbol.is_a?(Symbol) ? REMOVED[symbol] : (CUSTOM_REMOVED if value.respond_to?(:deliver))
|
|
214
|
+
["delivery must be one of #{Configuration::DELIVERIES.map(&:inspect).join(", ")} (got #{got}).", hint]
|
|
215
|
+
.compact.join(" ")
|
|
202
216
|
end
|
|
203
217
|
end
|
|
204
218
|
end
|
data/lib/goodsorcery/defaults.rb
CHANGED
|
@@ -36,6 +36,10 @@ module GoodSorcery
|
|
|
36
36
|
SystemExit
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
39
|
+
# Transactions (exact names or regexps) that performance monitoring
|
|
40
|
+
# never records.
|
|
41
|
+
PERFORMANCE_IGNORE_TRANSACTIONS = ["Rails::HealthController#show"].freeze
|
|
42
|
+
|
|
39
43
|
DISABLED_ENVIRONMENTS = %w[development test].freeze
|
|
40
44
|
ENDPOINT = "https://app.goodsorcery.com"
|
|
41
45
|
end
|
data/lib/goodsorcery/delivery.rb
CHANGED
|
@@ -6,20 +6,17 @@ require_relative "delivery/worker"
|
|
|
6
6
|
require_relative "delivery/async"
|
|
7
7
|
require_relative "delivery/sync"
|
|
8
8
|
require_relative "delivery/test"
|
|
9
|
-
require_relative "delivery/custom"
|
|
10
|
-
require_relative "delivery/active_job_queue"
|
|
11
9
|
|
|
12
10
|
module GoodSorcery
|
|
13
|
-
# Delivery strategies (config.delivery
|
|
11
|
+
# Delivery strategies (config.delivery: :async, :sync or :test; the
|
|
12
|
+
# setter rejects anything else). Each exposes deliver(event),
|
|
14
13
|
# flush(timeout) and shutdown(timeout).
|
|
15
14
|
module Delivery
|
|
16
15
|
def self.build(config, client)
|
|
17
16
|
case config.delivery
|
|
18
|
-
when :async then async(config, client.transport, client.serializer, client.log)
|
|
19
17
|
when :sync then Sync.new(transport: client.transport, serializer: client.serializer, log: client.log)
|
|
20
18
|
when :test then Test.new
|
|
21
|
-
|
|
22
|
-
else Custom.new(config.delivery)
|
|
19
|
+
else async(config, client.transport, client.serializer, client.log)
|
|
23
20
|
end
|
|
24
21
|
end
|
|
25
22
|
|
|
@@ -29,11 +26,5 @@ module GoodSorcery
|
|
|
29
26
|
max_queue_size: config.max_queue_size, max_retries: config.max_retries,
|
|
30
27
|
shutdown_timeout: config.shutdown_timeout)
|
|
31
28
|
end
|
|
32
|
-
|
|
33
|
-
def self.active_job(config, client)
|
|
34
|
-
ActiveJobQueue.new(serializer: client.serializer, queue_name: config.active_job_queue, log: client.log) do
|
|
35
|
-
async(config, client.transport, client.serializer, client.log)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
29
|
end
|
|
39
30
|
end
|
data/lib/goodsorcery/errors.rb
CHANGED
|
@@ -7,8 +7,4 @@ module GoodSorcery
|
|
|
7
7
|
# Raised (and rescued) by `goodsorcery:test` and the install check to send a
|
|
8
8
|
# realistic test exception.
|
|
9
9
|
class TestError < StandardError; end
|
|
10
|
-
|
|
11
|
-
# Raised by GoodSorcery::DeliveryJob so ActiveJob retries a delivery that
|
|
12
|
-
# failed with a 429, a 5xx or a network error.
|
|
13
|
-
class RetryableDeliveryError < Error; end
|
|
14
10
|
end
|
|
@@ -25,9 +25,11 @@ module GoodSorcery
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def perform_now
|
|
28
|
-
return super
|
|
28
|
+
return Integrations.run_unit_of_work(JobDetails.new(self)) { super } unless Performance.active?
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Performance.measure_job(Performance::ActiveJobTiming.new(self)) do
|
|
31
|
+
Integrations.run_unit_of_work(JobDetails.new(self)) { super }
|
|
32
|
+
end
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
|
|
@@ -12,7 +12,7 @@ module GoodSorcery
|
|
|
12
12
|
|
|
13
13
|
def report(error, handled:, severity: nil, context: {}, source: nil)
|
|
14
14
|
return if handled && !GoodSorcery.configuration.report_handled_errors
|
|
15
|
-
return if ReportedMark.reported?(error)
|
|
15
|
+
return if ReportedMark.reported?(error)
|
|
16
16
|
|
|
17
17
|
GoodSorcery.notify(error, level: level(handled, severity),
|
|
18
18
|
context: { "rails_error" => rails_error_context(context, source) })
|
|
@@ -24,10 +24,6 @@ module GoodSorcery
|
|
|
24
24
|
handled ? LEVELS.fetch(severity.to_s.to_sym, "warning") : "error"
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def from_delivery_job?(context)
|
|
28
|
-
context.is_a?(Hash) && Integrations.delivery_job?(context[:job])
|
|
29
|
-
end
|
|
30
|
-
|
|
31
27
|
# Controllers and jobs from Rails' execution context become
|
|
32
28
|
# "#<ClassName>"; records become GlobalID strings.
|
|
33
29
|
def rails_error_context(context, source)
|
|
@@ -31,13 +31,14 @@ module GoodSorcery
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# After the app's initializers ran: subscribe to Rails.error and to
|
|
34
|
-
# notifications, hook Sidekiq,
|
|
35
|
-
# filter_parameters apply.
|
|
34
|
+
# notifications, hook Sidekiq, recompile the filters so the final
|
|
35
|
+
# filter_parameters apply, and start performance monitoring if it is on.
|
|
36
36
|
def after_initialize
|
|
37
37
|
subscribe_to_error_reporter
|
|
38
38
|
NotificationBreadcrumbs.subscribe if GoodSorcery.configuration.breadcrumbs
|
|
39
39
|
install_sidekiq if defined?(::Sidekiq)
|
|
40
40
|
GoodSorcery.client.reconfigure(rebuild_delivery: false)
|
|
41
|
+
GoodSorcery::Performance.install if GoodSorcery.configuration.performance_monitoring
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def subscribe_to_error_reporter
|
|
@@ -30,9 +30,25 @@ module GoodSorcery
|
|
|
30
30
|
attr_accessor :config
|
|
31
31
|
|
|
32
32
|
def call(worker, job, queue, &)
|
|
33
|
-
return
|
|
33
|
+
return wrapped(job, &) if Sidekiq.active_job_wrapper?(job)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
details = JobDetails.new(job, queue: queue, worker: worker)
|
|
36
|
+
return Integrations.run_unit_of_work(details, &) unless Performance.active?
|
|
37
|
+
|
|
38
|
+
Performance.measure_job(Performance::SidekiqTiming.new(job, queue, worker)) do
|
|
39
|
+
Integrations.run_unit_of_work(details, &)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# A Sidekiq retry runs an Active Job with its original job data, so
|
|
46
|
+
# its enqueued_at is the first enqueue; Sidekiq's own enqueued_at is
|
|
47
|
+
# when the retry was queued, which the queue wait is measured from.
|
|
48
|
+
def wrapped(job, &)
|
|
49
|
+
return yield unless Performance.active? && job.key?("retry_count")
|
|
50
|
+
|
|
51
|
+
Performance::ActiveJobTiming.requeued_at(job["enqueued_at"], &)
|
|
36
52
|
end
|
|
37
53
|
end
|
|
38
54
|
|
|
@@ -5,12 +5,6 @@ module GoodSorcery
|
|
|
5
5
|
# unit of work and leaves an enricher that reads its details only when an
|
|
6
6
|
# error is reported.
|
|
7
7
|
module Integrations
|
|
8
|
-
# GoodSorcery::DeliveryJob sends events; errors it raises are never
|
|
9
|
-
# reported (no recursion).
|
|
10
|
-
def self.delivery_job?(object)
|
|
11
|
-
GoodSorcery.const_defined?(:DeliveryJob, false) && object.is_a?(GoodSorcery::DeliveryJob)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
8
|
# Runs the block in a fresh scope prepared by +details+ (an object with
|
|
15
9
|
# +open(scope)+), reporting any exception that escapes it, then
|
|
16
10
|
# re-raising.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# The per-process state: metrics, traces and the flusher. A forked child
|
|
6
|
+
# gets a fresh, empty agent (the parent keeps and sends what it
|
|
7
|
+
# recorded), and its flusher thread starts on the child's first record.
|
|
8
|
+
class Agent
|
|
9
|
+
attr_reader :pid, :aggregator, :trace_buffer, :flusher, :context
|
|
10
|
+
|
|
11
|
+
def initialize(config, log)
|
|
12
|
+
@pid = Process.pid
|
|
13
|
+
@aggregator = Aggregator.new
|
|
14
|
+
@trace_buffer = TraceBuffer.new
|
|
15
|
+
@context = TraceContext.new(config)
|
|
16
|
+
@flusher = Flusher.new(agent: self, log: log)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current?
|
|
20
|
+
@pid == Process.pid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(recorder, background:)
|
|
24
|
+
minute = (recorder.finished_wall / 60).floor
|
|
25
|
+
@aggregator.record(minute, Sample.from(recorder))
|
|
26
|
+
offer_trace(minute, recorder) if recorder.traces?
|
|
27
|
+
@flusher.ensure_started if background
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# A request's trace is built once the response has gone out, when the
|
|
33
|
+
# server offers a hook for that, so the slowest requests are not kept
|
|
34
|
+
# waiting for it.
|
|
35
|
+
def offer_trace(minute, recorder)
|
|
36
|
+
key = [recorder.kind, recorder.name]
|
|
37
|
+
return unless @trace_buffer.candidate?(minute, key, recorder.duration_ms)
|
|
38
|
+
|
|
39
|
+
hooks = AfterReply.hooks(recorder.env)
|
|
40
|
+
return build_trace(minute, key, recorder) unless hooks
|
|
41
|
+
|
|
42
|
+
hooks << AfterReply.callback { build_trace(minute, key, recorder) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_trace(minute, key, recorder)
|
|
46
|
+
return unless @trace_buffer.candidate?(minute, key, recorder.duration_ms)
|
|
47
|
+
|
|
48
|
+
trace = TraceBuilder.new(recorder, minute, @context).build
|
|
49
|
+
@trace_buffer.offer(minute, key, recorder.duration_ms, trace)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The Rack server's hooks run after the response is sent: Rack 3's
|
|
54
|
+
# rack.response_finished (callables get env, status, headers, error)
|
|
55
|
+
# or Puma's rack.after_reply (no arguments).
|
|
56
|
+
module AfterReply
|
|
57
|
+
KEYS = %w[rack.response_finished rack.after_reply].freeze
|
|
58
|
+
|
|
59
|
+
def self.hooks(env)
|
|
60
|
+
return nil unless env.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
KEYS.each do |key|
|
|
63
|
+
hooks = env[key]
|
|
64
|
+
return hooks if hooks.is_a?(Array) && !hooks.frozen?
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A hook that never raises into the server.
|
|
70
|
+
def self.callback(&work)
|
|
71
|
+
proc do |*|
|
|
72
|
+
work.call
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
GoodSorcery.log.warn_every(:apm_trace_error, 60, "could not build a performance trace (#{e.class})")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# performance_ignore_transactions: exact names or regexps. Answers are
|
|
80
|
+
# cached per name.
|
|
81
|
+
class IgnoreMatcher
|
|
82
|
+
MAX_CACHED = 1_000
|
|
83
|
+
|
|
84
|
+
def initialize(list)
|
|
85
|
+
entries = Array(list)
|
|
86
|
+
@names = entries.grep_v(Regexp).map(&:to_s).to_h { |name| [name, true] }
|
|
87
|
+
@patterns = entries.grep(Regexp)
|
|
88
|
+
@cache = {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ignored?(name)
|
|
92
|
+
return true if @names.key?(name)
|
|
93
|
+
return false if @patterns.empty?
|
|
94
|
+
|
|
95
|
+
cached = @cache[name]
|
|
96
|
+
return cached unless cached.nil?
|
|
97
|
+
|
|
98
|
+
@cache.clear if @cache.size >= MAX_CACHED
|
|
99
|
+
@cache[name] = @patterns.any? { |pattern| pattern.match?(name) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|