forge_ops_tracker 0.15.1 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81c08199bfeae97d20295b7c04a46dc3b9dd9be519d4790404b88e2ca514fbe1
4
- data.tar.gz: dad1efa10164cebcbce9452c1f903812157143e871162076f09298ebf41a6ea7
3
+ metadata.gz: 304c9a15155ecdf53fe8e181dd7f43b56a08d7397e7d63555e428185ece06560
4
+ data.tar.gz: d4d2cb88ffec650e579c7279c590e4eaee8f288bc6ef7041d2c2f5785767fa2f
5
5
  SHA512:
6
- metadata.gz: 2a32b34300ee005cd09be7f188275091ec9ba8cf4ef2e65044176a7e6d1427feef170cbc44dbd9f7a25dcf0073b2afb6186db3db2069a5737bfdcb274e62cc7f
7
- data.tar.gz: 4431641c5e95f757dd82e7af0015de824804610ee4ebf252cbe3a4db13ca5f5aa1cac5fda274a94833be652a9d6c5e25ab4406570d4790b165cf242f8490ff66
6
+ metadata.gz: dfd3a51d60e0decd113e2a8bd50db101ebd4ec57ebb468a522413b4f9ffa0c5c15d5440939c7a6f772561ce6ffe5c7078322d2903f0d3ec87006f1dd368c1718
7
+ data.tar.gz: 1646c0b506c357122c513604d031974318df0086a0c2a962c99e80143624be459755fab5dfff3df7718f24bb6caebe699ba5325fafb79b35a92f76ffa0783b63
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-10-13)
4
+
5
+ - Fixed: a batch the server refused was re-sent at every flush for as long as the process lived,
6
+ with everything recorded since added to it. Performance samples, custom and infrastructure
7
+ metrics, session counts and failure events are now dropped when the server answers with a 4xx
8
+ other than 408 or 429 (a 400, 401, 403, 413 or 422, for example), since sending the same batch
9
+ again can only fail the same way. Once a metric buffer had filled up behind such a batch, every
10
+ new metric was dropped too; that no longer happens.
11
+ - A batch that could not be delivered for now (a network error, a timeout, a 5xx, a 408 or a 429)
12
+ is still kept for the next flush, but only for 5 flushes in a row. After the fifth it is dropped
13
+ and the buffer starts clean, so a long outage no longer holds the same data in memory forever.
14
+ - The failure event buffer now holds at most 1000 entries; further ones are dropped until a flush
15
+ lets go of them, the same way the metric buffers already behave.
16
+ - With a `config.logger`, each drop is logged once at debug with what was dropped and why, and a
17
+ non-2xx answer is logged with its status code.
18
+ - Internal: `ForgeOpsTracker::Client#deliver` and its sibling `deliver_*` methods now return
19
+ `:delivered`, `:rejected` or `:failed` instead of `true`/`false`. All three are truthy, so code
20
+ calling `Client` directly should compare against `:delivered`. Nothing in the documented API
21
+ changed.
22
+
23
+ ## 0.16.0 (2026-10-02)
24
+
25
+ - Changed: outside Rails, the environment now defaults to `production` when nothing sets it (it
26
+ was `development`). It comes from `FORGE_OPS_ENVIRONMENT`, then `RAILS_ENV`, then `RACK_ENV`,
27
+ then `production`. Only `production` and `staging` send by default, so before this a plain Ruby
28
+ or Rack process that set just a DSN sent nothing at all. To keep the old behavior there, set
29
+ `FORGE_OPS_ENVIRONMENT=development` (or `RACK_ENV=development`, or `config.environment`).
30
+ - Changed: under Rails, `FORGE_OPS_ENVIRONMENT` now takes precedence over `Rails.env` when it's
31
+ set. Without it nothing changes: development and test still don't send.
32
+ - When a DSN is set but the environment isn't in `enabled_environments`, one warning is now logged
33
+ per process, naming the environment and how to send from it: at boot under Rails (to
34
+ `Rails.logger`), otherwise from `configure`, the Rack middleware, or `capture_exception` (to
35
+ `config.logger`, or stderr when there isn't one). Nothing is logged when no DSN is set.
36
+ - New `ForgeOpsTracker.capture_exception(error, context: {})` reports an exception you've rescued,
37
+ through the same path `Rails.error` reports into, so it works without Rails too.
38
+ - Plain Rack and Sinatra apps are now covered: `use ForgeOpsTracker::Middleware::RequestContext`
39
+ reports an exception escaping the app, and an error Sinatra itself turned into a 5xx response.
40
+ Under Rails it still only adds request context, since `Rails.error` already reports those.
41
+
3
42
  ## 0.15.1 (2026-10-01)
4
43
 
5
44
  - Errors reported just before a process exits are now sent before it ends. Delivery runs on a
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # ForgeOpsTracker
2
2
 
3
- Rails exception reporting client for [ForgeOps](https://getforgeops.net).
3
+ Exception reporting client for [ForgeOps](https://getforgeops.net), for Rails, plain Rack and
4
+ Sinatra apps, and scripts.
4
5
 
5
6
  ## Installation
6
7
 
@@ -25,6 +26,11 @@ ForgeOpsTracker.configure do |config|
25
26
  end
26
27
  ```
27
28
 
29
+ The environment is `FORGE_OPS_ENVIRONMENT` when set, otherwise `Rails.env` under Rails, and
30
+ outside Rails `RAILS_ENV`, then `RACK_ENV`, then `production`. Only `production` and `staging`
31
+ send by default, so a Rails app in development or test doesn't report. When a DSN is set but the
32
+ environment isn't enabled, one warning is logged per process (at boot under Rails) saying so.
33
+
28
34
  Delivery happens on a small background thread with a bounded queue and short HTTP timeouts. Every
29
35
  failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and dropped
30
36
  rather than raised, so a broken or unreachable tracker can never take down the host app.
@@ -71,6 +77,34 @@ Bottom line: if an exception would otherwise crash something, you're already cov
71
77
  code already catches and handles it, route that specific `rescue` through `Rails.error.handle`/
72
78
  `.record` instead of a bare one wherever you want ForgeOps to know about it.
73
79
 
80
+ ### Outside Rails: Rack, Sinatra, and scripts
81
+
82
+ Without Rails there's no `Rails.error`, so add the Rack middleware, which reports any exception
83
+ escaping your app (and, in Sinatra, an error it turned into a 5xx response itself), then re-raises
84
+ it unchanged:
85
+
86
+ ```ruby
87
+ # config.ru, or inside a Sinatra::Base subclass
88
+ require "forge_ops_tracker"
89
+
90
+ ForgeOpsTracker.configure do |config|
91
+ config.dsn = ENV["FORGE_OPS_DSN"]
92
+ end
93
+
94
+ use ForgeOpsTracker::Middleware::RequestContext
95
+ ```
96
+
97
+ Report an exception you rescued yourself, anywhere (Rails included), with
98
+ `ForgeOpsTracker.capture_exception`:
99
+
100
+ ```ruby
101
+ begin
102
+ charge_card(order)
103
+ rescue Stripe::CardError => e
104
+ ForgeOpsTracker.capture_exception(e, context: { order_id: order.id })
105
+ end
106
+ ```
107
+
74
108
  ### Sidekiq job failures and renamed job classes
75
109
 
76
110
  A failing Sidekiq job is reported through `Rails.error` like any other error. Since 0.10.1 the issue
@@ -254,7 +288,7 @@ end
254
288
  ```
255
289
 
256
290
  Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
257
- flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
291
+ flushes are accepted but not recorded, and the response says why.
258
292
 
259
293
  ## Performance monitoring
260
294
 
@@ -279,8 +313,7 @@ end
279
313
  ```
280
314
 
281
315
  Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
282
- periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
283
- failure.
316
+ periodic flushes are accepted but not recorded, and the response says why.
284
317
 
285
318
  ### Database queries, background jobs, and outbound HTTP calls
286
319
 
@@ -353,8 +386,7 @@ unaffected: confirmed directly that Net::HTTP connects lazily, inside `#request`
353
386
  time it's called on a not-yet-started instance.
354
387
 
355
388
  Requires a ForgeOps plan that includes performance monitoring, the same plan feature performance
356
- monitoring itself already requires; on a plan that doesn't, the periodic flushes are simply
357
- rejected server-side and dropped, exactly like any other delivery failure.
389
+ monitoring itself already requires; on a plan that doesn't, the periodic flushes are accepted but not recorded, and the response says why.
358
390
 
359
391
  ## Tracing
360
392
 
@@ -408,7 +440,7 @@ end
408
440
  ```
409
441
 
410
442
  Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, traces are
411
- simply rejected server-side and dropped, exactly like any other delivery failure. The `trace_id`
443
+ accepted but not recorded, and the response says why. The `trace_id`
412
444
  on error events is sent either way.
413
445
 
414
446
  ### The SQL behind each database span
@@ -476,7 +508,7 @@ What it doesn't do:
476
508
  It does use one pooled connection, briefly, for each plan. With the limits above that's at most 10
477
509
  short checkouts a minute, but on a pool that's already at capacity, leave it off or raise
478
510
  `explain_threshold_ms`. Plans are part of performance monitoring; on a plan without it, they're
479
- rejected server-side and dropped.
511
+ accepted but not recorded, and the response says why.
480
512
 
481
513
  ## What changed
482
514
 
@@ -521,8 +553,8 @@ their values), so an added or removed variable shows up as a change. Names that
521
553
  host, like `HOSTNAME`, `PATH`, `PORT`, `LC_*`, and Kubernetes service variables, are left out, as
522
554
  are the gem's own `FORGE_OPS_*` settings.
523
555
 
524
- Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are rejected
525
- server-side and dropped, exactly like any other delivery failure.
556
+ Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are accepted but not recorded,
557
+ and the response says why.
526
558
 
527
559
  ## Custom metrics
528
560
 
@@ -543,7 +575,7 @@ end
543
575
  ```
544
576
 
545
577
  Requires a ForgeOps plan that includes custom metrics; on a plan that doesn't, the periodic
546
- flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
578
+ flushes are accepted but not recorded, and the response says why.
547
579
 
548
580
  ## Infrastructure monitoring
549
581
 
@@ -572,5 +604,4 @@ ForgeOpsTracker.capture_infrastructure_metric("disk_used_percent", value: disk_u
572
604
 
573
605
  The example above is Linux-specific (`/proc/loadavg`, `/proc/meminfo`); on another OS, read that
574
606
  platform's own equivalents instead. Requires a ForgeOps plan that includes infrastructure
575
- monitoring; on a plan that doesn't, the periodic flushes are simply rejected server-side and
576
- dropped, exactly like any other delivery failure.
607
+ monitoring; on a plan that doesn't, the periodic flushes are accepted but not recorded, and the response says why.
@@ -66,7 +66,7 @@ module ForgeOpsTracker
66
66
  end
67
67
 
68
68
  def deliver
69
- client.deliver_change_snapshot(payload)
69
+ DeliveryStatus.from(client.deliver_change_snapshot(payload)) == DeliveryStatus::DELIVERED
70
70
  rescue StandardError => e
71
71
  configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
72
72
  false
@@ -5,8 +5,10 @@ require "uri"
5
5
  module ForgeOpsTracker
6
6
  # Delivers one payload over HTTP. Every failure mode; DNS, connection,
7
7
  # timeout, TLS, a non-2xx response; is caught here and turned into a
8
- # `false` return rather than a raised exception, since a broken or
9
- # unreachable tracker must never be able to break the host app.
8
+ # DeliveryStatus rather than a raised exception, since a broken or
9
+ # unreachable tracker must never be able to break the host app. Every
10
+ # deliver_* method answers one of DeliveryStatus::DELIVERED, REJECTED or
11
+ # FAILED (all three are truthy, so compare, never test for truth).
10
12
  class Client
11
13
  def initialize(configuration)
12
14
  @configuration = configuration
@@ -16,8 +18,8 @@ module ForgeOpsTracker
16
18
  post(configuration.ingestion_uri, payload)
17
19
  end
18
20
 
19
- # Same delivery contract as deliver above (never raises, returns a bare
20
- # true/false), just a different endpoint under the same DSN's host and
21
+ # Same delivery contract as deliver above (never raises, returns a
22
+ # DeliveryStatus), just a different endpoint under the same DSN's host and
21
23
  # api_key; see Configuration#session_checkins_uri.
22
24
  def deliver_session_checkin(payload)
23
25
  post(configuration.session_checkins_uri, payload)
@@ -61,7 +63,7 @@ module ForgeOpsTracker
61
63
  end
62
64
 
63
65
  # Same delivery contract again; see Configuration#changes_uri and ForgeOpsTracker.record_change.
64
- # One change per call. A plan without change tracking answers 403, which is just a false here.
66
+ # One change per call. A plan without change tracking answers 403, which is a REJECTED here.
65
67
  def deliver_change(payload)
66
68
  post(configuration.changes_uri, payload)
67
69
  end
@@ -72,7 +74,7 @@ module ForgeOpsTracker
72
74
  end
73
75
 
74
76
  # Same again; see Configuration#query_plans_uri and QueryExplainer. One plan per call. A plan
75
- # without performance monitoring answers 403, which is just a false here.
77
+ # without performance monitoring answers 403, which is a REJECTED here.
76
78
  def deliver_query_plan(payload)
77
79
  post(configuration.query_plans_uri, payload)
78
80
  end
@@ -81,13 +83,15 @@ module ForgeOpsTracker
81
83
  attr_reader :configuration
82
84
 
83
85
  def post(uri, payload)
84
- return false unless uri
86
+ return DeliveryStatus::FAILED unless uri
85
87
 
86
88
  response = http_for(uri).request(build_request(uri, payload))
87
- response.is_a?(Net::HTTPSuccess)
89
+ status = DeliveryStatus.for_code(response.code)
90
+ log { "delivery to #{uri.path} answered HTTP #{response.code}" } unless status == DeliveryStatus::DELIVERED
91
+ status
88
92
  rescue StandardError => e
89
93
  log { "delivery failed: #{e.class}: #{e.message}" }
90
- false
94
+ DeliveryStatus::FAILED
91
95
  end
92
96
 
93
97
  def http_for(uri)
@@ -23,7 +23,7 @@ module ForgeOpsTracker
23
23
 
24
24
  def initialize
25
25
  @dsn = ENV["FORGE_OPS_DSN"]
26
- @environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
26
+ @environment = self.class.resolve_environment
27
27
  @release = ENV["FORGE_OPS_RELEASE"]
28
28
  @server_name = safe_hostname
29
29
  @enabled_environments = %w[production staging]
@@ -262,10 +262,39 @@ module ForgeOpsTracker
262
262
  end
263
263
 
264
264
  def enabled?
265
- !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
265
+ configured? && environment_enabled?
266
+ end
267
+
268
+ # Whether a usable DSN is set, regardless of environment.
269
+ def configured?
270
+ !blank?(dsn) && !blank?(api_key)
271
+ end
272
+
273
+ # The one-line warning ForgeOpsTracker.warn_if_environment_disabled logs when a DSN is set but
274
+ # this environment isn't one that sends, or nil when there's nothing to warn about (no DSN, or
275
+ # the environment is enabled).
276
+ def disabled_environment_warning
277
+ return nil if !configured? || environment_enabled?
278
+
279
+ enabled = enabled_environments.map(&:to_s)
280
+ enabled = enabled.empty? ? "none" : enabled.join(", ")
281
+ "[ForgeOps] Not sending: this environment is \"#{environment}\", and only #{enabled} are enabled. " \
282
+ "Set FORGE_OPS_ENVIRONMENT=production (or add \"#{environment}\" to enabled_environments) to send from here."
283
+ end
284
+
285
+ # FORGE_OPS_ENVIRONMENT, then RAILS_ENV, then RACK_ENV, then "production". Defaulting to
286
+ # "production" means a plain Ruby or Rack process that only sets a DSN actually sends; a Rails
287
+ # app gets Rails.env from the Railtie instead (unless FORGE_OPS_ENVIRONMENT is set), so
288
+ # development and test stay quiet there, with one warning at boot when a DSN is set.
289
+ def self.resolve_environment(env = ENV)
290
+ [ env["FORGE_OPS_ENVIRONMENT"], env["RAILS_ENV"], env["RACK_ENV"] ].find { |value| value && !value.strip.empty? } || "production"
266
291
  end
267
292
 
268
293
  private
294
+ def environment_enabled?
295
+ enabled_environments.map(&:to_s).include?(environment.to_s)
296
+ end
297
+
269
298
  # Same substitution every *_uri method above spells out by hand: a sibling path under the
270
299
  # DSN's own /api/v1/events.
271
300
  def sibling_uri(path)
@@ -39,7 +39,7 @@ module ForgeOpsTracker
39
39
  rescue ThreadError
40
40
  break
41
41
  end
42
- client.public_send(@deliver_with, payload)
42
+ deliver(payload)
43
43
  end
44
44
  sleep 0.01 while @in_flight.positive? && monotonic_now < deadline
45
45
  rescue StandardError => e
@@ -78,7 +78,7 @@ module ForgeOpsTracker
78
78
  payload = @queue.pop
79
79
  @in_flight += 1
80
80
  begin
81
- client.public_send(@deliver_with, payload)
81
+ deliver(payload)
82
82
  ensure
83
83
  @in_flight -= 1
84
84
  end
@@ -87,6 +87,17 @@ module ForgeOpsTracker
87
87
  end
88
88
  end
89
89
 
90
+ # One attempt per payload, as it has always been: a queued event is never held back for a
91
+ # retry, whatever the outcome, so all that is left to do with a refusal is to say so once.
92
+ def deliver(payload)
93
+ case DeliveryStatus.from(client.public_send(@deliver_with, payload))
94
+ when DeliveryStatus::REJECTED
95
+ log { "dropping event: the server rejected it and will not accept a retry" }
96
+ when DeliveryStatus::FAILED
97
+ log { "dropping event: delivery failed" }
98
+ end
99
+ end
100
+
90
101
  # Registered on the first push, not at load, so a process that never reports anything never
91
102
  # waits on exit at all.
92
103
  def register_drain
@@ -0,0 +1,74 @@
1
+ module ForgeOpsTracker
2
+ # What became of one delivery attempt, as every Client#deliver_* method reports it. A bare
3
+ # true/false could not tell "the server will never take this payload" apart from "try again
4
+ # later", so a batch a plan or a validation rule refused was re-sent at every flush for as long
5
+ # as the process lived.
6
+ module DeliveryStatus
7
+ # Any 2xx.
8
+ DELIVERED = :delivered
9
+ # A 4xx other than 408 and 429: sending the same payload again can only fail the same way.
10
+ REJECTED = :rejected
11
+ # A network error, a timeout, a 5xx, a 408 or a 429: worth another attempt later.
12
+ FAILED = :failed
13
+
14
+ RETRYABLE_CLIENT_ERRORS = [ 408, 429 ].freeze
15
+
16
+ def self.for_code(code)
17
+ code = code.to_i
18
+ return DELIVERED if code.between?(200, 299)
19
+ return REJECTED if code.between?(400, 499) && !RETRYABLE_CLIENT_ERRORS.include?(code)
20
+
21
+ FAILED
22
+ end
23
+
24
+ # Reads whatever a client handed back. A status passes through; anything else is read the way
25
+ # the old boolean contract was (truthy delivered, falsy failed for now), so a client injected
26
+ # through a `client:` keyword that still answers true/false keeps working.
27
+ def self.from(result)
28
+ return result if [ DELIVERED, REJECTED, FAILED ].include?(result)
29
+
30
+ result ? DELIVERED : FAILED
31
+ end
32
+ end
33
+
34
+ # Decides, after each flush of one buffer/flusher, whether the batch it just sent is done with.
35
+ # Delivered and rejected batches are let go of at once; one that failed for now is kept, but only
36
+ # for MAX_FAILED_FLUSHES flushes in a row, so an outage (or a server answering 500 to one
37
+ # particular batch) can never pin the same data in memory for the life of the process.
38
+ class FlushOutcome
39
+ MAX_FAILED_FLUSHES = 5
40
+
41
+ # what names the batch in the debug log ("metrics", "performance samples").
42
+ def initialize(configuration, what)
43
+ @configuration = configuration
44
+ @what = what
45
+ @failed_flushes = 0
46
+ end
47
+
48
+ # True when the caller should remove what it just sent (delivered, or dropped), false when it
49
+ # should keep it for the next flush.
50
+ def release?(result)
51
+ case DeliveryStatus.from(result)
52
+ when DeliveryStatus::DELIVERED
53
+ @failed_flushes = 0
54
+ true
55
+ when DeliveryStatus::REJECTED
56
+ @failed_flushes = 0
57
+ log { "dropping buffered #{@what}: the server rejected them and will not accept a retry" }
58
+ true
59
+ else
60
+ @failed_flushes += 1
61
+ return false if @failed_flushes < MAX_FAILED_FLUSHES
62
+
63
+ @failed_flushes = 0
64
+ log { "dropping buffered #{@what}: #{MAX_FAILED_FLUSHES} flushes in a row failed" }
65
+ true
66
+ end
67
+ end
68
+
69
+ private
70
+ def log
71
+ @configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
72
+ end
73
+ end
74
+ end
@@ -8,12 +8,17 @@ module ForgeOpsTracker
8
8
  # kind of thing worth an accurate individual record of, not something to pre-aggregate away the
9
9
  # way PerformanceFlusher's own count/sum/max buckets duration.
10
10
  class FailureEventBuffer
11
+ # Same cap, and the same "drop the newest, not the oldest" reasoning, as MetricBuffer's own
12
+ # MAX_ENTRIES: a burst of failures during an outage must not grow this without bound.
13
+ MAX_ENTRIES = 1000
14
+
11
15
  def initialize(configuration, client: Client.new(configuration))
12
16
  @configuration = configuration
13
17
  @client = client
14
18
  @mutex = Mutex.new
15
19
  @entries = []
16
20
  @thread = nil
21
+ @outcome = FlushOutcome.new(configuration, "failure events")
17
22
 
18
23
  # Flushes whatever's already buffered on a normal process exit, so the last partial window
19
24
  # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
@@ -24,6 +29,8 @@ module ForgeOpsTracker
24
29
  ensure_worker_started
25
30
 
26
31
  @mutex.synchronize do
32
+ return false if @entries.size >= MAX_ENTRIES
33
+
27
34
  @entries << {
28
35
  kind: kind, transaction_name: transaction_name, error_class: error_class, error_message: error_message,
29
36
  environment: configuration.environment.to_s, release: configuration.release,
@@ -33,18 +40,22 @@ module ForgeOpsTracker
33
40
  end
34
41
 
35
42
  # Snapshots and resets the buffered entries, then delivers them as one batch. Same "a failed
36
- # delivery keeps every entry for the next attempt" contract as MetricBuffer#flush.
43
+ # delivery keeps every entry for the next attempt" contract as MetricBuffer#flush, with the
44
+ # same two exceptions: a rejected batch, and one that failed FlushOutcome::MAX_FAILED_FLUSHES
45
+ # flushes in a row, are dropped.
37
46
  def flush
38
47
  snapshot = nil
39
48
 
40
49
  @mutex.synchronize do
41
50
  return if @entries.empty?
42
- snapshot = @entries
51
+ snapshot = @entries.dup
43
52
  end
44
53
 
45
- return unless client.deliver_failure_events(snapshot)
54
+ return unless @outcome.release?(client.deliver_failure_events(snapshot))
46
55
 
47
- @mutex.synchronize { @entries = [] }
56
+ # Exactly the entries just sent: anything recorded while the request was in flight sits after
57
+ # them and stays for the next flush.
58
+ @mutex.synchronize { @entries.shift(snapshot.size) }
48
59
  end
49
60
 
50
61
  private
@@ -3,16 +3,16 @@ require "thread"
3
3
  module ForgeOpsTracker
4
4
  # Collects individual ForgeOpsTracker.capture_infrastructure_metric readings in-process and
5
5
  # periodically flushes them as one batch, same shape as MetricBuffer (a sibling class, not a
6
- # shared base -- this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
6
+ # shared base; this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
7
7
  # readable classes despite their own similar shape too, and these two follow that precedent).
8
8
  # Its main real caller is a short-lived cron/script process that exits right after a handful of
9
9
  # capture calls, so the at_exit flush below is what actually matters in practice; the periodic
10
10
  # background-thread timer still runs for a long-lived process that chooses to call this
11
11
  # continuously instead, but rarely gets the chance to fire in the cron case.
12
12
  class InfrastructureMetricBuffer
13
- # Once this many entries are buffered, further ones are dropped until a flush succeeds: a plan
14
- # without the feature answers 403 on every flush, and an uncapped buffer would then grow for as
15
- # long as the process lives. Dropping the newest rather than the oldest keeps the entries a flush
13
+ # Once this many entries are buffered, further ones are dropped until a flush lets go of them:
14
+ # an unreachable server fails every flush, and an uncapped buffer would keep growing until
15
+ # FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
16
16
  # is delivering at the front of the array, which is what makes removing exactly them exact.
17
17
  MAX_ENTRIES = 1000
18
18
 
@@ -22,6 +22,7 @@ module ForgeOpsTracker
22
22
  @mutex = Mutex.new
23
23
  @entries = []
24
24
  @thread = nil
25
+ @outcome = FlushOutcome.new(configuration, "infrastructure metrics")
25
26
 
26
27
  at_exit { flush }
27
28
  end
@@ -38,7 +39,7 @@ module ForgeOpsTracker
38
39
  end
39
40
  end
40
41
 
41
- # Same reset-on-success/keep-on-failure contract as MetricBuffer#flush; see that class's own
42
+ # Same reset-on-success/keep-on-failure/drop-on-rejection contract as MetricBuffer#flush; see that class's own
42
43
  # comment for the accepted race this shares with it.
43
44
  def flush
44
45
  snapshot = nil
@@ -48,9 +49,9 @@ module ForgeOpsTracker
48
49
  snapshot = @entries.dup
49
50
  end
50
51
 
51
- return unless client.deliver_infrastructure_metrics(snapshot)
52
+ return unless @outcome.release?(client.deliver_infrastructure_metrics(snapshot))
52
53
 
53
- # Exactly the entries just delivered: anything recorded while the request was in flight sits
54
+ # Exactly the entries just sent: anything recorded while the request was in flight sits
54
55
  # after them and stays for the next flush (resetting the whole array here lost it).
55
56
  @mutex.synchronize { @entries.shift(snapshot.size) }
56
57
  end
@@ -10,9 +10,9 @@ module ForgeOpsTracker
10
10
  # they'll want a genuinely accurate count/sum of later, not something to pre-aggregate away
11
11
  # client-side, so CustomMetricsController stores one row per entry as-is.
12
12
  class MetricBuffer
13
- # Once this many entries are buffered, further ones are dropped until a flush succeeds: a plan
14
- # without the feature answers 403 on every flush, and an uncapped buffer would then grow for as
15
- # long as the process lives. Dropping the newest rather than the oldest keeps the entries a flush
13
+ # Once this many entries are buffered, further ones are dropped until a flush lets go of them:
14
+ # an unreachable server fails every flush, and an uncapped buffer would keep growing until
15
+ # FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
16
16
  # is delivering at the front of the array, which is what makes removing exactly them exact.
17
17
  MAX_ENTRIES = 1000
18
18
 
@@ -22,6 +22,7 @@ module ForgeOpsTracker
22
22
  @mutex = Mutex.new
23
23
  @entries = []
24
24
  @thread = nil
25
+ @outcome = FlushOutcome.new(configuration, "metrics")
25
26
 
26
27
  # Flushes whatever's already buffered on a normal process exit, so the last partial window
27
28
  # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
@@ -44,10 +45,11 @@ module ForgeOpsTracker
44
45
  end
45
46
  end
46
47
 
47
- # Snapshots and resets the buffered entries, then delivers them as one batch. A failed
48
- # delivery keeps every entry where it is rather than resetting, so the next flush's batch
49
- # just grows instead of losing what was already buffered; same reasoning SessionFlusher#flush
50
- # already documents, including the same small, accepted race (an entry recorded during the
48
+ # Snapshots and resets the buffered entries, then delivers them as one batch. A delivery that
49
+ # failed for now keeps every entry where it is rather than resetting, so the next flush's batch
50
+ # just grows instead of losing what was already buffered (a rejected batch, or one that failed
51
+ # FlushOutcome::MAX_FAILED_FLUSHES flushes in a row, is dropped instead); same reasoning
52
+ # SessionFlusher#flush already documents, including the same small, accepted race (an entry recorded during the
51
53
  # in-flight HTTP request gets folded into this snapshot's own reset rather than kept for the
52
54
  # next one) that class already lives with, for the same reason: simple and consistent beats a
53
55
  # cleverer dedup that would only trade one rare edge case for a subtler one.
@@ -59,9 +61,9 @@ module ForgeOpsTracker
59
61
  snapshot = @entries.dup
60
62
  end
61
63
 
62
- return unless client.deliver_metrics(snapshot)
64
+ return unless @outcome.release?(client.deliver_metrics(snapshot))
63
65
 
64
- # Exactly the entries just delivered: anything recorded while the request was in flight sits
66
+ # Exactly the entries just sent: anything recorded while the request was in flight sits
65
67
  # after them and stays for the next flush (resetting the whole array here lost it).
66
68
  @mutex.synchronize { @entries.shift(snapshot.size) }
67
69
  end
@@ -19,6 +19,14 @@ module ForgeOpsTracker
19
19
  # Rescues Exception, not StandardError, and always re-raises: this only ever observes an
20
20
  # exception on its way out, it never handles one, so there's no reason to let anything slip
21
21
  # past without its context attached.
22
+ #
23
+ # Outside Rails it is also what reports an unhandled request error, since there's no
24
+ # Rails.error to do it: add it to a plain Rack or Sinatra app with
25
+ # `use ForgeOpsTracker::Middleware::RequestContext`. It reports a StandardError escaping the
26
+ # app, and a Sinatra error the app turned into a 5xx response itself (Sinatra keeps that in
27
+ # env["sinatra.error"] rather than raising it when raise_errors is off, its production
28
+ # default). report_exceptions defaults to true only when Rails isn't loaded, and the Railtie
29
+ # passes false, so nothing is reported twice under Rails.
22
30
  class RequestContext
23
31
  THREAD_KEY = :forge_ops_tracker_request_context
24
32
 
@@ -28,9 +36,13 @@ module ForgeOpsTracker
28
36
  # app raised them.
29
37
  SNAPSHOT_IVAR = :@__forge_ops_tracker_request_snapshot
30
38
 
31
- def initialize(app, configuration: ForgeOpsTracker.configuration)
39
+ FRAMEWORK_ERROR_KEY = "sinatra.error".freeze
40
+
41
+ def initialize(app, configuration: ForgeOpsTracker.configuration, report_exceptions: !defined?(::Rails::Railtie))
32
42
  @app = app
33
43
  @configuration = configuration
44
+ @report_exceptions = report_exceptions
45
+ ForgeOpsTracker.warn_if_environment_disabled if report_exceptions && configuration.equal?(ForgeOpsTracker.configuration)
34
46
  end
35
47
 
36
48
  def call(env)
@@ -38,11 +50,14 @@ module ForgeOpsTracker
38
50
 
39
51
  state = RequestState.for(env)
40
52
  Thread.current[THREAD_KEY] = state
41
- app.call(env)
53
+ response = app.call(env)
54
+ report_framework_error(env, response) if report_exceptions
55
+ response
42
56
  rescue Exception => e
43
57
  if state
44
58
  state.mark_errored!
45
59
  self.class.attach_snapshot(e, state)
60
+ report(e, env) if report_exceptions && e.is_a?(StandardError)
46
61
  end
47
62
  raise
48
63
  ensure
@@ -84,7 +99,23 @@ module ForgeOpsTracker
84
99
  end
85
100
 
86
101
  private
87
- attr_reader :app, :configuration
102
+ attr_reader :app, :configuration, :report_exceptions
103
+
104
+ # Called while this request's RequestState and the other middlewares' thread-locals are
105
+ # all still set, so the report carries the trace id, user and breadcrumbs.
106
+ def report(error, env)
107
+ ForgeOpsTracker.capture_exception(error, context: { "path" => env["PATH_INFO"], "method" => env["REQUEST_METHOD"] }.compact)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
112
+ def report_framework_error(env, response)
113
+ error = env[FRAMEWORK_ERROR_KEY]
114
+ return unless error.is_a?(StandardError) && response.is_a?(Array) && response.first.to_i >= 500
115
+
116
+ RequestContext.current&.mark_errored!
117
+ report(error, env)
118
+ end
88
119
  end
89
120
  end
90
121
  end
@@ -17,6 +17,7 @@ module ForgeOpsTracker
17
17
  @buckets = {}
18
18
  @period_started_at = Time.now.utc
19
19
  @thread = nil
20
+ @outcome = FlushOutcome.new(configuration, "performance samples")
20
21
 
21
22
  # Flushes whatever's already tallied on a normal process exit, so the last partial window
22
23
  # (anything shorter than a full performance_flush_interval) isn't silently dropped; same
@@ -48,6 +49,9 @@ module ForgeOpsTracker
48
49
  # just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
49
50
  # already documents, and the same reason PerformanceSamplesController accepts a batch rather
50
51
  # than a single row: a failed flush shouldn't have to re-deliver by transaction one at a time.
52
+ # That only holds for a delivery that failed for now, and only FlushOutcome::MAX_FAILED_FLUSHES
53
+ # times in a row: a batch the server rejected, or one that keeps failing, is removed exactly
54
+ # the way a delivered one is, since re-sending it could never succeed.
51
55
  #
52
56
  # Only exactly what this snapshot delivered is removed afterward (subtracted from whatever is in
53
57
  # each bucket by then), never the whole hash reset: #record can run on another thread while the
@@ -84,7 +88,7 @@ module ForgeOpsTracker
84
88
  end
85
89
  end
86
90
 
87
- return unless snapshot && client.deliver_performance_samples(snapshot)
91
+ return unless snapshot && @outcome.release?(client.deliver_performance_samples(snapshot))
88
92
 
89
93
  @mutex.synchronize do
90
94
  sent.each do |key, delivered|
@@ -135,10 +135,11 @@ module ForgeOpsTracker
135
135
  return false
136
136
  end
137
137
 
138
- client.deliver_query_plan(
138
+ result = client.deliver_query_plan(
139
139
  statement: job.statement, db_system: "postgresql", plan: masked_plan,
140
140
  duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
141
141
  )
142
+ DeliveryStatus.from(result) == DeliveryStatus::DELIVERED
142
143
  rescue StandardError => e
143
144
  log { "EXPLAIN failed: #{e.class}: #{e.message}" }
144
145
  false
@@ -38,10 +38,19 @@ module ForgeOpsTracker
38
38
  initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
39
39
  configuration = ForgeOpsTracker.configuration
40
40
  configuration.app_root ||= app.root.to_s
41
- configuration.environment = Rails.env.to_s
41
+ # FORGE_OPS_ENVIRONMENT wins when set, so a staging box running RAILS_ENV=production can
42
+ # still report as staging; otherwise Rails.env, so development and test stay quiet.
43
+ forge_ops_environment = ENV["FORGE_OPS_ENVIRONMENT"].to_s.strip
44
+ configuration.environment = forge_ops_environment.empty? ? Rails.env.to_s : forge_ops_environment
42
45
  configuration.logger ||= Rails.logger
43
46
 
44
- Rails.error.subscribe(ForgeOpsTracker::ErrorSubscriber.new(configuration))
47
+ Rails.error.subscribe(ForgeOpsTracker.error_subscriber)
48
+ end
49
+
50
+ # After every config/initializers file has run, so the DSN and environment are final: one
51
+ # warning at boot when a DSN is set but this environment doesn't send (development, say).
52
+ config.after_initialize do
53
+ ForgeOpsTracker.warn_if_environment_disabled
45
54
  end
46
55
 
47
56
  initializer "forge_ops_tracker.track_sessions" do |app|
@@ -94,7 +103,8 @@ module ForgeOpsTracker
94
103
  # PerformanceInstrumentation.controller_endpoint for what each is used for).
95
104
  initializer "forge_ops_tracker.request_context" do |app|
96
105
  configuration = ForgeOpsTracker.configuration
97
- app.middleware.use ForgeOpsTracker::Middleware::RequestContext, configuration: configuration
106
+ # report_exceptions: false because Rails.error already reports an unhandled request error.
107
+ app.middleware.use ForgeOpsTracker::Middleware::RequestContext, configuration: configuration, report_exceptions: false
98
108
 
99
109
  ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*args|
100
110
  next unless configuration.enabled?
@@ -16,6 +16,7 @@ module ForgeOpsTracker
16
16
  @crashed_sessions_count = 0
17
17
  @period_started_at = Time.now.utc
18
18
  @thread = nil
19
+ @outcome = FlushOutcome.new(configuration, "session counts")
19
20
 
20
21
  # Flushes whatever's already tallied on a normal process exit, so the last partial window
21
22
  # (anything shorter than a full session_flush_interval) isn't silently dropped.
@@ -34,6 +35,8 @@ module ForgeOpsTracker
34
35
  # Snapshots and resets the in-process counters, then delivers them. A failed delivery keeps
35
36
  # the counts where they are rather than resetting, so the next flush's window just grows
36
37
  # instead of losing what was already tallied; there's no other copy of this data anywhere.
38
+ # Counts the server rejected, or that failed FlushOutcome::MAX_FAILED_FLUSHES flushes in a
39
+ # row, are reset all the same: sending them again could never succeed.
37
40
  def flush
38
41
  snapshot = nil
39
42
 
@@ -50,7 +53,7 @@ module ForgeOpsTracker
50
53
  }
51
54
  end
52
55
 
53
- return unless snapshot && client.deliver_session_checkin(snapshot)
56
+ return unless snapshot && @outcome.release?(client.deliver_session_checkin(snapshot))
54
57
 
55
58
  @mutex.synchronize do
56
59
  @sessions_count = 0
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.15.1"
2
+ VERSION = "0.17.0"
3
3
  end
@@ -4,6 +4,7 @@ require "forge_ops_tracker/configuration"
4
4
  require "forge_ops_tracker/pii_scrubber"
5
5
  require "forge_ops_tracker/sql_statement"
6
6
  require "forge_ops_tracker/event_builder"
7
+ require "forge_ops_tracker/delivery_status"
7
8
  require "forge_ops_tracker/client"
8
9
  require "forge_ops_tracker/delivery_queue"
9
10
  require "forge_ops_tracker/change"
@@ -30,6 +31,8 @@ require "forge_ops_tracker/middleware/span_tracing"
30
31
  require "forge_ops_tracker/middleware/request_context"
31
32
 
32
33
  module ForgeOpsTracker
34
+ ENVIRONMENT_WARNING_LOCK = Mutex.new
35
+
33
36
  class << self
34
37
  def configuration
35
38
  @configuration ||= Configuration.new
@@ -37,10 +40,54 @@ module ForgeOpsTracker
37
40
 
38
41
  # Outside Rails this is also where the startup ChangeSnapshot goes out, since there's no
39
42
  # Railtie to send it once the app has booted; under Rails the Railtie sends it instead, after
40
- # initialization, when Active Record can report the migration version.
43
+ # initialization, when Active Record can report the migration version. The same goes for the
44
+ # one-time "Not sending" warning (see warn_if_environment_disabled).
41
45
  def configure
42
46
  yield configuration
43
- ChangeSnapshot.start(configuration) unless defined?(::Rails::Railtie)
47
+ return if defined?(::Rails::Railtie)
48
+
49
+ warn_if_environment_disabled
50
+ ChangeSnapshot.start(configuration)
51
+ end
52
+
53
+ # Reports an exception you've already rescued, e.g. from a Sinatra error handler, a plain Rack
54
+ # app, a script, or a background worker that isn't wired through Rails.error. Goes through the
55
+ # same ErrorSubscriber Rails.error reports into, so it carries the current request's user,
56
+ # breadcrumbs and trace the same way, and is a no-op when the gem isn't enabled. Under Rails,
57
+ # Rails.error.report/handle/record do the same thing. Never raises.
58
+ def capture_exception(error, context: {})
59
+ warn_if_environment_disabled unless configuration.enabled?
60
+ error_subscriber.report(error, handled: true, context: context || {}, source: "application")
61
+ end
62
+
63
+ # The one ErrorSubscriber this process reports through: the Railtie subscribes it to
64
+ # Rails.error, and capture_exception calls it directly, so both share one delivery queue.
65
+ def error_subscriber
66
+ @error_subscriber ||= ErrorSubscriber.new(configuration)
67
+ end
68
+
69
+ # Logs one warning per process when a DSN is set but the environment isn't in
70
+ # enabled_environments, so a process that would send nothing says so instead of staying
71
+ # silent. Goes to configuration.logger (Rails.logger under Rails), or $stderr when there isn't
72
+ # one. Never logs without a DSN. Returns whether it logged.
73
+ def warn_if_environment_disabled
74
+ message = configuration.disabled_environment_warning
75
+ return false unless message
76
+
77
+ ENVIRONMENT_WARNING_LOCK.synchronize do
78
+ return false if @environment_warning_logged
79
+
80
+ @environment_warning_logged = true
81
+ end
82
+
83
+ if configuration.logger
84
+ configuration.logger.warn(message)
85
+ else
86
+ Kernel.warn(message)
87
+ end
88
+ true
89
+ rescue StandardError
90
+ false
44
91
  end
45
92
 
46
93
  # Records one thing that changed in a running system, e.g.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forge_ops_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.30'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rack
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '2.2'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '2.2'
82
96
  description: Hooks Rails' error reporter and reports exceptions to ForgeOps over HTTP,
83
97
  without ever raising back into the host application.
84
98
  executables: []
@@ -95,6 +109,7 @@ files:
95
109
  - lib/forge_ops_tracker/client.rb
96
110
  - lib/forge_ops_tracker/configuration.rb
97
111
  - lib/forge_ops_tracker/delivery_queue.rb
112
+ - lib/forge_ops_tracker/delivery_status.rb
98
113
  - lib/forge_ops_tracker/error_subscriber.rb
99
114
  - lib/forge_ops_tracker/event_builder.rb
100
115
  - lib/forge_ops_tracker/failure_event_buffer.rb