forge_ops_tracker 0.7.0 → 0.10.2

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: 72692319cfdf068129d7d005773e21a3b6b78cb9f7816cf0e7c8e6409c9704f1
4
- data.tar.gz: 713f244a7ab71a005e66b7f00d1dff3e1e3ffc44e304d5a36f79c911e508c74b
3
+ metadata.gz: 9b32a031e24fb613c219aee3bf01f0517043b05b4d1788d7ece456096d7561a0
4
+ data.tar.gz: 39c7f16920425d01862607b6d3f85b957c694ab009ce8ffd010f33714632a3a9
5
5
  SHA512:
6
- metadata.gz: 57f5372fc1aa6ac8f8830804003c00d68d0633d4f330dc8f6c678faa1c1dbc3f09ad1da0f29c180c63a90dc41f2d0f6acb008e4a726d59ba6b806298af3d6d8f
7
- data.tar.gz: 2b21f12200f99202c5f26cc739104021d5eaf1cfe16a01b075fe4bc3418ada28cf7befb91470fb1ebd8bb18865cd52e61a705decc5f511c2c497a79c74c36016
6
+ metadata.gz: 315fb4765179a298da43dd2beabe68970f257dee7b4b1f3938bcc6efa72ac403b8dbb0e39b8d16dfcaf19b76696aed961270dc88fd92731b05ae1610b3d4a223
7
+ data.tar.gz: f2907bae8015b4093346a30369cc3da38c8d36f4198ae4935cb1e2d2cea2e6baed44d5fcab4a8278029f228353d991275316fd54a9e08bd3116519b529f09159
data/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.2
4
+
5
+ - Documentation only: the README and package description now describe ForgeOps as a hosted service, link to getforgeops.net, and show the real host in the connection string example instead of a placeholder. No code changes.
6
+
7
+ ## 0.10.1
8
+
9
+ - Sidekiq job failures now report the real error. Sidekiq raises its own control-flow exception
10
+ (`Sidekiq::JobRetry::Handled`, or `Skip` for a job with retries turned off) once it has scheduled
11
+ a failed job's retry, and that wrapper, not the error inside it, was what reached ForgeOps, so
12
+ every failing Sidekiq job, whatever went wrong, landed in the same opaque issue. The subscriber
13
+ now unwraps it to its `#cause`. Confirmed against a real Sidekiq worker on 7.3 and 8.1. Among
14
+ other things, this is what lets ForgeOps recognize a job class that was renamed or removed while
15
+ jobs for it were still queued (an ActiveJob `UnknownJobClassError`, or a plain Sidekiq job's
16
+ `NameError`), which previously arrived only as the wrapper.
17
+
18
+ ## 0.10.0
19
+
20
+ - Real per-occurrence job/dependency failure tracking, feeding ForgeOps's own new cross-type
21
+ project timeline: a job whose `perform` raised (detected via the existing `perform.active_job`
22
+ subscription reading ActiveSupport::Notifications' own automatic `exception_object` payload key,
23
+ no new hook needed) or an outbound `Net::HTTP` call that raised or came back 5xx (not 4xx) is
24
+ now recorded individually, not just as an aggregate failure count. New `config.track_failures`
25
+ (default true, independent of `track_performance`/`track_breadcrumbs`) and
26
+ `config.failure_event_flush_interval` (default 60s). Known, documented gap: a connection-refused
27
+ or DNS-failure error raised during `Net::HTTP.start`/`Net::HTTP.get_response`'s own connection
28
+ setup (before `#request` itself ever runs) is invisible to this detection, the same pre-existing
29
+ limitation that already silently applied to this SDK's outbound-HTTP breadcrumbs and timing;
30
+ `Net::HTTP.new(...).request(...)` (no explicit `#start`) is unaffected, since Net::HTTP connects
31
+ lazily inside `#request` itself in that case.
32
+
33
+ ## 0.9.0
34
+
35
+ - Performance percentiles: every `PerformanceSample` (controller/query/http timing) now carries a
36
+ small latency histogram alongside its existing count/sum/max, so ForgeOps can compute approximate
37
+ p50/p95/p99 per transaction instead of only a weighted average. No new config; this rides the
38
+ existing `track_performance` flag and flush interval.
39
+ - Background job queue latency: the existing `perform.active_job` subscription now also records how
40
+ long a job actually waited in its queue before starting (not how long it took to run), as a new
41
+ `job_latency` performance-sample kind grouped by queue name. Nil for a job run via `perform_now`,
42
+ which never goes through `enqueue` at all.
43
+ - Database connection pool utilization: a new automatic reporter (matching the existing Sidekiq/
44
+ Solid Queue/Puma stats reporters' own shape) periodically captures this process's own ActiveRecord
45
+ connection pool size/busy/waiting counts as infrastructure metrics, polled on the same
46
+ `config.gauge_poll_interval` those reporters already use.
47
+
48
+ ## 0.8.0
49
+
50
+ - Breadcrumbs: a trail of SQL queries, controller actions, and outbound `Net::HTTP` calls leading
51
+ up to an error, recorded automatically (on by default, same as every other automatic
52
+ instrumentation this gem does; opt out with `config.track_breadcrumbs = false`, or tune
53
+ `config.max_breadcrumbs`, default 30). `ForgeOpsTracker.add_breadcrumb(message, category:,
54
+ level:, data:)` adds your own, regardless of whether the automatic sources are on, and works
55
+ outside a request entirely too (a background job, a console session). Unlike the affected user,
56
+ a breadcrumb's message/data is scrubbed for likely PII.
57
+
3
58
  ## 0.7.0
4
59
 
5
60
  - Automatically associates a reported error with the current signed-in user, for any Warden-based
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ForgeOpsTracker
2
2
 
3
- Rails exception reporting client for a [ForgeOps](../../) instance.
3
+ Rails exception reporting client for [ForgeOps](https://getforgeops.net).
4
4
 
5
5
  ## Installation
6
6
 
@@ -15,7 +15,7 @@ Set a DSN (from a project's settings page in ForgeOps) via an initializer or env
15
15
  ```ruby
16
16
  # config/initializers/forge_ops_tracker.rb
17
17
  ForgeOpsTracker.configure do |config|
18
- config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@forgeops.example.com/api/v1/events"
18
+ config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@getforgeops.net/api/v1/events"
19
19
  config.release = ENV["HEROKU_SLUG_COMMIT"] || `git rev-parse HEAD`.strip
20
20
  config.enabled_environments = %w[production staging] # default; reporting is a no-op elsewhere
21
21
  end
@@ -67,6 +67,25 @@ 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
+ ### Sidekiq job failures and renamed job classes
71
+
72
+ A failing Sidekiq job is reported through `Rails.error` like any other error. Since 0.10.1 the issue
73
+ carries the real error, not Sidekiq's internal `Sidekiq::JobRetry::Handled` retry wrapper (which is
74
+ what every failing job used to arrive as, whatever went wrong inside it).
75
+
76
+ Renaming or removing a job class breaks every job still queued under the old name, since Sidekiq
77
+ stores the class name as a string, and no test can catch it because those jobs live in Redis. ForgeOps
78
+ recognizes that failure (an Active Job `UnknownJobClassError`, or a plain Sidekiq job's `NameError`
79
+ from constantizing the class), names the class that no longer exists, and shows how soon after which
80
+ release it first appeared. Keep the old name as an alias until the queue, retry set and scheduled set
81
+ have drained:
82
+
83
+ ```ruby
84
+ # app/jobs/send_invoice_job.rb
85
+ # Kept only until jobs queued under the old name have drained, then delete this file.
86
+ SendInvoiceJob = DeliverInvoiceJob
87
+ ```
88
+
70
89
  ## Identifying users
71
90
 
72
91
  If you're using Devise, or any other Warden-based authentication, the currently signed-in user is
@@ -93,6 +112,40 @@ none of them (or with a signed-out `current_user`, as the `&.` above already han
93
112
  whatever was set. Shows up on an issue's own detail page, and as its own `affected_users_count`
94
113
  alongside the regular event count.
95
114
 
115
+ ## Breadcrumbs
116
+
117
+ A trail of what happened right before an error, on by default, no setup needed: every SQL query,
118
+ controller action, and outbound `Net::HTTP` call during a request is recorded automatically, and
119
+ shows up alongside the error on an issue's own detail page.
120
+
121
+ ```ruby
122
+ ForgeOpsTracker.configure do |config|
123
+ config.track_breadcrumbs = false # opt out of the automatic sources entirely
124
+ config.max_breadcrumbs = 30 # oldest entry dropped once this many have accumulated in one request
125
+ end
126
+ ```
127
+
128
+ Add your own by hand, regardless of whether the automatic sources are on:
129
+
130
+ ```ruby
131
+ ForgeOpsTracker.add_breadcrumb("charged card", category: "billing", data: { order_id: order.id })
132
+ ```
133
+
134
+ `category` defaults to `"custom"`, `level` to `"info"` (`"debug"`/`"info"`/`"warning"`/`"error"`
135
+ are the four levels the automatic sources themselves use too), and `data` to `{}`. Works outside a
136
+ request entirely too (a background job, a console session): the buffer it adds to is created
137
+ lazily on whatever thread calls it, the same "works standalone, no specific setup required" shape
138
+ `ForgeOpsTracker.set_user` already has, rather than silently doing nothing with no
139
+ `ForgeOpsTracker::Middleware::BreadcrumbContext` around it.
140
+
141
+ Each request gets its own fresh, bounded trail (a ring buffer capped at `max_breadcrumbs`, oldest
142
+ entry dropped once full): scoped to the current thread the same way `ForgeOpsTracker.set_user`'s
143
+ own thread-local is, so one request's trail never bleeds into another's on a reused Puma thread.
144
+ Unlike the affected user above, a breadcrumb's `message`/`data` **is** scrubbed for likely PII:
145
+ console-style/query/request trail entries are exactly the kind of free text (a bind parameter
146
+ showing up in a message, a URL with a token in it) the scrubber exists to catch, not a
147
+ deliberately-structured field the way `user` is.
148
+
96
149
  ## PII scrubbing
97
150
 
98
151
  By default, the message, backtrace, and any context/tags you attach are scanned for likely
@@ -162,6 +215,12 @@ Bucketed by transaction ("PostsController#show") and flushed as a small periodic
162
215
  transaction on a background thread, the same delivery philosophy as session tracking above: a
163
216
  broken or unreachable tracker never affects the host app either way.
164
217
 
218
+ Each aggregate carries a small latency histogram alongside its count/sum/max, so ForgeOps can show
219
+ an approximate p50/p95/p99 per transaction, not just an average: accurate to the width of whichever
220
+ latency bucket a duration falls into (50/100/250/500/1000/2500/5000/10000ms), the standard
221
+ histogram-quantile trade-off (the same one Prometheus's own `histogram_quantile` makes) rather than
222
+ storing every individual request's own duration.
223
+
165
224
  ```ruby
166
225
  ForgeOpsTracker.configure do |config|
167
226
  config.track_performance = false # opt out entirely
@@ -181,7 +240,10 @@ The same automatic instrumentation, on the same `track_performance` flag, also c
181
240
  ("User Load", "Order Create"), not the raw SQL text. Internal schema-introspection queries
182
241
  ("SCHEMA") and cached reads (never a real round trip to the database) are skipped.
183
242
  - **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.
243
+ backend, Solid Queue included, with no backend-specific code needed. A second, independent
244
+ sample, `kind: "job_latency"`, bucketed by queue name instead of job class, also records how long
245
+ the job actually waited in its queue before this run started: `nil`, and skipped, for a job run
246
+ via `perform_now`, which never goes through `enqueue` at all and so has no queue wait to report.
185
247
  - **Raw Sidekiq workers** (a `Sidekiq::Worker`/`Sidekiq::Job` not dispatched through Active Job):
186
248
  a server middleware, registered automatically when Sidekiq is already loaded. A job dispatched
187
249
  through Active Job and run on Sidekiq is still only counted once, by the Active Job hook above,
@@ -191,19 +253,20 @@ The same automatic instrumentation, on the same `track_performance` flag, also c
191
253
  `net_http` adapter, HTTParty, RestClient) ultimately call through `Net::HTTP`, so this covers
192
254
  those too as a side effect.
193
255
 
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.
256
+ Every one of these shows up as its own `kind` ("controller", "job", "job_latency", "query", "http")
257
+ on the same `performance` dashboard dataset, so "slowest jobs" and "slowest queries" are just a
258
+ filtered version of the same widget builder "slowest transactions" already uses.
197
259
 
198
- ### Sidekiq, Solid Queue, and Puma gauges
260
+ ### Sidekiq, Solid Queue, Puma, and ActiveRecord gauges
199
261
 
200
262
  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:
263
+ stats (`Sidekiq::Stats`: processed/failed/scheduled/retry/dead counts, plus a queue-depth
264
+ reading per queue), Solid Queue's own state (active workers, active dispatchers, failed/scheduled/
265
+ blocked counts, a queue-depth reading per queue), Puma's own thread pool (backlog, running threads,
266
+ pool capacity), and this process's own ActiveRecord connection pool (size, busy count, waiting
267
+ count, and a derived utilization percent). Reported as ordinary infrastructure metrics
268
+ (`sidekiq.queue_depth.default`, `puma.backlog`, `active_record_pool.utilization_pct`, and so on) via
269
+ the same `capture_infrastructure_metric` call your own scripts use, on a periodic timer:
207
270
 
208
271
  ```ruby
209
272
  ForgeOpsTracker.configure do |config|
@@ -214,6 +277,35 @@ end
214
277
  No separate opt-out: these are gated by the same `track_performance` flag as everything else in
215
278
  this section, not a flag of their own.
216
279
 
280
+ ## Failure tracking
281
+
282
+ On by default, from the same two instrumentation points performance monitoring already uses:
283
+
284
+ - **Background jobs**: a job whose `perform` raised, via the existing `perform.active_job`
285
+ subscription. Not just an aggregate failure count; each occurrence carries the job class, the
286
+ raised exception's class name, and its message.
287
+ - **Outbound `Net::HTTP` calls**: a request that raised, or came back with a 5xx status (a 4xx is
288
+ a response the dependency actually gave, not a failure of the dependency itself).
289
+
290
+ ```ruby
291
+ ForgeOpsTracker.configure do |config|
292
+ config.track_failures = false # opt out entirely
293
+ config.failure_event_flush_interval = 30 # seconds; default 60
294
+ end
295
+ ```
296
+
297
+ **Known, honest gap**: `Net::HTTP.get_response` and the `Net::HTTP.start { |http| ... }` block
298
+ form both open the TCP connection before the block (and so before `#request`, the only method
299
+ this gem wraps) ever runs, so a connection-refused or DNS-failure error raised during that setup
300
+ is invisible here, for the same reason it's already invisible to this gem's own outbound-HTTP
301
+ breadcrumbs and timing. `Net::HTTP.new(host, port).request(...)` (no explicit `#start`) is
302
+ unaffected: confirmed directly that Net::HTTP connects lazily, inside `#request` itself, the first
303
+ time it's called on a not-yet-started instance.
304
+
305
+ Requires a ForgeOps plan that includes performance monitoring, the same plan feature performance
306
+ monitoring itself already requires; on a plan that doesn't, the periodic flushes are simply
307
+ rejected server-side and dropped, exactly like any other delivery failure.
308
+
217
309
  ## Custom metrics
218
310
 
219
311
  Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
@@ -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
@@ -43,6 +43,23 @@ module ForgeOpsTracker
43
43
  post(configuration.infrastructure_metrics_uri, { metrics: entries })
44
44
  end
45
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
+
46
63
  private
47
64
  attr_reader :configuration
48
65
 
@@ -11,8 +11,11 @@ module ForgeOpsTracker
11
11
  attr_accessor :track_sessions, :session_flush_interval
12
12
  attr_accessor :track_performance, :performance_flush_interval
13
13
  attr_accessor :track_current_user
14
+ attr_accessor :track_breadcrumbs, :max_breadcrumbs
15
+ attr_accessor :track_failures, :failure_event_flush_interval
14
16
  attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
15
17
  attr_accessor :gauge_poll_interval
18
+ attr_accessor :track_tracing, :trace_capture_threshold_ms
16
19
 
17
20
  def initialize
18
21
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -58,6 +61,27 @@ module ForgeOpsTracker
58
61
  # ForgeOpsTracker::Middleware::UserContext for how, and ForgeOpsTracker.set_user for the
59
62
  # manual override/fallback when there's no Warden at all or its guess isn't right.
60
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
61
85
  # No track_metrics/track_infrastructure boolean the way track_sessions/track_performance
62
86
  # each have one: those gate automatic instrumentation that's on unless you turn it off;
63
87
  # capture_metric/capture_infrastructure_metric are explicit calls the customer's own code
@@ -71,6 +95,25 @@ module ForgeOpsTracker
71
95
  # instrument this app's own operational data," reused rather than adding a separate boolean
72
96
  # per new automatic instrumentation source this gem grows.
73
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
74
117
  end
75
118
 
76
119
  def api_key
@@ -128,6 +171,24 @@ module ForgeOpsTracker
128
171
  uri
129
172
  end
130
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
+
131
192
  def enabled?
132
193
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
133
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
@@ -18,8 +28,11 @@ module ForgeOpsTracker
18
28
  # hands this class the Rack env, so ForgeOpsTracker::Middleware::UserContext (Warden
19
29
  # auto-detection) and ForgeOpsTracker.set_user (the manual override/fallback) both
20
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)
21
33
  user = Thread.current[:forge_ops_tracker_current_user]
22
- delivery_queue.push(event_builder.build(error, context: context, user: user))
34
+ breadcrumbs = Thread.current[:forge_ops_tracker_breadcrumbs]&.all || []
35
+ delivery_queue.push(event_builder.build(error, context: context, user: user, breadcrumbs: breadcrumbs))
23
36
  nil
24
37
  rescue StandardError => e
25
38
  configuration.logger&.debug { "[ForgeOpsTracker] report failed: #{e.class}: #{e.message}" }
@@ -28,5 +41,14 @@ module ForgeOpsTracker
28
41
 
29
42
  private
30
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
31
53
  end
32
54
  end
@@ -27,7 +27,7 @@ module ForgeOpsTracker
27
27
  @configuration = configuration
28
28
  end
29
29
 
30
- def build(error, context: {}, user: nil)
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,
@@ -41,6 +41,7 @@ module ForgeOpsTracker
41
41
  sdk_name: SDK_NAME
42
42
  }
43
43
  payload[:user] = user if user && !user.empty?
44
+ payload[:breadcrumbs] = breadcrumbs if breadcrumbs && !breadcrumbs.empty?
44
45
  scrub(payload)
45
46
  end
46
47
 
@@ -51,16 +52,22 @@ module ForgeOpsTracker
51
52
  # structured fields this gem or the host app sets deliberately, not free text an exception
52
53
  # or its context could accidentally spill sensitive data into. user specifically is a
53
54
  # deliberate exemption, not an oversight: the server's own PiiScrubber-equivalent would
54
- # otherwise redact the exact email address this field exists to carry.
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.
55
60
  def scrub(payload)
56
61
  return payload unless configuration.scrub_pii
57
62
 
58
- payload.merge(
63
+ scrubbed = payload.merge(
59
64
  message: PiiScrubber.scrub(payload[:message]),
60
65
  backtrace: PiiScrubber.scrub(payload[:backtrace]),
61
66
  context: PiiScrubber.scrub(payload[:context]),
62
67
  tags: PiiScrubber.scrub(payload[:tags])
63
68
  )
69
+ scrubbed[:breadcrumbs] = PiiScrubber.scrub(payload[:breadcrumbs]) if payload.key?(:breadcrumbs)
70
+ scrubbed
64
71
  end
65
72
 
66
73
  def backtrace_frames(error)
@@ -0,0 +1,73 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Collects individual job/dependency failures in-process (see Railtie's own perform.active_job
5
+ # subscription and Integrations::NetHTTP::Timing#request, the two places that actually call
6
+ # #record) and periodically flushes them as one batch, the same shape MetricBuffer already uses
7
+ # for the identical reason: a real failure's own detail (which job, what error) is exactly the
8
+ # kind of thing worth an accurate individual record of, not something to pre-aggregate away the
9
+ # way PerformanceFlusher's own count/sum/max buckets duration.
10
+ class FailureEventBuffer
11
+ def initialize(configuration, client: Client.new(configuration))
12
+ @configuration = configuration
13
+ @client = client
14
+ @mutex = Mutex.new
15
+ @entries = []
16
+ @thread = nil
17
+
18
+ # Flushes whatever's already buffered on a normal process exit, so the last partial window
19
+ # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
20
+ at_exit { flush }
21
+ end
22
+
23
+ def record(kind:, transaction_name:, error_class: nil, error_message: nil)
24
+ ensure_worker_started
25
+
26
+ @mutex.synchronize do
27
+ @entries << {
28
+ kind: kind, transaction_name: transaction_name, error_class: error_class, error_message: error_message,
29
+ environment: configuration.environment.to_s, release: configuration.release,
30
+ occurred_at: Time.now.utc.iso8601
31
+ }
32
+ end
33
+ end
34
+
35
+ # Snapshots and resets the buffered entries, then delivers them as one batch. Same "a failed
36
+ # delivery keeps every entry for the next attempt" contract as MetricBuffer#flush.
37
+ def flush
38
+ snapshot = nil
39
+
40
+ @mutex.synchronize do
41
+ return if @entries.empty?
42
+ snapshot = @entries
43
+ end
44
+
45
+ return unless client.deliver_failure_events(snapshot)
46
+
47
+ @mutex.synchronize { @entries = [] }
48
+ end
49
+
50
+ private
51
+ attr_reader :configuration, :client
52
+
53
+ def ensure_worker_started
54
+ return if @thread&.alive?
55
+
56
+ @mutex.synchronize do
57
+ return if @thread&.alive?
58
+
59
+ @thread = Thread.new { run }
60
+ @thread.abort_on_exception = false
61
+ end
62
+ end
63
+
64
+ def run
65
+ loop do
66
+ sleep configuration.failure_event_flush_interval
67
+ flush
68
+ rescue StandardError => e
69
+ configuration.logger&.debug { "[ForgeOpsTracker] failure event flush thread error: #{e.class}: #{e.message}" }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,32 @@
1
+ module ForgeOpsTracker
2
+ # Buckets a single duration into one of a fixed set of latency-range labels, the building block
3
+ # PerformanceFlusher uses to accumulate an approximate distribution (not just count/sum/max)
4
+ # alongside every [transaction_name, kind] bucket it already tallies. The server merges these
5
+ # counts across matching samples at read time and walks cumulative counts to approximate a
6
+ # percentile, the same "accurate to the bucket width, not exact" trade-off Prometheus's own
7
+ # histogram_quantile makes: this SDK does not store, or need, the raw duration list a true
8
+ # percentile would require.
9
+ #
10
+ # BOUNDARIES_MS is duplicated (not shared via a gem dependency) on the server side, in
11
+ # app/services/histogram_percentile.rb: the gem and the Rails app it reports to are separate
12
+ # deployables, the same "duplicated, not imported, so this package's own dependency graph stays
13
+ # independent" reasoning already applied elsewhere in this repo (e.g. the React Native SDK's own
14
+ # copy of the TypeScript SDK's PII scrubber). Change one, change the other, or a released gem
15
+ # version and the server version it talks to would silently disagree about what each bucket label
16
+ # means.
17
+ module HistogramBucketer
18
+ BOUNDARIES_MS = [ 50, 100, 250, 500, 1000, 2500, 5000, 10_000 ].freeze
19
+
20
+ # Returns the label (a String) of the smallest boundary duration_ms fits under, or "inf" for
21
+ # anything larger than the largest boundary. String, not Integer/Float: this travels as a JSON
22
+ # object key once flushed (see PerformanceFlusher#flush), and JSON object keys are always
23
+ # strings regardless of what Ruby type builds the Hash locally, so returning a String here
24
+ # keeps the in-process Hash's own keys identical to what actually goes over the wire, rather
25
+ # than looking different locally and then silently getting stringified only at serialization
26
+ # time.
27
+ def self.bucket_for(duration_ms)
28
+ boundary = BOUNDARIES_MS.find { |b| duration_ms <= b }
29
+ boundary ? boundary.to_s : "inf"
30
+ end
31
+ end
32
+ end
@@ -10,6 +10,12 @@ module ForgeOpsTracker
10
10
  # background-thread timer still runs for a long-lived process that chooses to call this
11
11
  # continuously instead, but rarely gets the chance to fire in the cron case.
12
12
  class InfrastructureMetricBuffer
13
+ # Once this many entries are buffered, further ones are dropped until a flush succeeds: a plan
14
+ # without the feature answers 403 on every flush, and an uncapped buffer would then grow for as
15
+ # long as the process lives. Dropping the newest rather than the oldest keeps the entries a flush
16
+ # is delivering at the front of the array, which is what makes removing exactly them exact.
17
+ MAX_ENTRIES = 1000
18
+
13
19
  def initialize(configuration, client: Client.new(configuration))
14
20
  @configuration = configuration
15
21
  @client = client
@@ -21,9 +27,13 @@ module ForgeOpsTracker
21
27
  end
22
28
 
23
29
  def record(metric_name:, value:, hostname:)
30
+ return false unless keepable?(value)
31
+
24
32
  ensure_worker_started
25
33
 
26
34
  @mutex.synchronize do
35
+ return false if @entries.size >= MAX_ENTRIES
36
+
27
37
  @entries << { metric_name: metric_name, value: value, hostname: hostname, recorded_at: Time.now.utc.iso8601 }
28
38
  end
29
39
  end
@@ -35,17 +45,24 @@ module ForgeOpsTracker
35
45
 
36
46
  @mutex.synchronize do
37
47
  return if @entries.empty?
38
- snapshot = @entries
48
+ snapshot = @entries.dup
39
49
  end
40
50
 
41
51
  return unless client.deliver_infrastructure_metrics(snapshot)
42
52
 
43
- @mutex.synchronize { @entries = [] }
53
+ # Exactly the entries just delivered: anything recorded while the request was in flight sits
54
+ # after them and stays for the next flush (resetting the whole array here lost it).
55
+ @mutex.synchronize { @entries.shift(snapshot.size) }
44
56
  end
45
57
 
46
58
  private
47
59
  attr_reader :configuration, :client
48
60
 
61
+ # A NaN or infinite value is not valid JSON and would make the server reject the whole batch.
62
+ def keepable?(value)
63
+ value.is_a?(Numeric) && value.to_f.finite?
64
+ end
65
+
49
66
  def ensure_worker_started
50
67
  return if @thread&.alive?
51
68