forge_ops_tracker 0.5.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +214 -1
  4. data/lib/forge_ops_tracker/breadcrumb_buffer.rb +45 -0
  5. data/lib/forge_ops_tracker/client.rb +37 -0
  6. data/lib/forge_ops_tracker/configuration.rb +117 -0
  7. data/lib/forge_ops_tracker/error_subscriber.rb +28 -1
  8. data/lib/forge_ops_tracker/event_builder.rb +15 -6
  9. data/lib/forge_ops_tracker/failure_event_buffer.rb +73 -0
  10. data/lib/forge_ops_tracker/histogram_bucketer.rb +32 -0
  11. data/lib/forge_ops_tracker/infrastructure_metric_buffer.rb +86 -0
  12. data/lib/forge_ops_tracker/integrations/active_record_pool.rb +41 -0
  13. data/lib/forge_ops_tracker/integrations/net_http.rb +114 -0
  14. data/lib/forge_ops_tracker/integrations/puma.rb +55 -0
  15. data/lib/forge_ops_tracker/integrations/redis_client.rb +62 -0
  16. data/lib/forge_ops_tracker/integrations/sidekiq.rb +69 -0
  17. data/lib/forge_ops_tracker/integrations/solid_queue.rb +37 -0
  18. data/lib/forge_ops_tracker/metric_buffer.rb +97 -0
  19. data/lib/forge_ops_tracker/middleware/breadcrumb_context.rb +35 -0
  20. data/lib/forge_ops_tracker/middleware/span_tracing.rb +38 -0
  21. data/lib/forge_ops_tracker/middleware/user_context.rb +52 -0
  22. data/lib/forge_ops_tracker/performance_flusher.rb +129 -0
  23. data/lib/forge_ops_tracker/performance_instrumentation.rb +92 -0
  24. data/lib/forge_ops_tracker/periodic_poller.rb +50 -0
  25. data/lib/forge_ops_tracker/railtie.rb +272 -0
  26. data/lib/forge_ops_tracker/span_buffer.rb +65 -0
  27. data/lib/forge_ops_tracker/span_queue.rb +61 -0
  28. data/lib/forge_ops_tracker/version.rb +1 -1
  29. data/lib/forge_ops_tracker.rb +106 -0
  30. metadata +76 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f15d33570709adad8c061ee0c432d9a6484fc50881076fae1493b892562188e7
4
- data.tar.gz: f5fada725654a4006a6a44ee0680fb5f7f34cb808087305596892101c743a0ef
3
+ metadata.gz: 8366b59c60be4a6766a7d7775e9eaeed1e184645e620351e81aa97bc88c0cacd
4
+ data.tar.gz: a971669ad792a6893bdcddb577050883cb3d8f175778d817f02174c9428b5acc
5
5
  SHA512:
6
- metadata.gz: 66513f4aebaeb443198a73bad548faaab86653841d213c37000bd343de406926053faa58b65be68934d90c8e12cc1393061ee763e7b5541c4545e90713910fb9
7
- data.tar.gz: 86eb61aca41a42a55a6df37df0d8ef7789b5342055239640a593310da438cda20e3d6e959a0b229c4dbcbe137089e31d8bd537b1eb8d4e91c72bec4acfd6ec8c
6
+ metadata.gz: 03c78e9de07b46238931804ce2743c5ce659efbfdb5ae86151ab64a355c31a9e2fc142f07e22ad15d2e12bff02cb53fddaebd419f3c889ca9bd325adc3205b54
7
+ data.tar.gz: b882fd3008d11185934f8905ad23261bedf5c5290902da1da0a789042f829f117b9e7d2fa66361a79a8d802a1d8561e700b1af81caec8bf4fb41cbbc974eb180
data/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1
4
+
5
+ - Sidekiq job failures now report the real error. Sidekiq raises its own control-flow exception
6
+ (`Sidekiq::JobRetry::Handled`, or `Skip` for a job with retries turned off) once it has scheduled
7
+ a failed job's retry, and that wrapper, not the error inside it, was what reached ForgeOps, so
8
+ every failing Sidekiq job, whatever went wrong, landed in the same opaque issue. The subscriber
9
+ now unwraps it to its `#cause`. Confirmed against a real Sidekiq worker on 7.3 and 8.1. Among
10
+ other things, this is what lets ForgeOps recognize a job class that was renamed or removed while
11
+ jobs for it were still queued (an ActiveJob `UnknownJobClassError`, or a plain Sidekiq job's
12
+ `NameError`), which previously arrived only as the wrapper.
13
+
14
+ ## 0.10.0
15
+
16
+ - Real per-occurrence job/dependency failure tracking, feeding ForgeOps's own new cross-type
17
+ project timeline: a job whose `perform` raised (detected via the existing `perform.active_job`
18
+ subscription reading ActiveSupport::Notifications' own automatic `exception_object` payload key,
19
+ no new hook needed) or an outbound `Net::HTTP` call that raised or came back 5xx (not 4xx) is
20
+ now recorded individually, not just as an aggregate failure count. New `config.track_failures`
21
+ (default true, independent of `track_performance`/`track_breadcrumbs`) and
22
+ `config.failure_event_flush_interval` (default 60s). Known, documented gap: a connection-refused
23
+ or DNS-failure error raised during `Net::HTTP.start`/`Net::HTTP.get_response`'s own connection
24
+ setup (before `#request` itself ever runs) is invisible to this detection, the same pre-existing
25
+ limitation that already silently applied to this SDK's outbound-HTTP breadcrumbs and timing;
26
+ `Net::HTTP.new(...).request(...)` (no explicit `#start`) is unaffected, since Net::HTTP connects
27
+ lazily inside `#request` itself in that case.
28
+
29
+ ## 0.9.0
30
+
31
+ - Performance percentiles: every `PerformanceSample` (controller/query/http timing) now carries a
32
+ small latency histogram alongside its existing count/sum/max, so ForgeOps can compute approximate
33
+ p50/p95/p99 per transaction instead of only a weighted average. No new config; this rides the
34
+ existing `track_performance` flag and flush interval.
35
+ - Background job queue latency: the existing `perform.active_job` subscription now also records how
36
+ long a job actually waited in its queue before starting (not how long it took to run), as a new
37
+ `job_latency` performance-sample kind grouped by queue name. Nil for a job run via `perform_now`,
38
+ which never goes through `enqueue` at all.
39
+ - Database connection pool utilization: a new automatic reporter (matching the existing Sidekiq/
40
+ Solid Queue/Puma stats reporters' own shape) periodically captures this process's own ActiveRecord
41
+ connection pool size/busy/waiting counts as infrastructure metrics, polled on the same
42
+ `config.gauge_poll_interval` those reporters already use.
43
+
44
+ ## 0.8.0
45
+
46
+ - Breadcrumbs: a trail of SQL queries, controller actions, and outbound `Net::HTTP` calls leading
47
+ up to an error, recorded automatically (on by default, same as every other automatic
48
+ instrumentation this gem does; opt out with `config.track_breadcrumbs = false`, or tune
49
+ `config.max_breadcrumbs`, default 30). `ForgeOpsTracker.add_breadcrumb(message, category:,
50
+ level:, data:)` adds your own, regardless of whether the automatic sources are on, and works
51
+ outside a request entirely too (a background job, a console session). Unlike the affected user,
52
+ a breadcrumb's message/data is scrubbed for likely PII.
53
+
54
+ ## 0.7.0
55
+
56
+ - Automatically associates a reported error with the current signed-in user, for any Warden-based
57
+ auth setup (Devise included; Devise just mounts Warden automatically, this isn't Devise-
58
+ specific). On by default, same as every other automatic instrumentation this gem does; opt out
59
+ with `config.track_current_user = false`. `ForgeOpsTracker.set_user(id:, email:, username:)`
60
+ manually sets or overrides it, for a custom auth setup or outside a request entirely (a
61
+ background job, a console session).
62
+
63
+ ## 0.6.0
64
+
65
+ - Performance monitoring now also covers database queries (`sql.active_record`, bucketed by
66
+ Rails' own query name, never raw SQL text), background jobs (`perform.active_job`, any backend,
67
+ Solid Queue included, plus raw Sidekiq workers via a server middleware, deduplicated against
68
+ jobs dispatched through Active Job), and outbound `Net::HTTP` calls, alongside the existing
69
+ controller/action timing. Each shows up as its own `kind` on the same `performance` dashboard
70
+ dataset. Also reports Sidekiq's, Solid Queue's, and Puma's own operational gauges (queue depth,
71
+ worker/dispatcher counts, Puma's thread pool) as infrastructure metrics on a periodic timer
72
+ (`config.gauge_poll_interval`, default 60 seconds), whenever the corresponding library is
73
+ already loaded. All on the same `track_performance` flag as before; no new flags to opt into.
74
+
3
75
  ## 0.5.0
4
76
 
5
77
  - Every reported event now carries `sdk_name` ("ruby"), so a project's language on the ForgeOps
data/README.md CHANGED
@@ -67,13 +67,75 @@ 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
+
96
+ ## Breadcrumbs
97
+
98
+ A trail of what happened right before an error, on by default, no setup needed: every SQL query,
99
+ controller action, and outbound `Net::HTTP` call during a request is recorded automatically, and
100
+ shows up alongside the error on an issue's own detail page.
101
+
102
+ ```ruby
103
+ ForgeOpsTracker.configure do |config|
104
+ config.track_breadcrumbs = false # opt out of the automatic sources entirely
105
+ config.max_breadcrumbs = 30 # oldest entry dropped once this many have accumulated in one request
106
+ end
107
+ ```
108
+
109
+ Add your own by hand, regardless of whether the automatic sources are on:
110
+
111
+ ```ruby
112
+ ForgeOpsTracker.add_breadcrumb("charged card", category: "billing", data: { order_id: order.id })
113
+ ```
114
+
115
+ `category` defaults to `"custom"`, `level` to `"info"` (`"debug"`/`"info"`/`"warning"`/`"error"`
116
+ are the four levels the automatic sources themselves use too), and `data` to `{}`. Works outside a
117
+ request entirely too (a background job, a console session): the buffer it adds to is created
118
+ lazily on whatever thread calls it, the same "works standalone, no specific setup required" shape
119
+ `ForgeOpsTracker.set_user` already has, rather than silently doing nothing with no
120
+ `ForgeOpsTracker::Middleware::BreadcrumbContext` around it.
121
+
122
+ Each request gets its own fresh, bounded trail (a ring buffer capped at `max_breadcrumbs`, oldest
123
+ entry dropped once full): scoped to the current thread the same way `ForgeOpsTracker.set_user`'s
124
+ own thread-local is, so one request's trail never bleeds into another's on a reused Puma thread.
125
+ Unlike the affected user above, a breadcrumb's `message`/`data` **is** scrubbed for likely PII:
126
+ console-style/query/request trail entries are exactly the kind of free text (a bind parameter
127
+ showing up in a message, a URL with a token in it) the scrubber exists to catch, not a
128
+ deliberately-structured field the way `user` is.
129
+
70
130
  ## PII scrubbing
71
131
 
72
132
  By default, the message, backtrace, and any context/tags you attach are scanned for likely
73
133
  personal data: email addresses, formatted SSNs/credit cards, known API key/token formats, and
74
134
  anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar); redacted
75
135
  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.
136
+ this is a second, earlier layer, not the only one. The user identified via automatic Warden
137
+ detection or `ForgeOpsTracker.set_user` above is a deliberate exception: it's never scrubbed,
138
+ since redacting it would defeat the whole point of identifying users in the first place.
77
139
 
78
140
  To disable it (e.g. if your app already scrubs its own error context, or you have your own reasons
79
141
  to want the raw payload):
@@ -124,3 +186,154 @@ end
124
186
 
125
187
  Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
126
188
  flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
189
+
190
+ ## Performance monitoring
191
+
192
+ By default, every request's controller/action duration is timed (via Rails' own
193
+ `process_action.action_controller` instrumentation, no extra middleware needed) so a dashboard
194
+ widget on ForgeOps can show which parts of your app are actually slow, not just which ones raise.
195
+ Bucketed by transaction ("PostsController#show") and flushed as a small periodic aggregate per
196
+ transaction on a background thread, the same delivery philosophy as session tracking above: a
197
+ broken or unreachable tracker never affects the host app either way.
198
+
199
+ Each aggregate carries a small latency histogram alongside its count/sum/max, so ForgeOps can show
200
+ an approximate p50/p95/p99 per transaction, not just an average: accurate to the width of whichever
201
+ latency bucket a duration falls into (50/100/250/500/1000/2500/5000/10000ms), the standard
202
+ histogram-quantile trade-off (the same one Prometheus's own `histogram_quantile` makes) rather than
203
+ storing every individual request's own duration.
204
+
205
+ ```ruby
206
+ ForgeOpsTracker.configure do |config|
207
+ config.track_performance = false # opt out entirely
208
+ config.performance_flush_interval = 30 # seconds; default 60
209
+ end
210
+ ```
211
+
212
+ Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
213
+ periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
214
+ failure.
215
+
216
+ ### Database queries, background jobs, and outbound HTTP calls
217
+
218
+ The same automatic instrumentation, on the same `track_performance` flag, also covers:
219
+
220
+ - **Database queries**, via `sql.active_record`: bucketed by Rails' own auto-generated query name
221
+ ("User Load", "Order Create"), not the raw SQL text. Internal schema-introspection queries
222
+ ("SCHEMA") and cached reads (never a real round trip to the database) are skipped.
223
+ - **Background jobs**, via `perform.active_job`: bucketed by job class. Covers any Active Job
224
+ backend, Solid Queue included, with no backend-specific code needed. A second, independent
225
+ sample, `kind: "job_latency"`, bucketed by queue name instead of job class, also records how long
226
+ the job actually waited in its queue before this run started: `nil`, and skipped, for a job run
227
+ via `perform_now`, which never goes through `enqueue` at all and so has no queue wait to report.
228
+ - **Raw Sidekiq workers** (a `Sidekiq::Worker`/`Sidekiq::Job` not dispatched through Active Job):
229
+ a server middleware, registered automatically when Sidekiq is already loaded. A job dispatched
230
+ through Active Job and run on Sidekiq is still only counted once, by the Active Job hook above,
231
+ not twice.
232
+ - **Outbound `Net::HTTP` calls**, bucketed by `"<method> <host>"`, never the full URL (a path or
233
+ query string could carry an id or a token). Most other Ruby HTTP client libraries (Faraday's own
234
+ `net_http` adapter, HTTParty, RestClient) ultimately call through `Net::HTTP`, so this covers
235
+ those too as a side effect.
236
+
237
+ Every one of these shows up as its own `kind` ("controller", "job", "job_latency", "query", "http")
238
+ on the same `performance` dashboard dataset, so "slowest jobs" and "slowest queries" are just a
239
+ filtered version of the same widget builder "slowest transactions" already uses.
240
+
241
+ ### Sidekiq, Solid Queue, Puma, and ActiveRecord gauges
242
+
243
+ Also on by default whenever the corresponding library is already loaded: Sidekiq's own aggregate
244
+ stats (`Sidekiq::Stats`: processed/failed/scheduled/retry/dead counts, plus a queue-depth
245
+ reading per queue), Solid Queue's own state (active workers, active dispatchers, failed/scheduled/
246
+ blocked counts, a queue-depth reading per queue), Puma's own thread pool (backlog, running threads,
247
+ pool capacity), and this process's own ActiveRecord connection pool (size, busy count, waiting
248
+ count, and a derived utilization percent). Reported as ordinary infrastructure metrics
249
+ (`sidekiq.queue_depth.default`, `puma.backlog`, `active_record_pool.utilization_pct`, and so on) via
250
+ the same `capture_infrastructure_metric` call your own scripts use, on a periodic timer:
251
+
252
+ ```ruby
253
+ ForgeOpsTracker.configure do |config|
254
+ config.gauge_poll_interval = 30 # seconds; default 60
255
+ end
256
+ ```
257
+
258
+ No separate opt-out: these are gated by the same `track_performance` flag as everything else in
259
+ this section, not a flag of their own.
260
+
261
+ ## Failure tracking
262
+
263
+ On by default, from the same two instrumentation points performance monitoring already uses:
264
+
265
+ - **Background jobs**: a job whose `perform` raised, via the existing `perform.active_job`
266
+ subscription. Not just an aggregate failure count; each occurrence carries the job class, the
267
+ raised exception's class name, and its message.
268
+ - **Outbound `Net::HTTP` calls**: a request that raised, or came back with a 5xx status (a 4xx is
269
+ a response the dependency actually gave, not a failure of the dependency itself).
270
+
271
+ ```ruby
272
+ ForgeOpsTracker.configure do |config|
273
+ config.track_failures = false # opt out entirely
274
+ config.failure_event_flush_interval = 30 # seconds; default 60
275
+ end
276
+ ```
277
+
278
+ **Known, honest gap**: `Net::HTTP.get_response` and the `Net::HTTP.start { |http| ... }` block
279
+ form both open the TCP connection before the block (and so before `#request`, the only method
280
+ this gem wraps) ever runs, so a connection-refused or DNS-failure error raised during that setup
281
+ is invisible here, for the same reason it's already invisible to this gem's own outbound-HTTP
282
+ breadcrumbs and timing. `Net::HTTP.new(host, port).request(...)` (no explicit `#start`) is
283
+ unaffected: confirmed directly that Net::HTTP connects lazily, inside `#request` itself, the first
284
+ time it's called on a not-yet-started instance.
285
+
286
+ Requires a ForgeOps plan that includes performance monitoring, the same plan feature performance
287
+ monitoring itself already requires; on a plan that doesn't, the periodic flushes are simply
288
+ rejected server-side and dropped, exactly like any other delivery failure.
289
+
290
+ ## Custom metrics
291
+
292
+ Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
293
+ signup or a payment isn't something this gem could ever detect on its own, so this is an explicit
294
+ call your own code makes. `value` defaults to `1.0` so a bare counter-style call needs no
295
+ argument; pass one for a metric with a real amount. Buffered and flushed as a batch on a
296
+ background thread, same delivery philosophy as everything else here, so this is safe to call from
297
+ inside a request (right after a signup completes, say) without adding network latency there.
298
+
299
+ ```ruby
300
+ ForgeOpsTracker.capture_metric("signups")
301
+ ForgeOpsTracker.capture_metric("revenue", value: 49.00)
302
+
303
+ ForgeOpsTracker.configure do |config|
304
+ config.metric_flush_interval = 30 # seconds; default 60
305
+ end
306
+ ```
307
+
308
+ Requires a ForgeOps plan that includes custom metrics; on a plan that doesn't, the periodic
309
+ flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
310
+
311
+ ## Infrastructure monitoring
312
+
313
+ A deliberately different shape from everything above: there's no automatic instrumentation and no
314
+ ForgeOps-built agent. Run your own short-lived script on a cron entry or a systemd timer, reading
315
+ your own host's own stats; `hostname` defaults to the box the script is actually running on.
316
+ Buffered and flushed on exit, so a handful of capture calls in one short-lived process still cost
317
+ one network request, not several.
318
+
319
+ ```ruby
320
+ # A cron entry or systemd timer runs this periodically, not your web app itself.
321
+ require "forge_ops_tracker"
322
+ ForgeOpsTracker.configure { |config| config.dsn = ENV["FORGE_OPS_DSN"] }
323
+
324
+ load_average = File.read("/proc/loadavg").split.first.to_f
325
+ ForgeOpsTracker.capture_infrastructure_metric("load_average", value: load_average)
326
+
327
+ meminfo = File.read("/proc/meminfo").lines.to_h { |line| line.split(":").map(&:strip) }
328
+ total_kb, available_kb = meminfo["MemTotal"].to_i, meminfo["MemAvailable"].to_i
329
+ ForgeOpsTracker.capture_infrastructure_metric("memory_used_percent",
330
+ value: 100.0 * (total_kb - available_kb) / total_kb)
331
+
332
+ disk_used_percent = `df --output=pcent / | tail -1`.strip.delete("%").to_f
333
+ ForgeOpsTracker.capture_infrastructure_metric("disk_used_percent", value: disk_used_percent)
334
+ ```
335
+
336
+ The example above is Linux-specific (`/proc/loadavg`, `/proc/meminfo`); on another OS, read that
337
+ platform's own equivalents instead. Requires a ForgeOps plan that includes infrastructure
338
+ monitoring; on a plan that doesn't, the periodic flushes are simply rejected server-side and
339
+ dropped, exactly like any other delivery failure.
@@ -0,0 +1,45 @@
1
+ module ForgeOpsTracker
2
+ # A bounded, in-order trail of whatever happened recently on this thread: SQL queries,
3
+ # controller actions, outgoing HTTP requests, and anything added by hand via
4
+ # ForgeOpsTracker.add_breadcrumb, all recorded automatically once ForgeOpsTracker::Railtie's own
5
+ # subscriptions are installed. The direct Ruby analog to sdks/typescript's own BreadcrumbBuffer
6
+ # (a ring buffer capped at Configuration#max_breadcrumbs, oldest entry dropped once full), but
7
+ # per-thread rather than per-page-lifetime: a browser tab has one continuous session to trail,
8
+ # but a Rails server handles many concurrent, unrelated requests on a thread pool, the same
9
+ # "Thread.current, not a plain global" reasoning ForgeOpsTracker.set_user's own thread-local
10
+ # already documents. ForgeOpsTracker::Middleware::BreadcrumbContext resets this to a fresh, empty
11
+ # buffer at the start of every request, so one request's trail never bleeds into another's on a
12
+ # reused Puma thread, and clears it again in an ensure so it doesn't leak into whatever runs next
13
+ # on that thread outside a request either (a console session, a background job).
14
+ class BreadcrumbBuffer
15
+ def initialize(configuration)
16
+ @configuration = configuration
17
+ @entries = []
18
+ end
19
+
20
+ def add(category:, message:, level: "info", data: {})
21
+ # Read fresh on every add, not captured once at construction: the same reasoning
22
+ # DeliveryQueue re-reads Configuration#queue_size on every push, so a config change from
23
+ # ForgeOpsTracker.configure takes effect on whatever's added next, not just a buffer created
24
+ # afterward.
25
+ max_size = [ configuration.max_breadcrumbs, 0 ].max
26
+ return if max_size.zero?
27
+
28
+ entries << {
29
+ category: category.to_s,
30
+ message: message.to_s,
31
+ level: level.to_s,
32
+ timestamp: Time.now.utc.iso8601,
33
+ data: data || {}
34
+ }
35
+ entries.shift while entries.length > max_size
36
+ end
37
+
38
+ def all
39
+ entries.dup
40
+ end
41
+
42
+ private
43
+ attr_reader :configuration, :entries
44
+ end
45
+ end
@@ -23,6 +23,43 @@ 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
+
46
+ # Same again; see Configuration#failure_events_uri and FailureEventBuffer. payload is a batch
47
+ # of individual real failures (matching Api::V1::FailureEventsController's own { failures:
48
+ # [...] } expectation), the same "a list of individually-meaningful entries, not an aggregate"
49
+ # shape deliver_metrics above already has, not deliver_performance_samples' own bucketed one.
50
+ def deliver_failure_events(entries)
51
+ post(configuration.failure_events_uri, { failures: entries })
52
+ end
53
+
54
+ # Same delivery contract again; see Configuration#spans_uri and SpanQueue. payload is one
55
+ # whole captured trace (a trace_id plus every span belonging to it), matching what
56
+ # Api::V1::SpansController expects - unlike every other deliver_* method above, this is never
57
+ # a batch of several distinct traces at once; SpanQueue pushes (and this posts) one trace per
58
+ # call, the moment it's ready, rather than accumulating several over a flush interval.
59
+ def deliver_spans(trace_id:, spans:)
60
+ post(configuration.spans_uri, { trace_id: trace_id, spans: spans })
61
+ end
62
+
26
63
  private
27
64
  attr_reader :configuration
28
65
 
@@ -9,6 +9,13 @@ 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 :track_breadcrumbs, :max_breadcrumbs
15
+ attr_accessor :track_failures, :failure_event_flush_interval
16
+ attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
17
+ attr_accessor :gauge_poll_interval
18
+ attr_accessor :track_tracing, :trace_capture_threshold_ms
12
19
 
13
20
  def initialize
14
21
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -43,6 +50,70 @@ module ForgeOpsTracker
43
50
  # ForgeOpsTracker::Middleware::SessionTracking for what this actually wraps.
44
51
  @track_sessions = true
45
52
  @session_flush_interval = 60
53
+ # Auto-instruments every request the same "on unless you turn it off" default as
54
+ # track_sessions above; see Railtie's own comment for exactly what this subscribes to.
55
+ @track_performance = true
56
+ @performance_flush_interval = 60
57
+ # Auto-detects the current user via Warden (env["warden"].user; Devise mounts Warden
58
+ # automatically, so this covers Devise apps too, but works for any Warden-based auth, not
59
+ # just Devise specifically) the same "on unless you turn it off" default every other
60
+ # automatic instrumentation flag above already has. See
61
+ # ForgeOpsTracker::Middleware::UserContext for how, and ForgeOpsTracker.set_user for the
62
+ # manual override/fallback when there's no Warden at all or its guess isn't right.
63
+ @track_current_user = true
64
+ # Auto-instruments SQL queries, controller actions, and outgoing Net::HTTP requests as
65
+ # breadcrumbs the same "on unless you turn it off" default every other automatic
66
+ # instrumentation flag above already has. See ForgeOpsTracker::Middleware::BreadcrumbContext
67
+ # for the per-request buffer this gates, and ForgeOpsTracker.add_breadcrumb for adding one by
68
+ # hand regardless of this flag (the manual API isn't gated by it: an app that wants only its
69
+ # own hand-added breadcrumbs, with none of the automatic ones, turns this off and still gets
70
+ # add_breadcrumb).
71
+ @track_breadcrumbs = true
72
+ # Oldest entry dropped once this many have accumulated in a single request: the same
73
+ # "bounded ring buffer, not an unbounded log" reasoning sdks/typescript's own
74
+ # Configuration#maxBreadcrumbs already documents, so a request that runs a very large number
75
+ # of queries doesn't grow the trail (and the payload it rides in) without bound.
76
+ @max_breadcrumbs = 30
77
+ # Recorded from the same two instrumentation points that already exist for other reasons
78
+ # (the perform.active_job subscription, Net::HTTP's own Timing#request): a job whose perform
79
+ # raised, or an outbound call that raised or came back 5xx. On by default, independently of
80
+ # track_performance/track_breadcrumbs, the same "several genuinely independent mechanisms
81
+ # recorded from the same instrumentation point" pattern breadcrumbs already established at
82
+ # both of those exact call sites.
83
+ @track_failures = true
84
+ @failure_event_flush_interval = 60
85
+ # No track_metrics/track_infrastructure boolean the way track_sessions/track_performance
86
+ # each have one: those gate automatic instrumentation that's on unless you turn it off;
87
+ # capture_metric/capture_infrastructure_metric are explicit calls the customer's own code
88
+ # chooses to make at all, so there's no "automatic behavior" for a flag to disable. Only
89
+ # the flush interval needs a knob.
90
+ @metric_flush_interval = 60
91
+ @infrastructure_metric_flush_interval = 60
92
+ # How often the Sidekiq/Solid Queue/Puma stats reporters poll and report their own gauge
93
+ # readings (queue depth, worker counts, Puma backlog/threads/pool capacity). Gated by
94
+ # track_performance, same as controller/job/query/http timing: one flag for "automatically
95
+ # instrument this app's own operational data," reused rather than adding a separate boolean
96
+ # per new automatic instrumentation source this gem grows.
97
+ @gauge_poll_interval = 60
98
+ # Auto-instruments controller actions, SQL queries, outgoing Net::HTTP requests, and Redis
99
+ # calls into a nested per-request span tree, the same "on unless you turn it off" default
100
+ # every other automatic instrumentation flag above already has. See
101
+ # ForgeOpsTracker::Middleware::SpanTracing for the per-request buffer this gates, and
102
+ # ForgeOpsTracker.span for wrapping a customer's own service-level code by hand so it shows
103
+ # up as a real nested layer (e.g. "PaymentService#charge") rather than its database/HTTP
104
+ # calls appearing to hang directly off the controller root. Whether this ever actually
105
+ # reaches the server is a completely separate question from whether it's on: distributed
106
+ # tracing is a plan-gated feature (see Api::V1::SpansController), enforced server-side the
107
+ # same way every other ingestion endpoint already is, not by this client-side flag.
108
+ @track_tracing = true
109
+ # Only a request whose own total duration is at least this slow ever gets sent at all: see
110
+ # SpanBuffer#slow?/Middleware::SpanTracing's own comment for why that decision happens
111
+ # entirely client-side, before a single byte goes over the wire, rather than sending every
112
+ # trace and letting the server decide. 1 second is a deliberately conservative default (most
113
+ # web apps consider anything near that already a bad user experience), not tied to any
114
+ # PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
115
+ # many requests, this describes a single one being outright slow.
116
+ @trace_capture_threshold_ms = 1_000
46
117
  end
47
118
 
48
119
  def api_key
@@ -72,6 +143,52 @@ module ForgeOpsTracker
72
143
  uri
73
144
  end
74
145
 
146
+ # Same substitution as session_checkins_uri above, its own sibling path under the same DSN.
147
+ def performance_samples_uri
148
+ uri = ingestion_uri
149
+ return nil unless uri
150
+
151
+ uri = uri.dup
152
+ uri.path = uri.path.sub(%r{/events\z}, "/performance_samples")
153
+ uri
154
+ end
155
+
156
+ def custom_metrics_uri
157
+ uri = ingestion_uri
158
+ return nil unless uri
159
+
160
+ uri = uri.dup
161
+ uri.path = uri.path.sub(%r{/events\z}, "/custom_metrics")
162
+ uri
163
+ end
164
+
165
+ def infrastructure_metrics_uri
166
+ uri = ingestion_uri
167
+ return nil unless uri
168
+
169
+ uri = uri.dup
170
+ uri.path = uri.path.sub(%r{/events\z}, "/infrastructure_metrics")
171
+ uri
172
+ end
173
+
174
+ def failure_events_uri
175
+ uri = ingestion_uri
176
+ return nil unless uri
177
+
178
+ uri = uri.dup
179
+ uri.path = uri.path.sub(%r{/events\z}, "/failure_events")
180
+ uri
181
+ end
182
+
183
+ def spans_uri
184
+ uri = ingestion_uri
185
+ return nil unless uri
186
+
187
+ uri = uri.dup
188
+ uri.path = uri.path.sub(%r{/events\z}, "/spans")
189
+ uri
190
+ end
191
+
75
192
  def enabled?
76
193
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
77
194
  end
@@ -5,6 +5,16 @@ module ForgeOpsTracker
5
5
  # wrapped to guarantee this never propagates an exception back into the
6
6
  # host app's error-handling cycle.
7
7
  class ErrorSubscriber
8
+ # Sidekiq raises its own control-flow exception once it has already scheduled a failed job's
9
+ # retry (Sidekiq::JobRetry::Handled, and Sidekiq::JobRetry::Skip, a subclass, for a job that
10
+ # opted out of retries), and Rails' executor, which Sidekiq's Rails integration wraps every
11
+ # job in, reports whatever escapes that wrap. Left alone, every failing Sidekiq job, whatever
12
+ # actually went wrong inside it, reaches ForgeOps as the same opaque "Sidekiq::JobRetry::
13
+ # Handled" issue with the real error nowhere in it. The real error is that exception's #cause
14
+ # (Sidekiq raises it from inside its own rescue), so it's unwrapped here, by class name
15
+ # rather than constant since Sidekiq is an optional dependency this gem never requires.
16
+ RETRY_CONTROL_FLOW_CLASS = "Sidekiq::JobRetry::Handled".freeze
17
+
8
18
  def initialize(configuration, delivery_queue: DeliveryQueue.new(configuration), event_builder: EventBuilder.new(configuration))
9
19
  @configuration = configuration
10
20
  @delivery_queue = delivery_queue
@@ -14,7 +24,15 @@ module ForgeOpsTracker
14
24
  def report(error, handled: true, severity: nil, context: {}, source: nil)
15
25
  return unless configuration.enabled?
16
26
 
17
- delivery_queue.push(event_builder.build(error, context: context))
27
+ # Read here, not inside EventBuilder: Rails.error.subscribe's own #report interface never
28
+ # hands this class the Rack env, so ForgeOpsTracker::Middleware::UserContext (Warden
29
+ # auto-detection) and ForgeOpsTracker.set_user (the manual override/fallback) both
30
+ # communicate with this exact same thread-local instead, the only channel available here.
31
+ # ForgeOpsTracker::Middleware::BreadcrumbContext's own per-request buffer works the same way.
32
+ error = unwrap_retry_control_flow(error)
33
+ user = Thread.current[:forge_ops_tracker_current_user]
34
+ breadcrumbs = Thread.current[:forge_ops_tracker_breadcrumbs]&.all || []
35
+ delivery_queue.push(event_builder.build(error, context: context, user: user, breadcrumbs: breadcrumbs))
18
36
  nil
19
37
  rescue StandardError => e
20
38
  configuration.logger&.debug { "[ForgeOpsTracker] report failed: #{e.class}: #{e.message}" }
@@ -23,5 +41,14 @@ module ForgeOpsTracker
23
41
 
24
42
  private
25
43
  attr_reader :configuration, :delivery_queue, :event_builder
44
+
45
+ # Only ever unwraps when there is a real #cause to unwrap to; a Handled with no cause (not
46
+ # something Sidekiq itself does) is reported as-is rather than dropped.
47
+ def unwrap_retry_control_flow(error)
48
+ while error.cause && error.class.ancestors.any? { |ancestor| ancestor.name == RETRY_CONTROL_FLOW_CLASS }
49
+ error = error.cause
50
+ end
51
+ error
52
+ end
26
53
  end
27
54
  end
@@ -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, breadcrumbs: [])
31
31
  payload = {
32
32
  exception_class: error.class.name,
33
33
  message: error.message.to_s,
@@ -40,25 +40,34 @@ module ForgeOpsTracker
40
40
  tags: {},
41
41
  sdk_name: SDK_NAME
42
42
  }
43
+ payload[:user] = user if user && !user.empty?
44
+ payload[:breadcrumbs] = breadcrumbs if breadcrumbs && !breadcrumbs.empty?
43
45
  scrub(payload)
44
46
  end
45
47
 
46
48
  private
47
49
  attr_reader :configuration
48
50
 
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.
51
+ # exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone;
52
+ # structured fields this gem or the host app sets deliberately, not free text an exception
53
+ # or its context could accidentally spill sensitive data into. user specifically is a
54
+ # deliberate exemption, not an oversight: the server's own PiiScrubber-equivalent would
55
+ # otherwise redact the exact email address this field exists to carry. breadcrumbs is *not*
56
+ # exempt, unlike user: console-style/query/request trail entries are exactly the kind of free
57
+ # text (a query's bind params showing up in a message, a URL with a token in it) the scrubber
58
+ # exists to catch, matching the server's own api/v1/events_controller.rb treatment of this
59
+ # field.
53
60
  def scrub(payload)
54
61
  return payload unless configuration.scrub_pii
55
62
 
56
- payload.merge(
63
+ scrubbed = payload.merge(
57
64
  message: PiiScrubber.scrub(payload[:message]),
58
65
  backtrace: PiiScrubber.scrub(payload[:backtrace]),
59
66
  context: PiiScrubber.scrub(payload[:context]),
60
67
  tags: PiiScrubber.scrub(payload[:tags])
61
68
  )
69
+ scrubbed[:breadcrumbs] = PiiScrubber.scrub(payload[:breadcrumbs]) if payload.key?(:breadcrumbs)
70
+ scrubbed
62
71
  end
63
72
 
64
73
  def backtrace_frames(error)