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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +214 -1
- data/lib/forge_ops_tracker/breadcrumb_buffer.rb +45 -0
- data/lib/forge_ops_tracker/client.rb +37 -0
- data/lib/forge_ops_tracker/configuration.rb +117 -0
- data/lib/forge_ops_tracker/error_subscriber.rb +28 -1
- data/lib/forge_ops_tracker/event_builder.rb +15 -6
- data/lib/forge_ops_tracker/failure_event_buffer.rb +73 -0
- data/lib/forge_ops_tracker/histogram_bucketer.rb +32 -0
- data/lib/forge_ops_tracker/infrastructure_metric_buffer.rb +86 -0
- data/lib/forge_ops_tracker/integrations/active_record_pool.rb +41 -0
- data/lib/forge_ops_tracker/integrations/net_http.rb +114 -0
- data/lib/forge_ops_tracker/integrations/puma.rb +55 -0
- data/lib/forge_ops_tracker/integrations/redis_client.rb +62 -0
- data/lib/forge_ops_tracker/integrations/sidekiq.rb +69 -0
- data/lib/forge_ops_tracker/integrations/solid_queue.rb +37 -0
- data/lib/forge_ops_tracker/metric_buffer.rb +97 -0
- data/lib/forge_ops_tracker/middleware/breadcrumb_context.rb +35 -0
- data/lib/forge_ops_tracker/middleware/span_tracing.rb +38 -0
- data/lib/forge_ops_tracker/middleware/user_context.rb +52 -0
- data/lib/forge_ops_tracker/performance_flusher.rb +129 -0
- data/lib/forge_ops_tracker/performance_instrumentation.rb +92 -0
- data/lib/forge_ops_tracker/periodic_poller.rb +50 -0
- data/lib/forge_ops_tracker/railtie.rb +272 -0
- data/lib/forge_ops_tracker/span_buffer.rb +65 -0
- data/lib/forge_ops_tracker/span_queue.rb +61 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +106 -0
- metadata +76 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8366b59c60be4a6766a7d7775e9eaeed1e184645e620351e81aa97bc88c0cacd
|
|
4
|
+
data.tar.gz: a971669ad792a6893bdcddb577050883cb3d8f175778d817f02174c9428b5acc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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)
|