forge_ops_tracker 0.5.0 → 0.7.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: f15d33570709adad8c061ee0c432d9a6484fc50881076fae1493b892562188e7
4
- data.tar.gz: f5fada725654a4006a6a44ee0680fb5f7f34cb808087305596892101c743a0ef
3
+ metadata.gz: 72692319cfdf068129d7d005773e21a3b6b78cb9f7816cf0e7c8e6409c9704f1
4
+ data.tar.gz: 713f244a7ab71a005e66b7f00d1dff3e1e3ffc44e304d5a36f79c911e508c74b
5
5
  SHA512:
6
- metadata.gz: 66513f4aebaeb443198a73bad548faaab86653841d213c37000bd343de406926053faa58b65be68934d90c8e12cc1393061ee763e7b5541c4545e90713910fb9
7
- data.tar.gz: 86eb61aca41a42a55a6df37df0d8ef7789b5342055239640a593310da438cda20e3d6e959a0b229c4dbcbe137089e31d8bd537b1eb8d4e91c72bec4acfd6ec8c
6
+ metadata.gz: 57f5372fc1aa6ac8f8830804003c00d68d0633d4f330dc8f6c678faa1c1dbc3f09ad1da0f29c180c63a90dc41f2d0f6acb008e4a726d59ba6b806298af3d6d8f
7
+ data.tar.gz: 2b21f12200f99202c5f26cc739104021d5eaf1cfe16a01b075fe4bc3418ada28cf7befb91470fb1ebd8bb18865cd52e61a705decc5f511c2c497a79c74c36016
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Automatically associates a reported error with the current signed-in user, for any Warden-based
6
+ auth setup (Devise included; Devise just mounts Warden automatically, this isn't Devise-
7
+ specific). On by default, same as every other automatic instrumentation this gem does; opt out
8
+ with `config.track_current_user = false`. `ForgeOpsTracker.set_user(id:, email:, username:)`
9
+ manually sets or overrides it, for a custom auth setup or outside a request entirely (a
10
+ background job, a console session).
11
+
12
+ ## 0.6.0
13
+
14
+ - Performance monitoring now also covers database queries (`sql.active_record`, bucketed by
15
+ Rails' own query name, never raw SQL text), background jobs (`perform.active_job`, any backend,
16
+ Solid Queue included, plus raw Sidekiq workers via a server middleware, deduplicated against
17
+ jobs dispatched through Active Job), and outbound `Net::HTTP` calls, alongside the existing
18
+ controller/action timing. Each shows up as its own `kind` on the same `performance` dashboard
19
+ dataset. Also reports Sidekiq's, Solid Queue's, and Puma's own operational gauges (queue depth,
20
+ worker/dispatcher counts, Puma's thread pool) as infrastructure metrics on a periodic timer
21
+ (`config.gauge_poll_interval`, default 60 seconds), whenever the corresponding library is
22
+ already loaded. All on the same `track_performance` flag as before; no new flags to opt into.
23
+
3
24
  ## 0.5.0
4
25
 
5
26
  - Every reported event now carries `sdk_name` ("ruby"), so a project's language on the ForgeOps
data/README.md CHANGED
@@ -67,13 +67,41 @@ Bottom line: if an exception would otherwise crash something, you're already cov
67
67
  code already catches and handles it, route that specific `rescue` through `Rails.error.handle`/
68
68
  `.record` instead of a bare one wherever you want ForgeOps to know about it.
69
69
 
70
+ ## Identifying users
71
+
72
+ If you're using Devise, or any other Warden-based authentication, the currently signed-in user is
73
+ already associated with every error automatically, on by default, no setup needed:
74
+
75
+ ```ruby
76
+ ForgeOpsTracker.configure do |config|
77
+ config.track_current_user = false # opt out entirely
78
+ end
79
+ ```
80
+
81
+ For a custom auth setup this can't detect, or to override its guess, call
82
+ `ForgeOpsTracker.set_user` yourself:
83
+
84
+ ```ruby
85
+ before_action do
86
+ ForgeOpsTracker.set_user(id: current_user&.id, email: current_user&.email)
87
+ end
88
+ ```
89
+
90
+ Also usable outside a request entirely, a background job or a console session, for the rest of
91
+ whichever thread calls it. `id`/`email`/`username` are all independently optional; call it with
92
+ none of them (or with a signed-out `current_user`, as the `&.` above already handles) to clear
93
+ whatever was set. Shows up on an issue's own detail page, and as its own `affected_users_count`
94
+ alongside the regular event count.
95
+
70
96
  ## PII scrubbing
71
97
 
72
98
  By default, the message, backtrace, and any context/tags you attach are scanned for likely
73
99
  personal data: email addresses, formatted SSNs/credit cards, known API key/token formats, and
74
100
  anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar); redacted
75
101
  before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so
76
- this is a second, earlier layer, not the only one.
102
+ this is a second, earlier layer, not the only one. The user identified via automatic Warden
103
+ detection or `ForgeOpsTracker.set_user` above is a deliberate exception: it's never scrubbed,
104
+ since redacting it would defeat the whole point of identifying users in the first place.
77
105
 
78
106
  To disable it (e.g. if your app already scrubs its own error context, or you have your own reasons
79
107
  to want the raw payload):
@@ -124,3 +152,115 @@ end
124
152
 
125
153
  Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
126
154
  flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
155
+
156
+ ## Performance monitoring
157
+
158
+ By default, every request's controller/action duration is timed (via Rails' own
159
+ `process_action.action_controller` instrumentation, no extra middleware needed) so a dashboard
160
+ widget on ForgeOps can show which parts of your app are actually slow, not just which ones raise.
161
+ Bucketed by transaction ("PostsController#show") and flushed as a small periodic aggregate per
162
+ transaction on a background thread, the same delivery philosophy as session tracking above: a
163
+ broken or unreachable tracker never affects the host app either way.
164
+
165
+ ```ruby
166
+ ForgeOpsTracker.configure do |config|
167
+ config.track_performance = false # opt out entirely
168
+ config.performance_flush_interval = 30 # seconds; default 60
169
+ end
170
+ ```
171
+
172
+ Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
173
+ periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
174
+ failure.
175
+
176
+ ### Database queries, background jobs, and outbound HTTP calls
177
+
178
+ The same automatic instrumentation, on the same `track_performance` flag, also covers:
179
+
180
+ - **Database queries**, via `sql.active_record`: bucketed by Rails' own auto-generated query name
181
+ ("User Load", "Order Create"), not the raw SQL text. Internal schema-introspection queries
182
+ ("SCHEMA") and cached reads (never a real round trip to the database) are skipped.
183
+ - **Background jobs**, via `perform.active_job`: bucketed by job class. Covers any Active Job
184
+ backend, Solid Queue included, with no backend-specific code needed.
185
+ - **Raw Sidekiq workers** (a `Sidekiq::Worker`/`Sidekiq::Job` not dispatched through Active Job):
186
+ a server middleware, registered automatically when Sidekiq is already loaded. A job dispatched
187
+ through Active Job and run on Sidekiq is still only counted once, by the Active Job hook above,
188
+ not twice.
189
+ - **Outbound `Net::HTTP` calls**, bucketed by `"<method> <host>"`, never the full URL (a path or
190
+ query string could carry an id or a token). Most other Ruby HTTP client libraries (Faraday's own
191
+ `net_http` adapter, HTTParty, RestClient) ultimately call through `Net::HTTP`, so this covers
192
+ those too as a side effect.
193
+
194
+ Every one of these shows up as its own `kind` ("controller", "job", "query", "http") on the same
195
+ `performance` dashboard dataset, so "slowest jobs" and "slowest queries" are just a filtered
196
+ version of the same widget builder "slowest transactions" already uses.
197
+
198
+ ### Sidekiq, Solid Queue, and Puma gauges
199
+
200
+ Also on by default whenever the corresponding library is already loaded: Sidekiq's own aggregate
201
+ stats (`Sidekiq::Stats`: processed/failed/scheduled/retry/dead counts, plus a queue-depth reading
202
+ per queue), Solid Queue's own state (active workers, active dispatchers, failed/scheduled/blocked
203
+ counts, a queue-depth reading per queue), and Puma's own thread pool (backlog, running threads,
204
+ pool capacity). Reported as ordinary infrastructure metrics (`sidekiq.queue_depth.default`,
205
+ `puma.backlog`, and so on) via the same `capture_infrastructure_metric` call your own scripts use,
206
+ on a periodic timer:
207
+
208
+ ```ruby
209
+ ForgeOpsTracker.configure do |config|
210
+ config.gauge_poll_interval = 30 # seconds; default 60
211
+ end
212
+ ```
213
+
214
+ No separate opt-out: these are gated by the same `track_performance` flag as everything else in
215
+ this section, not a flag of their own.
216
+
217
+ ## Custom metrics
218
+
219
+ Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
220
+ signup or a payment isn't something this gem could ever detect on its own, so this is an explicit
221
+ call your own code makes. `value` defaults to `1.0` so a bare counter-style call needs no
222
+ argument; pass one for a metric with a real amount. Buffered and flushed as a batch on a
223
+ background thread, same delivery philosophy as everything else here, so this is safe to call from
224
+ inside a request (right after a signup completes, say) without adding network latency there.
225
+
226
+ ```ruby
227
+ ForgeOpsTracker.capture_metric("signups")
228
+ ForgeOpsTracker.capture_metric("revenue", value: 49.00)
229
+
230
+ ForgeOpsTracker.configure do |config|
231
+ config.metric_flush_interval = 30 # seconds; default 60
232
+ end
233
+ ```
234
+
235
+ Requires a ForgeOps plan that includes custom metrics; on a plan that doesn't, the periodic
236
+ flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
237
+
238
+ ## Infrastructure monitoring
239
+
240
+ A deliberately different shape from everything above: there's no automatic instrumentation and no
241
+ ForgeOps-built agent. Run your own short-lived script on a cron entry or a systemd timer, reading
242
+ your own host's own stats; `hostname` defaults to the box the script is actually running on.
243
+ Buffered and flushed on exit, so a handful of capture calls in one short-lived process still cost
244
+ one network request, not several.
245
+
246
+ ```ruby
247
+ # A cron entry or systemd timer runs this periodically, not your web app itself.
248
+ require "forge_ops_tracker"
249
+ ForgeOpsTracker.configure { |config| config.dsn = ENV["FORGE_OPS_DSN"] }
250
+
251
+ load_average = File.read("/proc/loadavg").split.first.to_f
252
+ ForgeOpsTracker.capture_infrastructure_metric("load_average", value: load_average)
253
+
254
+ meminfo = File.read("/proc/meminfo").lines.to_h { |line| line.split(":").map(&:strip) }
255
+ total_kb, available_kb = meminfo["MemTotal"].to_i, meminfo["MemAvailable"].to_i
256
+ ForgeOpsTracker.capture_infrastructure_metric("memory_used_percent",
257
+ value: 100.0 * (total_kb - available_kb) / total_kb)
258
+
259
+ disk_used_percent = `df --output=pcent / | tail -1`.strip.delete("%").to_f
260
+ ForgeOpsTracker.capture_infrastructure_metric("disk_used_percent", value: disk_used_percent)
261
+ ```
262
+
263
+ The example above is Linux-specific (`/proc/loadavg`, `/proc/meminfo`); on another OS, read that
264
+ platform's own equivalents instead. Requires a ForgeOps plan that includes infrastructure
265
+ monitoring; on a plan that doesn't, the periodic flushes are simply rejected server-side and
266
+ dropped, exactly like any other delivery failure.
@@ -23,6 +23,26 @@ module ForgeOpsTracker
23
23
  post(configuration.session_checkins_uri, payload)
24
24
  end
25
25
 
26
+ # Same delivery contract again; see Configuration#performance_samples_uri. payload here is a
27
+ # batch (one entry per distinct transaction a flush interval saw), not a single aggregate the
28
+ # way deliver_session_checkin's own payload is, so this posts { samples: [...] } rather than
29
+ # the array bare, matching what Api::V1::PerformanceSamplesController expects.
30
+ def deliver_performance_samples(samples)
31
+ post(configuration.performance_samples_uri, { samples: samples })
32
+ end
33
+
34
+ # Same delivery contract again; see Configuration#custom_metrics_uri. payload is a batch of
35
+ # individual capture_metric calls (see MetricBuffer), matching what
36
+ # Api::V1::CustomMetricsController expects.
37
+ def deliver_metrics(entries)
38
+ post(configuration.custom_metrics_uri, { metrics: entries })
39
+ end
40
+
41
+ # Same again; see Configuration#infrastructure_metrics_uri and InfrastructureMetricBuffer.
42
+ def deliver_infrastructure_metrics(entries)
43
+ post(configuration.infrastructure_metrics_uri, { metrics: entries })
44
+ end
45
+
26
46
  private
27
47
  attr_reader :configuration
28
48
 
@@ -9,6 +9,10 @@ module ForgeOpsTracker
9
9
  attr_accessor :enabled_environments, :queue_size, :open_timeout, :read_timeout, :scrub_pii
10
10
  attr_accessor :capture_source_context
11
11
  attr_accessor :track_sessions, :session_flush_interval
12
+ attr_accessor :track_performance, :performance_flush_interval
13
+ attr_accessor :track_current_user
14
+ attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
15
+ attr_accessor :gauge_poll_interval
12
16
 
13
17
  def initialize
14
18
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -43,6 +47,30 @@ module ForgeOpsTracker
43
47
  # ForgeOpsTracker::Middleware::SessionTracking for what this actually wraps.
44
48
  @track_sessions = true
45
49
  @session_flush_interval = 60
50
+ # Auto-instruments every request the same "on unless you turn it off" default as
51
+ # track_sessions above; see Railtie's own comment for exactly what this subscribes to.
52
+ @track_performance = true
53
+ @performance_flush_interval = 60
54
+ # Auto-detects the current user via Warden (env["warden"].user; Devise mounts Warden
55
+ # automatically, so this covers Devise apps too, but works for any Warden-based auth, not
56
+ # just Devise specifically) the same "on unless you turn it off" default every other
57
+ # automatic instrumentation flag above already has. See
58
+ # ForgeOpsTracker::Middleware::UserContext for how, and ForgeOpsTracker.set_user for the
59
+ # manual override/fallback when there's no Warden at all or its guess isn't right.
60
+ @track_current_user = true
61
+ # No track_metrics/track_infrastructure boolean the way track_sessions/track_performance
62
+ # each have one: those gate automatic instrumentation that's on unless you turn it off;
63
+ # capture_metric/capture_infrastructure_metric are explicit calls the customer's own code
64
+ # chooses to make at all, so there's no "automatic behavior" for a flag to disable. Only
65
+ # the flush interval needs a knob.
66
+ @metric_flush_interval = 60
67
+ @infrastructure_metric_flush_interval = 60
68
+ # How often the Sidekiq/Solid Queue/Puma stats reporters poll and report their own gauge
69
+ # readings (queue depth, worker counts, Puma backlog/threads/pool capacity). Gated by
70
+ # track_performance, same as controller/job/query/http timing: one flag for "automatically
71
+ # instrument this app's own operational data," reused rather than adding a separate boolean
72
+ # per new automatic instrumentation source this gem grows.
73
+ @gauge_poll_interval = 60
46
74
  end
47
75
 
48
76
  def api_key
@@ -72,6 +100,34 @@ module ForgeOpsTracker
72
100
  uri
73
101
  end
74
102
 
103
+ # Same substitution as session_checkins_uri above, its own sibling path under the same DSN.
104
+ def performance_samples_uri
105
+ uri = ingestion_uri
106
+ return nil unless uri
107
+
108
+ uri = uri.dup
109
+ uri.path = uri.path.sub(%r{/events\z}, "/performance_samples")
110
+ uri
111
+ end
112
+
113
+ def custom_metrics_uri
114
+ uri = ingestion_uri
115
+ return nil unless uri
116
+
117
+ uri = uri.dup
118
+ uri.path = uri.path.sub(%r{/events\z}, "/custom_metrics")
119
+ uri
120
+ end
121
+
122
+ def infrastructure_metrics_uri
123
+ uri = ingestion_uri
124
+ return nil unless uri
125
+
126
+ uri = uri.dup
127
+ uri.path = uri.path.sub(%r{/events\z}, "/infrastructure_metrics")
128
+ uri
129
+ end
130
+
75
131
  def enabled?
76
132
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
77
133
  end
@@ -14,7 +14,12 @@ module ForgeOpsTracker
14
14
  def report(error, handled: true, severity: nil, context: {}, source: nil)
15
15
  return unless configuration.enabled?
16
16
 
17
- delivery_queue.push(event_builder.build(error, context: context))
17
+ # Read here, not inside EventBuilder: Rails.error.subscribe's own #report interface never
18
+ # hands this class the Rack env, so ForgeOpsTracker::Middleware::UserContext (Warden
19
+ # auto-detection) and ForgeOpsTracker.set_user (the manual override/fallback) both
20
+ # communicate with this exact same thread-local instead, the only channel available here.
21
+ user = Thread.current[:forge_ops_tracker_current_user]
22
+ delivery_queue.push(event_builder.build(error, context: context, user: user))
18
23
  nil
19
24
  rescue StandardError => e
20
25
  configuration.logger&.debug { "[ForgeOpsTracker] report failed: #{e.class}: #{e.message}" }
@@ -27,7 +27,7 @@ module ForgeOpsTracker
27
27
  @configuration = configuration
28
28
  end
29
29
 
30
- def build(error, context: {})
30
+ def build(error, context: {}, user: nil)
31
31
  payload = {
32
32
  exception_class: error.class.name,
33
33
  message: error.message.to_s,
@@ -40,16 +40,18 @@ module ForgeOpsTracker
40
40
  tags: {},
41
41
  sdk_name: SDK_NAME
42
42
  }
43
+ payload[:user] = user if user && !user.empty?
43
44
  scrub(payload)
44
45
  end
45
46
 
46
47
  private
47
48
  attr_reader :configuration
48
49
 
49
- # exception_class/occurred_at/environment/release/server_name/sdk_name are
50
- # left alone; structured fields this gem or the host app sets
51
- # deliberately, not free text an exception or its context could
52
- # accidentally spill sensitive data into.
50
+ # exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone;
51
+ # structured fields this gem or the host app sets deliberately, not free text an exception
52
+ # or its context could accidentally spill sensitive data into. user specifically is a
53
+ # deliberate exemption, not an oversight: the server's own PiiScrubber-equivalent would
54
+ # otherwise redact the exact email address this field exists to carry.
53
55
  def scrub(payload)
54
56
  return payload unless configuration.scrub_pii
55
57
 
@@ -0,0 +1,69 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Collects individual ForgeOpsTracker.capture_infrastructure_metric readings in-process and
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-
7
+ # readable classes despite their own similar shape too, and these two follow that precedent).
8
+ # Its main real caller is a short-lived cron/script process that exits right after a handful of
9
+ # capture calls, so the at_exit flush below is what actually matters in practice; the periodic
10
+ # background-thread timer still runs for a long-lived process that chooses to call this
11
+ # continuously instead, but rarely gets the chance to fire in the cron case.
12
+ class InfrastructureMetricBuffer
13
+ def initialize(configuration, client: Client.new(configuration))
14
+ @configuration = configuration
15
+ @client = client
16
+ @mutex = Mutex.new
17
+ @entries = []
18
+ @thread = nil
19
+
20
+ at_exit { flush }
21
+ end
22
+
23
+ def record(metric_name:, value:, hostname:)
24
+ ensure_worker_started
25
+
26
+ @mutex.synchronize do
27
+ @entries << { metric_name: metric_name, value: value, hostname: hostname, recorded_at: Time.now.utc.iso8601 }
28
+ end
29
+ end
30
+
31
+ # Same reset-on-success/keep-on-failure contract as MetricBuffer#flush; see that class's own
32
+ # comment for the accepted race this shares with it.
33
+ def flush
34
+ snapshot = nil
35
+
36
+ @mutex.synchronize do
37
+ return if @entries.empty?
38
+ snapshot = @entries
39
+ end
40
+
41
+ return unless client.deliver_infrastructure_metrics(snapshot)
42
+
43
+ @mutex.synchronize { @entries = [] }
44
+ end
45
+
46
+ private
47
+ attr_reader :configuration, :client
48
+
49
+ def ensure_worker_started
50
+ return if @thread&.alive?
51
+
52
+ @mutex.synchronize do
53
+ return if @thread&.alive?
54
+
55
+ @thread = Thread.new { run }
56
+ @thread.abort_on_exception = false
57
+ end
58
+ end
59
+
60
+ def run
61
+ loop do
62
+ sleep configuration.infrastructure_metric_flush_interval
63
+ flush
64
+ rescue StandardError => e
65
+ configuration.logger&.debug { "[ForgeOpsTracker] infrastructure metric flush thread error: #{e.class}: #{e.message}" }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,32 @@
1
+ module ForgeOpsTracker
2
+ module Integrations
3
+ module NetHTTP
4
+ # Times every outbound Net::HTTP call, applied once via Net::HTTP.prepend(Timing) from
5
+ # Railtie. Ruby's standard library has no ActiveSupport::Notifications instrumentation of
6
+ # its own for outbound HTTP the way it does for ActiveRecord/ActionController, so wrapping
7
+ # #request directly is the only hook available; most other HTTP client libraries in the Ruby
8
+ # ecosystem (Faraday's own net_http adapter, HTTParty, RestClient) ultimately call through
9
+ # Net::HTTP too, so this covers those as a side effect without needing a wrapper per library.
10
+ #
11
+ # transaction_name is "<HTTP method> <host>", not the full URL: a request's own path or
12
+ # query string could carry an id or a token, the same low-cardinality/no-secrets-in-a-label
13
+ # reasoning every other transaction_name in this system already follows.
14
+ module Timing
15
+ # The guard (return super unless ...) skips timing, but the ensure block below still runs
16
+ # on every exit path regardless, same as any ensure; start staying nil is what actually
17
+ # skips recording there, not a second guard duplicating this one.
18
+ def request(req, body = nil, &block)
19
+ return super unless ForgeOpsTracker.configuration.track_performance && ForgeOpsTracker.configuration.enabled?
20
+
21
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
22
+ super
23
+ ensure
24
+ if start
25
+ duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000.0
26
+ Railtie.performance_flusher.record(transaction_name: "#{req.method} #{address}", duration_ms: duration_ms, kind: "http")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ module ForgeOpsTracker
2
+ module Integrations
3
+ module Puma
4
+ # Reports Puma's own backlog/running-threads/pool-capacity as gauge-style infrastructure
5
+ # metrics on a periodic timer (see PeriodicPoller). Only ever loaded when Puma is already
6
+ # present (checked with defined? in Railtie).
7
+ #
8
+ # Puma.stats_hash's own shape genuinely differs by deployment mode, confirmed directly
9
+ # against Puma 6.6.1's real source rather than assumed: single-mode (Puma::Single, no
10
+ # `workers` config) has backlog/running/pool_capacity at the top level (Server#stats merged
11
+ # with ThreadPool#stats); clustered mode (Puma::Cluster) has none of those at the top level
12
+ # at all, only a worker_status array, one entry per forked worker, each carrying its own
13
+ # last_status hash with the identical three keys (parsed from that worker's own periodic
14
+ # IPC ping, see Puma::Cluster::WorkerHandle's own STATUS_PATTERN), summed across workers
15
+ # here for one whole-process reading, rather than reported per worker, since ForgeOps'
16
+ # infrastructure dataset already aggregates by hostname, not by worker index. Puma.stats_object
17
+ # is set unconditionally by Puma::Launcher on every normal boot (not just when a control/
18
+ # stats app is configured), so Puma.stats_hash is populated by the time this ever runs; still
19
+ # guarded against nil/an unrecognized shape, both because that wiring could change in a
20
+ # future Puma version and because this must never be the thing that crashes a poll tick.
21
+ class StatsReporter
22
+ def self.start(configuration: ForgeOpsTracker.configuration)
23
+ return unless configuration.track_performance && configuration.enabled?
24
+
25
+ PeriodicPoller.start(configuration.gauge_poll_interval, logger: configuration.logger) { new.report }
26
+ end
27
+
28
+ def report
29
+ stats = ::Puma.stats_hash
30
+ return unless stats
31
+
32
+ if stats[:backlog] && stats[:running]
33
+ report_metrics(stats[:backlog], stats[:running], stats[:pool_capacity])
34
+ elsif stats[:worker_status]
35
+ statuses = stats[:worker_status].filter_map { |worker| worker[:last_status] }.reject(&:empty?)
36
+ return if statuses.empty?
37
+
38
+ report_metrics(
39
+ statuses.sum { |s| s[:backlog] || 0 },
40
+ statuses.sum { |s| s[:running] || 0 },
41
+ statuses.sum { |s| s[:pool_capacity] || 0 }
42
+ )
43
+ end
44
+ end
45
+
46
+ private
47
+ def report_metrics(backlog, running, pool_capacity)
48
+ ForgeOpsTracker.capture_infrastructure_metric("puma.backlog", value: backlog)
49
+ ForgeOpsTracker.capture_infrastructure_metric("puma.running_threads", value: running)
50
+ ForgeOpsTracker.capture_infrastructure_metric("puma.pool_capacity", value: pool_capacity)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,69 @@
1
+ # Sidekiq::Stats/Sidekiq::Queue (used by StatsReporter below) live in sidekiq/api, not
2
+ # autoloaded by a bare "sidekiq" require; a real Sidekiq server process almost always already has
3
+ # this loaded some other way, but requiring it explicitly here doesn't depend on that happening to
4
+ # be true.
5
+ require "sidekiq/api"
6
+
7
+ module ForgeOpsTracker
8
+ module Integrations
9
+ module Sidekiq
10
+ # Times a raw (non-ActiveJob) Sidekiq::Worker's own #perform. Registered via
11
+ # Sidekiq.configure_server { |c| c.server_middleware { |chain| chain.add ... } } (see
12
+ # Railtie); only ever loaded when Sidekiq is already present (checked with defined? there),
13
+ # matching every other optional integration in this gem.
14
+ #
15
+ # Skips (yields, but doesn't record) any job whose worker is ActiveJob's own Sidekiq wrapper:
16
+ # see PerformanceInstrumentation.sidekiq_worker_is_active_job_wrapper?'s own comment for why
17
+ # that job is already counted by the perform.active_job subscription instead, and would be
18
+ # double-counted here otherwise.
19
+ class Middleware
20
+ include ::Sidekiq::ServerMiddleware
21
+
22
+ def initialize(configuration: ForgeOpsTracker.configuration, flusher: Railtie.performance_flusher)
23
+ @configuration = configuration
24
+ @flusher = flusher
25
+ end
26
+
27
+ def call(job_instance, _msg, _queue)
28
+ skip = !@configuration.track_performance || !@configuration.enabled? ||
29
+ PerformanceInstrumentation.sidekiq_worker_is_active_job_wrapper?(job_instance)
30
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) unless skip
31
+
32
+ yield
33
+ ensure
34
+ unless skip
35
+ duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000.0
36
+ @flusher.record(transaction_name: job_instance.class.name, duration_ms: duration_ms, kind: "job")
37
+ end
38
+ end
39
+ end
40
+
41
+ # Reports Sidekiq's own aggregate stats (Sidekiq::Stats, Sidekiq::Queue) as gauge-style
42
+ # infrastructure metrics on a periodic timer (see PeriodicPoller), via the same public
43
+ # ForgeOpsTracker.capture_infrastructure_metric every customer's own manual infrastructure-
44
+ # monitoring script already uses; no new ingestion endpoint or dashboard dataset needed.
45
+ class StatsReporter
46
+ def self.start(configuration: ForgeOpsTracker.configuration)
47
+ return unless configuration.track_performance && configuration.enabled?
48
+
49
+ PeriodicPoller.start(configuration.gauge_poll_interval, logger: configuration.logger) { new.report }
50
+ end
51
+
52
+ def report
53
+ stats = ::Sidekiq::Stats.new
54
+
55
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.processed", value: stats.processed)
56
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.failed", value: stats.failed)
57
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.scheduled_size", value: stats.scheduled_size)
58
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.retry_size", value: stats.retry_size)
59
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.dead_size", value: stats.dead_size)
60
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.workers_size", value: stats.workers_size)
61
+
62
+ ::Sidekiq::Queue.all.each do |queue|
63
+ ForgeOpsTracker.capture_infrastructure_metric("sidekiq.queue_depth.#{queue.name}", value: queue.size)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,37 @@
1
+ module ForgeOpsTracker
2
+ module Integrations
3
+ module SolidQueue
4
+ # Reports Solid Queue's own gauge-style state as infrastructure metrics on a periodic timer
5
+ # (see PeriodicPoller and Sidekiq::StatsReporter's own comment, the same shape). Only ever
6
+ # loaded when Solid Queue is already present (checked with defined? in Railtie).
7
+ #
8
+ # No job *duration*/*count* reporting here: Solid Queue jobs are ActiveJob jobs, already
9
+ # covered end to end by the perform.active_job subscription in Railtie regardless of which
10
+ # backend actually runs them, so there's nothing left for this integration to time.
11
+ #
12
+ # kind is Solid Queue's own attribute on a registered process (a plain String, "Worker" or
13
+ # "Dispatcher": confirmed directly against solid_queue's own Processes::Base#kind, which is
14
+ # literally self.class.name.demodulize, not assumed from its docs), so this reads it exactly
15
+ # as Solid Queue itself defines it rather than re-deriving it a different way.
16
+ class StatsReporter
17
+ def self.start(configuration: ForgeOpsTracker.configuration)
18
+ return unless configuration.track_performance && configuration.enabled?
19
+
20
+ PeriodicPoller.start(configuration.gauge_poll_interval, logger: configuration.logger) { new.report }
21
+ end
22
+
23
+ def report
24
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.active_workers", value: ::SolidQueue::Process.where(kind: "Worker").count)
25
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.active_dispatchers", value: ::SolidQueue::Process.where(kind: "Dispatcher").count)
26
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.failed", value: ::SolidQueue::FailedExecution.count)
27
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.scheduled", value: ::SolidQueue::ScheduledExecution.count)
28
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.blocked", value: ::SolidQueue::BlockedExecution.count)
29
+
30
+ ::SolidQueue::Queue.all.each do |queue|
31
+ ForgeOpsTracker.capture_infrastructure_metric("solid_queue.queue_depth.#{queue.name}", value: queue.size)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,80 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Collects individual ForgeOpsTracker.capture_metric calls in-process and periodically flushes
5
+ # them as one batch, rather than one network call per capture -- the same "lazily start a
6
+ # background thread on first use" pattern SessionFlusher/PerformanceFlusher already use, so each
7
+ # forked Puma/Passenger worker gets its own fresh thread instead of inheriting a dead one across
8
+ # fork. Unlike those two, this collects a *list* of individually-meaningful entries rather than
9
+ # summing them into buckets: a customer's own signup or payment is exactly the kind of thing
10
+ # they'll want a genuinely accurate count/sum of later, not something to pre-aggregate away
11
+ # client-side, so CustomMetricsController stores one row per entry as-is.
12
+ class MetricBuffer
13
+ def initialize(configuration, client: Client.new(configuration))
14
+ @configuration = configuration
15
+ @client = client
16
+ @mutex = Mutex.new
17
+ @entries = []
18
+ @thread = nil
19
+
20
+ # Flushes whatever's already buffered on a normal process exit, so the last partial window
21
+ # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
22
+ at_exit { flush }
23
+ end
24
+
25
+ def record(metric_name:, value:)
26
+ ensure_worker_started
27
+
28
+ @mutex.synchronize do
29
+ @entries << {
30
+ metric_name: metric_name, value: value,
31
+ environment: configuration.environment.to_s, release: configuration.release,
32
+ recorded_at: Time.now.utc.iso8601
33
+ }
34
+ end
35
+ end
36
+
37
+ # Snapshots and resets the buffered entries, then delivers them as one batch. A failed
38
+ # delivery keeps every entry where it is rather than resetting, so the next flush's batch
39
+ # just grows instead of losing what was already buffered; same reasoning SessionFlusher#flush
40
+ # already documents, including the same small, accepted race (an entry recorded during the
41
+ # in-flight HTTP request gets folded into this snapshot's own reset rather than kept for the
42
+ # next one) that class already lives with, for the same reason: simple and consistent beats a
43
+ # cleverer dedup that would only trade one rare edge case for a subtler one.
44
+ def flush
45
+ snapshot = nil
46
+
47
+ @mutex.synchronize do
48
+ return if @entries.empty?
49
+ snapshot = @entries
50
+ end
51
+
52
+ return unless client.deliver_metrics(snapshot)
53
+
54
+ @mutex.synchronize { @entries = [] }
55
+ end
56
+
57
+ private
58
+ attr_reader :configuration, :client
59
+
60
+ def ensure_worker_started
61
+ return if @thread&.alive?
62
+
63
+ @mutex.synchronize do
64
+ return if @thread&.alive?
65
+
66
+ @thread = Thread.new { run }
67
+ @thread.abort_on_exception = false
68
+ end
69
+ end
70
+
71
+ def run
72
+ loop do
73
+ sleep configuration.metric_flush_interval
74
+ flush
75
+ rescue StandardError => e
76
+ configuration.logger&.debug { "[ForgeOpsTracker] metric flush thread error: #{e.class}: #{e.message}" }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,52 @@
1
+ module ForgeOpsTracker
2
+ module Middleware
3
+ # Wraps every request: if Warden is mounted (env["warden"] present, whether or not the app
4
+ # uses Devise specifically, since Devise just mounts Warden automatically, but any Warden-
5
+ # based auth setup works here, confirmed directly against the installed warden gem's own
6
+ # Warden::Manager/Warden::Proxy source) and it has a signed-in user, stashes a serialized
7
+ # version of that user in a thread-local for the duration of this request so
8
+ # ErrorSubscriber#report (which never receives the Rack env at all: Rails.error.subscribe's
9
+ # own interface hands it error/context/severity, nothing request-shaped) can read it back and
10
+ # attach it to whatever gets reported. A sibling to SessionTracking, not folded into it:
11
+ # distinct concern, same "own file, own class" shape every middleware in this gem already
12
+ # takes.
13
+ #
14
+ # The ensure-clear is load-bearing, not optional: Puma reuses threads across requests, so
15
+ # leaving this set would leak one request's user into a later, unrelated request handled on
16
+ # the same thread.
17
+ class UserContext
18
+ def initialize(app, configuration: ForgeOpsTracker.configuration)
19
+ @app = app
20
+ @configuration = configuration
21
+ end
22
+
23
+ def call(env)
24
+ return app.call(env) unless configuration.track_current_user && configuration.enabled?
25
+
26
+ if env.key?("warden") && (user = env["warden"].user)
27
+ serialized = serialize(user)
28
+ Thread.current[:forge_ops_tracker_current_user] = serialized unless serialized.empty?
29
+ end
30
+
31
+ app.call(env)
32
+ ensure
33
+ Thread.current[:forge_ops_tracker_current_user] = nil
34
+ end
35
+
36
+ private
37
+ attr_reader :app, :configuration
38
+
39
+ # Duck-types rather than assuming a Devise-shaped model: id if the object responds to it,
40
+ # email if it responds to it, username else name if either responds to it. A custom
41
+ # Warden setup with a differently-shaped user object just gets whatever subset applies
42
+ # here; ForgeOpsTracker.set_user covers anything this can't infer.
43
+ def serialize(user)
44
+ {
45
+ id: (user.id if user.respond_to?(:id)),
46
+ email: (user.email if user.respond_to?(:email)),
47
+ username: (user.username if user.respond_to?(:username)) || (user.name if user.respond_to?(:name))
48
+ }.compact
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,101 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Times every request/job/query/outbound call in-process, bucketed by [transaction_name, kind]
5
+ # (see Railtie's several ActiveSupport::Notifications subscriptions, each a different kind), and
6
+ # periodically flushes each distinct bucket as one small aggregate report, rather than one
7
+ # network call per event. One shared flusher for every kind, not one per kind: the aggregation
8
+ # shape (count + duration sum + max over a period) is identical regardless of what's actually
9
+ # being timed. Same "lazily start a background thread on first use, not at load time" pattern
10
+ # SessionFlusher already uses, so each forked Puma/Passenger worker gets its own fresh thread
11
+ # instead of inheriting a dead one across fork.
12
+ class PerformanceFlusher
13
+ def initialize(configuration, client: Client.new(configuration))
14
+ @configuration = configuration
15
+ @client = client
16
+ @mutex = Mutex.new
17
+ @buckets = {}
18
+ @period_started_at = Time.now.utc
19
+ @thread = nil
20
+
21
+ # Flushes whatever's already tallied on a normal process exit, so the last partial window
22
+ # (anything shorter than a full performance_flush_interval) isn't silently dropped; same
23
+ # reasoning SessionFlusher's own at_exit hook documents.
24
+ at_exit { flush }
25
+ end
26
+
27
+ # kind ("controller"/"job"/"query"/"http", see Railtie's own subscriptions) is part of the
28
+ # bucket key alongside transaction_name, not folded into transaction_name itself: a job and a
29
+ # controller action (or, less likely but still real, a query and a job) can share a name
30
+ # without colliding into the same bucket.
31
+ def record(transaction_name:, duration_ms:, kind: "controller")
32
+ ensure_worker_started
33
+
34
+ @mutex.synchronize do
35
+ bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0 })
36
+ bucket[:count] += 1
37
+ bucket[:duration_sum_ms] += duration_ms
38
+ bucket[:max_duration_ms] = duration_ms if duration_ms > bucket[:max_duration_ms]
39
+ end
40
+ end
41
+
42
+ # Snapshots and resets the in-process buckets, then delivers them as one batch. A failed
43
+ # delivery keeps every bucket where it is rather than resetting, so the next flush's window
44
+ # just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
45
+ # already documents, and the same reason PerformanceSamplesController accepts a batch rather
46
+ # than a single row: a failed flush shouldn't have to re-deliver by transaction one at a time.
47
+ def flush
48
+ snapshot = nil
49
+ period_started_at = nil
50
+
51
+ @mutex.synchronize do
52
+ return if @buckets.empty?
53
+
54
+ period_started_at = @period_started_at
55
+ snapshot = @buckets.map do |(transaction_name, kind), bucket|
56
+ {
57
+ transaction_name: transaction_name,
58
+ kind: kind,
59
+ environment: configuration.environment.to_s,
60
+ release: configuration.release,
61
+ period_started_at: period_started_at.iso8601,
62
+ period_ended_at: Time.now.utc.iso8601,
63
+ request_count: bucket[:count],
64
+ duration_sum_ms: bucket[:duration_sum_ms],
65
+ max_duration_ms: bucket[:max_duration_ms]
66
+ }
67
+ end
68
+ end
69
+
70
+ return unless snapshot && client.deliver_performance_samples(snapshot)
71
+
72
+ @mutex.synchronize do
73
+ @buckets = {}
74
+ @period_started_at = Time.now.utc
75
+ end
76
+ end
77
+
78
+ private
79
+ attr_reader :configuration, :client
80
+
81
+ def ensure_worker_started
82
+ return if @thread&.alive?
83
+
84
+ @mutex.synchronize do
85
+ return if @thread&.alive?
86
+
87
+ @thread = Thread.new { run }
88
+ @thread.abort_on_exception = false
89
+ end
90
+ end
91
+
92
+ def run
93
+ loop do
94
+ sleep configuration.performance_flush_interval
95
+ flush
96
+ rescue StandardError => e
97
+ configuration.logger&.debug { "[ForgeOpsTracker] performance flush thread error: #{e.class}: #{e.message}" }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,60 @@
1
+ module ForgeOpsTracker
2
+ # The actual "what do we call this, and should we even record it" decisions Railtie's
3
+ # ActiveSupport::Notifications subscriptions make, pulled out into plain module-function methods
4
+ # rather than left inline in each subscribe block. Railtie itself only ever loads when
5
+ # Rails::Railtie is already defined (see lib/forge_ops_tracker.rb's own final line), so nothing
6
+ # in that file can be exercised by this gem's own test suite without adding Rails itself as a
7
+ # development dependency just to fire a notification event by hand; every method here takes a
8
+ # plain payload Hash instead of a real ActiveSupport::Notifications::Event, so the decision logic
9
+ # itself (the SCHEMA/cached skip, the ActiveJob-wrapper dedup) is fully unit-testable with no
10
+ # Rails/Sidekiq/ActiveJob dependency at all. Railtie's own subscribe blocks stay thin, calling
11
+ # straight into these.
12
+ module PerformanceInstrumentation
13
+ module_function
14
+
15
+ def controller_transaction_name(payload)
16
+ "#{payload[:controller]}##{payload[:action]}"
17
+ end
18
+
19
+ # nil means "don't record this one": Rails' own sql.active_record fires for two kinds of
20
+ # noise that would drown out real query timing otherwise. "SCHEMA" is Rails' own internal
21
+ # introspection (column lookups, etc.), never something a customer's own code triggered;
22
+ # payload[:cached] is a genuinely instant hash lookup off Rails' own query cache, not a real
23
+ # round trip to the database, so timing it would understate every cached query's own real
24
+ # cost the next time it isn't cached.
25
+ #
26
+ # payload[:name] (Rails' own auto-generated label, e.g. "User Load", "Order Create"), not
27
+ # payload[:sql] (the literal query text): a customer's own query text could carry a literal
28
+ # value in some edge cases despite ActiveRecord's own parameterization, and even where it
29
+ # doesn't, raw SQL text is far higher cardinality than this system's transaction_name is
30
+ # designed for everywhere else. Falls back to the literal string "SQL" for the rare query
31
+ # with no name at all, rather than skipping it outright.
32
+ def query_transaction_name(payload)
33
+ return nil if payload[:name] == "SCHEMA" || payload[:cached]
34
+
35
+ # A plain nil-or-empty check, not ActiveSupport's #presence: this module is unit-tested
36
+ # without Rails/ActiveSupport loaded at all (see this file's own top comment), so it can't
37
+ # rely on a method ActiveSupport adds to String/NilClass.
38
+ name = payload[:name]
39
+ name.nil? || name.empty? ? "SQL" : name
40
+ end
41
+
42
+ def job_transaction_name(payload)
43
+ payload[:job].class.name
44
+ end
45
+
46
+ # True for a job dispatched through ActiveJob but actually run on Sidekiq: Sidekiq's own
47
+ # server middleware chain wraps every job it runs, ActiveJob-dispatched or not, so without
48
+ # this check a job on the Sidekiq/ActiveJob combination would be recorded twice: once here
49
+ # (kind: "job", via the raw Sidekiq middleware) and once already by the perform.active_job
50
+ # subscription that already covers every ActiveJob job regardless of which queue backend
51
+ # actually runs it (Solid Queue, Sidekiq, or anything else). The class name itself is
52
+ # ActiveJob's own, not this gem's: confirmed directly against how ActiveJob wraps a job for
53
+ # Sidekiq, not assumed from either project's docs alone.
54
+ def sidekiq_worker_is_active_job_wrapper?(worker)
55
+ return false unless defined?(::ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper)
56
+
57
+ worker.is_a?(::ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,50 @@
1
+ module ForgeOpsTracker
2
+ # A small shared background-thread loop for the Sidekiq/Solid Queue/Puma stats reporters (see
3
+ # integrations/): unlike PerformanceFlusher and the two metric buffers, which genuinely differ
4
+ # in what they buffer and are kept as separate sibling classes on purpose (see
5
+ # InfrastructureMetricBuffer's own comment), these three reporters only differ in *what they
6
+ # poll*, not *how they poll it*: lazily start a thread on first use (so a forked worker gets
7
+ # its own live thread regardless of when it forked relative to load, same reasoning
8
+ # PerformanceFlusher's own lazy start documents), sleep the interval, call the block, rescue and
9
+ # log so one bad tick never kills the loop, forever. Genuinely mechanical enough across all
10
+ # three to share, unlike those.
11
+ #
12
+ # No at_exit flush: unlike the flushers/buffers, which hold real accumulated data that would
13
+ # otherwise be lost, a gauge reading skipped on process exit is simply the same as one skipped
14
+ # because the interval hadn't ticked yet: nothing to lose.
15
+ class PeriodicPoller
16
+ def self.start(interval, logger: nil, &block)
17
+ new(interval, logger: logger, &block).tap(&:start)
18
+ end
19
+
20
+ def initialize(interval, logger: nil, &block)
21
+ @interval = interval
22
+ @logger = logger
23
+ @block = block
24
+ @mutex = Mutex.new
25
+ @thread = nil
26
+ end
27
+
28
+ def start
29
+ return if @thread&.alive?
30
+
31
+ @mutex.synchronize do
32
+ return if @thread&.alive?
33
+
34
+ @thread = Thread.new { run }
35
+ @thread.abort_on_exception = false
36
+ end
37
+ self
38
+ end
39
+
40
+ private
41
+ def run
42
+ loop do
43
+ sleep @interval
44
+ @block.call
45
+ rescue StandardError => e
46
+ @logger&.debug { "[ForgeOpsTracker] periodic poller error: #{e.class}: #{e.message}" }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,20 @@ require "rails/railtie"
2
2
 
3
3
  module ForgeOpsTracker
4
4
  class Railtie < ::Rails::Railtie
5
+ # Shared across every performance-timing subscription/integration below (controller, query,
6
+ # job, raw Sidekiq, outbound Net::HTTP): one flusher, one background thread, one set of
7
+ # buckets, since PerformanceFlusher already buckets on [transaction_name, kind] and safely
8
+ # handles concurrent calls from several sources at once. Exposed as a class method, not a
9
+ # local variable inside one initializer, specifically so the Sidekiq/Net::HTTP integrations
10
+ # (each guarded by their own separate initializer, since each only loads if its own optional
11
+ # library is present) can record into the exact same instance rather than each starting a
12
+ # redundant flusher/thread of their own.
13
+ class << self
14
+ def performance_flusher
15
+ @performance_flusher ||= ForgeOpsTracker::PerformanceFlusher.new(ForgeOpsTracker.configuration)
16
+ end
17
+ end
18
+
5
19
  initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
6
20
  configuration = ForgeOpsTracker.configuration
7
21
  configuration.app_root ||= app.root.to_s
@@ -14,5 +28,110 @@ module ForgeOpsTracker
14
28
  initializer "forge_ops_tracker.track_sessions" do |app|
15
29
  app.middleware.use ForgeOpsTracker::Middleware::SessionTracking, configuration: ForgeOpsTracker.configuration
16
30
  end
31
+
32
+ # Warden::Manager (mounted automatically by Devise, or directly by any other Warden-based
33
+ # auth setup) sets env["warden"] itself; this only ever reads it, so it works regardless of
34
+ # exactly where in the middleware stack this ends up relative to Warden::Manager, as long as
35
+ # that key exists in env by the time this middleware's own #call runs, which is true for any
36
+ # Warden-based auth setup: it mounts its own middleware once, for the whole app, long before
37
+ # any individual request begins.
38
+ initializer "forge_ops_tracker.track_current_user" do |app|
39
+ app.middleware.use ForgeOpsTracker::Middleware::UserContext, configuration: ForgeOpsTracker.configuration
40
+ end
41
+
42
+ # Rails already fires "process_action.action_controller" after every request with the
43
+ # controller/action and how long it took (event.duration, in ms) built in, so unlike session
44
+ # tracking above (which needed a Rack middleware because there's no single existing hook for
45
+ # "a request finished"), this needs no middleware of its own at all, just a subscriber.
46
+ # Guarded the same way the error subscriber and session-tracking middleware both are: on by
47
+ # default, off entirely if track_performance is turned off or the gem itself isn't enabled
48
+ # (no DSN configured, or the current environment isn't in enabled_environments).
49
+ #
50
+ # sql.active_record/perform.active_job are subscribed here too, not in their own separate
51
+ # initializers: all three (plus Sidekiq/Net::HTTP below) are genuinely the same concern
52
+ # (automatic performance timing), sharing the one performance_flusher above; see
53
+ # PerformanceInstrumentation for the actual transaction_name/skip decisions each one makes,
54
+ # kept out of these blocks specifically so that decision logic is unit-testable without a real
55
+ # Rails/ActiveJob boot (this gem's own test suite doesn't require Rails at all; see that
56
+ # file's own comment).
57
+ initializer "forge_ops_tracker.track_performance" do
58
+ configuration = ForgeOpsTracker.configuration
59
+ flusher = Railtie.performance_flusher
60
+
61
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
62
+ next unless configuration.track_performance && configuration.enabled?
63
+
64
+ event = ActiveSupport::Notifications::Event.new(*args)
65
+ transaction_name = PerformanceInstrumentation.controller_transaction_name(event.payload)
66
+ flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "controller")
67
+ end
68
+
69
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
70
+ next unless configuration.track_performance && configuration.enabled?
71
+
72
+ event = ActiveSupport::Notifications::Event.new(*args)
73
+ transaction_name = PerformanceInstrumentation.query_transaction_name(event.payload)
74
+ next if transaction_name.nil? # SCHEMA/cached, see PerformanceInstrumentation's own comment
75
+
76
+ flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "query")
77
+ end
78
+
79
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
80
+ next unless configuration.track_performance && configuration.enabled?
81
+
82
+ event = ActiveSupport::Notifications::Event.new(*args)
83
+ transaction_name = PerformanceInstrumentation.job_transaction_name(event.payload)
84
+ flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "job")
85
+ end
86
+ end
87
+
88
+ # Raw (non-ActiveJob) Sidekiq::Worker job timing, plus Sidekiq's own queue-depth/processed/
89
+ # failed/etc. gauges: a separate initializer, since unlike ActiveRecord/ActionController
90
+ # (always present in any Rails app this gem's own gemspec already implies), Sidekiq is
91
+ # genuinely optional and isn't a declared dependency of this gem at all.
92
+ initializer "forge_ops_tracker.track_sidekiq" do
93
+ next unless defined?(::Sidekiq)
94
+
95
+ require "forge_ops_tracker/integrations/sidekiq"
96
+
97
+ configuration = ForgeOpsTracker.configuration
98
+ ::Sidekiq.configure_server do |config|
99
+ config.server_middleware do |chain|
100
+ # No extra args to chain.add: Sidekiq calls Middleware.new(*args) itself for every job,
101
+ # splatting whatever was passed here; the middleware's own keyword-argument defaults
102
+ # (ForgeOpsTracker.configuration, Railtie.performance_flusher) already resolve correctly
103
+ # when called with none, and passing a keyword hash through *args here is genuinely
104
+ # ambiguous (Sidekiq's own chain.add doc example passes a literal positional Hash, not
105
+ # keyword syntax, for exactly this reason).
106
+ chain.add ForgeOpsTracker::Integrations::Sidekiq::Middleware
107
+ end
108
+ end
109
+
110
+ ForgeOpsTracker::Integrations::Sidekiq::StatsReporter.start(configuration: configuration)
111
+ end
112
+
113
+ initializer "forge_ops_tracker.track_solid_queue" do
114
+ next unless defined?(::SolidQueue)
115
+
116
+ require "forge_ops_tracker/integrations/solid_queue"
117
+ ForgeOpsTracker::Integrations::SolidQueue::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
118
+ end
119
+
120
+ initializer "forge_ops_tracker.track_puma" do
121
+ next unless defined?(::Puma)
122
+
123
+ require "forge_ops_tracker/integrations/puma"
124
+ ForgeOpsTracker::Integrations::Puma::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
125
+ end
126
+
127
+ # Net::HTTP is always present (Ruby's own standard library), so this always prepends; the
128
+ # actual on/off decision happens at call time inside Timing#request itself (checking
129
+ # configuration.track_performance/enabled? the same way the subscriptions above do), so
130
+ # toggling that flag after boot still takes effect immediately, the same as every other
131
+ # automatic instrumentation point in this gem.
132
+ initializer "forge_ops_tracker.track_net_http" do
133
+ require "forge_ops_tracker/integrations/net_http"
134
+ Net::HTTP.prepend(ForgeOpsTracker::Integrations::NetHTTP::Timing)
135
+ end
17
136
  end
18
137
  end
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.5.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -6,7 +6,13 @@ require "forge_ops_tracker/client"
6
6
  require "forge_ops_tracker/delivery_queue"
7
7
  require "forge_ops_tracker/error_subscriber"
8
8
  require "forge_ops_tracker/session_flusher"
9
+ require "forge_ops_tracker/performance_flusher"
10
+ require "forge_ops_tracker/performance_instrumentation"
11
+ require "forge_ops_tracker/metric_buffer"
12
+ require "forge_ops_tracker/infrastructure_metric_buffer"
13
+ require "forge_ops_tracker/periodic_poller"
9
14
  require "forge_ops_tracker/middleware/session_tracking"
15
+ require "forge_ops_tracker/middleware/user_context"
10
16
 
11
17
  module ForgeOpsTracker
12
18
  class << self
@@ -17,6 +23,50 @@ module ForgeOpsTracker
17
23
  def configure
18
24
  yield configuration
19
25
  end
26
+
27
+ # Records a named business metric (a signup, a payment, anything a customer wants to name),
28
+ # buffered and flushed periodically as a batch rather than one network call per capture (see
29
+ # MetricBuffer). value defaults to 1.0 so a bare counter-style call ("a signup happened")
30
+ # needs no argument; pass an explicit one for a metric with a real magnitude ("a $49 payment
31
+ # happened"). A no-op, same as every other capture path in this gem, when the gem isn't
32
+ # enabled (no DSN configured, or the current environment isn't in enabled_environments).
33
+ def capture_metric(name, value: 1.0)
34
+ return unless configuration.enabled?
35
+
36
+ metric_buffer.record(metric_name: name, value: value)
37
+ end
38
+
39
+ # Records one infrastructure reading (CPU/memory/disk, or anything else a customer's own
40
+ # script reads) from one of their own hosts. hostname defaults to Configuration#server_name
41
+ # (already derived from Socket.gethostname), so a script running on the box it's reporting
42
+ # about doesn't need to pass one explicitly. Same buffered-batch delivery and no-op-when-
43
+ # disabled contract as capture_metric above.
44
+ def capture_infrastructure_metric(name, value:, hostname: nil)
45
+ return unless configuration.enabled?
46
+
47
+ infrastructure_metric_buffer.record(metric_name: name, value: value, hostname: hostname || configuration.server_name)
48
+ end
49
+
50
+ # Manually attaches an affected user to whatever gets reported for the rest of this request
51
+ # (or, outside a request entirely, in a background job or a console session, for the rest of
52
+ # this thread). Sets the same thread-local ForgeOpsTracker::Middleware::UserContext's own
53
+ # Warden auto-detection uses, so this composes with it rather than being a second, separate
54
+ # mechanism: call this after that middleware ran to override its guess, or call it standalone
55
+ # in an app with no Warden at all. id/email/username are all independently optional; a call
56
+ # with none of them (or all nil) clears whatever was set.
57
+ def set_user(id: nil, email: nil, username: nil)
58
+ user = { id: id, email: email, username: username }.compact
59
+ Thread.current[:forge_ops_tracker_current_user] = user.empty? ? nil : user
60
+ end
61
+
62
+ private
63
+ def metric_buffer
64
+ @metric_buffer ||= MetricBuffer.new(configuration)
65
+ end
66
+
67
+ def infrastructure_metric_buffer
68
+ @infrastructure_metric_buffer ||= InfrastructureMetricBuffer.new(configuration)
69
+ end
20
70
  end
21
71
  end
22
72
 
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -23,6 +23,48 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sidekiq
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: puma
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webrick
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.8'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.8'
26
68
  description: Hooks Rails' error reporter and reports exceptions to a ForgeOps exception
27
69
  tracker instance over HTTP, without ever raising back into the host application.
28
70
  executables: []
@@ -38,7 +80,17 @@ files:
38
80
  - lib/forge_ops_tracker/delivery_queue.rb
39
81
  - lib/forge_ops_tracker/error_subscriber.rb
40
82
  - lib/forge_ops_tracker/event_builder.rb
83
+ - lib/forge_ops_tracker/infrastructure_metric_buffer.rb
84
+ - lib/forge_ops_tracker/integrations/net_http.rb
85
+ - lib/forge_ops_tracker/integrations/puma.rb
86
+ - lib/forge_ops_tracker/integrations/sidekiq.rb
87
+ - lib/forge_ops_tracker/integrations/solid_queue.rb
88
+ - lib/forge_ops_tracker/metric_buffer.rb
41
89
  - lib/forge_ops_tracker/middleware/session_tracking.rb
90
+ - lib/forge_ops_tracker/middleware/user_context.rb
91
+ - lib/forge_ops_tracker/performance_flusher.rb
92
+ - lib/forge_ops_tracker/performance_instrumentation.rb
93
+ - lib/forge_ops_tracker/periodic_poller.rb
42
94
  - lib/forge_ops_tracker/pii_scrubber.rb
43
95
  - lib/forge_ops_tracker/railtie.rb
44
96
  - lib/forge_ops_tracker/session_flusher.rb