forge_ops_tracker 0.15.1 → 0.16.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: a535fb7cb193fd614ee40259a9b20e62832149c7a2dbeb7d1d40253c0630f65a
4
+ data.tar.gz: 559a19cd52a56addacc87c5533bc0954c7ca2de6eac81a6afb18769f280c06c5
5
5
  SHA512:
6
- metadata.gz: 2a32b34300ee005cd09be7f188275091ec9ba8cf4ef2e65044176a7e6d1427feef170cbc44dbd9f7a25dcf0073b2afb6186db3db2069a5737bfdcb274e62cc7f
7
- data.tar.gz: 4431641c5e95f757dd82e7af0015de824804610ee4ebf252cbe3a4db13ca5f5aa1cac5fda274a94833be652a9d6c5e25ab4406570d4790b165cf242f8490ff66
6
+ metadata.gz: 158342795c1dbb5e7575da43ddcd239c25b52111c268ede3fdfdbb9b5621cf1a10cd120e602f75fe2e57bd46faed9483aaa1a61f8c9f91770474210414400e8f
7
+ data.tar.gz: 5b4f11f1758833c58f8925f1354f8a7da49b80d04344cc2c340a2662a186fcfc0a8451709faef4c78a60d618c9f0ea936c460c97b3af8bad2687fbc8f6fbfdae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 (2026-10-02)
4
+
5
+ - Changed: outside Rails, the environment now defaults to `production` when nothing sets it (it
6
+ was `development`). It comes from `FORGE_OPS_ENVIRONMENT`, then `RAILS_ENV`, then `RACK_ENV`,
7
+ then `production`. Only `production` and `staging` send by default, so before this a plain Ruby
8
+ or Rack process that set just a DSN sent nothing at all. To keep the old behavior there, set
9
+ `FORGE_OPS_ENVIRONMENT=development` (or `RACK_ENV=development`, or `config.environment`).
10
+ - Changed: under Rails, `FORGE_OPS_ENVIRONMENT` now takes precedence over `Rails.env` when it's
11
+ set. Without it nothing changes: development and test still don't send.
12
+ - When a DSN is set but the environment isn't in `enabled_environments`, one warning is now logged
13
+ per process, naming the environment and how to send from it: at boot under Rails (to
14
+ `Rails.logger`), otherwise from `configure`, the Rack middleware, or `capture_exception` (to
15
+ `config.logger`, or stderr when there isn't one). Nothing is logged when no DSN is set.
16
+ - New `ForgeOpsTracker.capture_exception(error, context: {})` reports an exception you've rescued,
17
+ through the same path `Rails.error` reports into, so it works without Rails too.
18
+ - Plain Rack and Sinatra apps are now covered: `use ForgeOpsTracker::Middleware::RequestContext`
19
+ reports an exception escaping the app, and an error Sinatra itself turned into a 5xx response.
20
+ Under Rails it still only adds request context, since `Rails.error` already reports those.
21
+
3
22
  ## 0.15.1 (2026-10-01)
4
23
 
5
24
  - 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.
@@ -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)
@@ -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
@@ -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?
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.15.1"
2
+ VERSION = "0.16.0"
3
3
  end
@@ -30,6 +30,8 @@ require "forge_ops_tracker/middleware/span_tracing"
30
30
  require "forge_ops_tracker/middleware/request_context"
31
31
 
32
32
  module ForgeOpsTracker
33
+ ENVIRONMENT_WARNING_LOCK = Mutex.new
34
+
33
35
  class << self
34
36
  def configuration
35
37
  @configuration ||= Configuration.new
@@ -37,10 +39,54 @@ module ForgeOpsTracker
37
39
 
38
40
  # Outside Rails this is also where the startup ChangeSnapshot goes out, since there's no
39
41
  # 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.
42
+ # initialization, when Active Record can report the migration version. The same goes for the
43
+ # one-time "Not sending" warning (see warn_if_environment_disabled).
41
44
  def configure
42
45
  yield configuration
43
- ChangeSnapshot.start(configuration) unless defined?(::Rails::Railtie)
46
+ return if defined?(::Rails::Railtie)
47
+
48
+ warn_if_environment_disabled
49
+ ChangeSnapshot.start(configuration)
50
+ end
51
+
52
+ # Reports an exception you've already rescued, e.g. from a Sinatra error handler, a plain Rack
53
+ # app, a script, or a background worker that isn't wired through Rails.error. Goes through the
54
+ # same ErrorSubscriber Rails.error reports into, so it carries the current request's user,
55
+ # breadcrumbs and trace the same way, and is a no-op when the gem isn't enabled. Under Rails,
56
+ # Rails.error.report/handle/record do the same thing. Never raises.
57
+ def capture_exception(error, context: {})
58
+ warn_if_environment_disabled unless configuration.enabled?
59
+ error_subscriber.report(error, handled: true, context: context || {}, source: "application")
60
+ end
61
+
62
+ # The one ErrorSubscriber this process reports through: the Railtie subscribes it to
63
+ # Rails.error, and capture_exception calls it directly, so both share one delivery queue.
64
+ def error_subscriber
65
+ @error_subscriber ||= ErrorSubscriber.new(configuration)
66
+ end
67
+
68
+ # Logs one warning per process when a DSN is set but the environment isn't in
69
+ # enabled_environments, so a process that would send nothing says so instead of staying
70
+ # silent. Goes to configuration.logger (Rails.logger under Rails), or $stderr when there isn't
71
+ # one. Never logs without a DSN. Returns whether it logged.
72
+ def warn_if_environment_disabled
73
+ message = configuration.disabled_environment_warning
74
+ return false unless message
75
+
76
+ ENVIRONMENT_WARNING_LOCK.synchronize do
77
+ return false if @environment_warning_logged
78
+
79
+ @environment_warning_logged = true
80
+ end
81
+
82
+ if configuration.logger
83
+ configuration.logger.warn(message)
84
+ else
85
+ Kernel.warn(message)
86
+ end
87
+ true
88
+ rescue StandardError
89
+ false
44
90
  end
45
91
 
46
92
  # 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.16.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: []