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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +44 -13
- data/lib/forge_ops_tracker/change_snapshot.rb +1 -1
- data/lib/forge_ops_tracker/client.rb +13 -9
- data/lib/forge_ops_tracker/configuration.rb +31 -2
- data/lib/forge_ops_tracker/delivery_queue.rb +13 -2
- data/lib/forge_ops_tracker/delivery_status.rb +74 -0
- data/lib/forge_ops_tracker/failure_event_buffer.rb +15 -4
- data/lib/forge_ops_tracker/infrastructure_metric_buffer.rb +8 -7
- data/lib/forge_ops_tracker/metric_buffer.rb +11 -9
- data/lib/forge_ops_tracker/middleware/request_context.rb +34 -3
- data/lib/forge_ops_tracker/performance_flusher.rb +5 -1
- data/lib/forge_ops_tracker/query_explainer.rb +2 -1
- data/lib/forge_ops_tracker/railtie.rb +13 -3
- data/lib/forge_ops_tracker/session_flusher.rb +4 -1
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +49 -2
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 304c9a15155ecdf53fe8e181dd7f43b56a08d7397e7d63555e428185ece06560
|
|
4
|
+
data.tar.gz: d4d2cb88ffec650e579c7279c590e4eaee8f288bc6ef7041d2c2f5785767fa2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
525
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
20
|
-
#
|
|
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
|
|
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
|
|
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
|
|
86
|
+
return DeliveryStatus::FAILED unless uri
|
|
85
87
|
|
|
86
88
|
response = http_for(uri).request(build_request(uri, payload))
|
|
87
|
-
response.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
48
|
-
#
|
|
49
|
-
# just grows instead of losing what was already buffered
|
|
50
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|