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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +35 -7
  4. data/lib/generators/goodsorcery/install/templates/initializer.rb +8 -2
  5. data/lib/goodsorcery/client.rb +13 -4
  6. data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
  7. data/lib/goodsorcery/configuration/validation.rb +10 -0
  8. data/lib/goodsorcery/configuration.rb +24 -10
  9. data/lib/goodsorcery/defaults.rb +4 -0
  10. data/lib/goodsorcery/delivery.rb +3 -12
  11. data/lib/goodsorcery/errors.rb +0 -4
  12. data/lib/goodsorcery/integrations/active_job.rb +4 -2
  13. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +1 -5
  14. data/lib/goodsorcery/integrations/rails.rb +3 -2
  15. data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
  16. data/lib/goodsorcery/integrations.rb +0 -6
  17. data/lib/goodsorcery/performance/agent.rb +103 -0
  18. data/lib/goodsorcery/performance/aggregator.rb +63 -0
  19. data/lib/goodsorcery/performance/call_site.rb +61 -0
  20. data/lib/goodsorcery/performance/flusher.rb +224 -0
  21. data/lib/goodsorcery/performance/histogram.rb +23 -0
  22. data/lib/goodsorcery/performance/job_timing.rb +106 -0
  23. data/lib/goodsorcery/performance/measurement.rb +117 -0
  24. data/lib/goodsorcery/performance/net_http.rb +37 -0
  25. data/lib/goodsorcery/performance/payloads.rb +110 -0
  26. data/lib/goodsorcery/performance/process_label.rb +22 -0
  27. data/lib/goodsorcery/performance/recorder.rb +202 -0
  28. data/lib/goodsorcery/performance/span_list.rb +156 -0
  29. data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
  30. data/lib/goodsorcery/performance/subscribers.rb +140 -0
  31. data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
  32. data/lib/goodsorcery/performance/trace_builder.rb +121 -0
  33. data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
  34. data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
  35. data/lib/goodsorcery/performance.rb +158 -0
  36. data/lib/goodsorcery/rack/middleware.rb +10 -3
  37. data/lib/goodsorcery/status_report.rb +1 -2
  38. data/lib/goodsorcery/transport.rb +12 -1
  39. data/lib/goodsorcery/version.rb +1 -1
  40. data/lib/goodsorcery.rb +17 -0
  41. metadata +21 -4
  42. data/lib/goodsorcery/delivery/active_job_queue.rb +0 -71
  43. data/lib/goodsorcery/delivery/custom.rb +0 -27
  44. data/lib/goodsorcery/delivery_job.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1413354a65863f49ea7822ea12cf89b824a71ccec864719ed8dcb0443a54fb48
4
- data.tar.gz: e5f2d0445ba8261748f55dd09d15e2ae100bfa8349cf76a55d778fe13ebe51e2
3
+ metadata.gz: edbecd397465b4b9853689dcaeb10b832770545b51c4801f3f3dd22d6c126b08
4
+ data.tar.gz: fd3b3fdf18956d5e9dd9db3af4e042a5385459f531ad6791851551949590ab6c
5
5
  SHA512:
6
- metadata.gz: b7df0121fdb191079e323cb6b6242134c21735aa461aa8c6a9bbed04572c50a820a863d90957a3e7fa57cb9d9b241ea6c9399d7ce71a5974c6c93cde833d956d
7
- data.tar.gz: 4533d8744e3d5b8b1b93402b02cd18d6b6c8468222a5945cfb98c417b33a752a43ac72289e4beccf1a0915605d844e4e16656970cdc06a132406ff8bfae3fcf2
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` | See [Delivery](#delivery). |
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
- | `active_job_queue` | `"default"` | Queue for `delivery = :active_job`. |
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 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. |
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
- | any object | Must respond to `deliver(event_hash)`; may respond to `flush(timeout)` and `shutdown(timeout)`. | Custom transports. |
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. `GoodSorcery::DeliveryJob` is never reported;
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, :active_job, :test or a custom object
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
@@ -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) || exception.is_a?(RetryableDeliveryError) || ignore_list.ignore?(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 active_job test].freeze
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 active_job_queue
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, :active_job_queue, :request_extractor,
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=#{delivery_name}>"
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, "delivery must be one of #{Configuration::DELIVERIES.map(&:inspect).join(", ")} " \
201
- "or an object responding to #deliver (got a #{value.class})"
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
@@ -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
@@ -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). Each exposes deliver(event),
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
- when :active_job then active_job(config, client)
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
@@ -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 if Integrations.delivery_job?(self)
28
+ return Integrations.run_unit_of_work(JobDetails.new(self)) { super } unless Performance.active?
29
29
 
30
- Integrations.run_unit_of_work(JobDetails.new(self)) { super }
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) || from_delivery_job?(context)
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, and recompile the filters so the final
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 yield if Sidekiq.active_job_wrapper?(job)
33
+ return wrapped(job, &) if Sidekiq.active_job_wrapper?(job)
34
34
 
35
- Integrations.run_unit_of_work(JobDetails.new(job, queue: queue, worker: worker), &)
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