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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +105 -13
- data/lib/forge_ops_tracker/breadcrumb_buffer.rb +45 -0
- data/lib/forge_ops_tracker/client.rb +17 -0
- data/lib/forge_ops_tracker/configuration.rb +61 -0
- data/lib/forge_ops_tracker/error_subscriber.rb +23 -1
- data/lib/forge_ops_tracker/event_builder.rb +10 -3
- 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 +19 -2
- data/lib/forge_ops_tracker/integrations/active_record_pool.rb +41 -0
- data/lib/forge_ops_tracker/integrations/net_http.rb +85 -3
- data/lib/forge_ops_tracker/integrations/redis_client.rb +62 -0
- data/lib/forge_ops_tracker/metric_buffer.rb +20 -3
- 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/performance_flusher.rb +33 -5
- data/lib/forge_ops_tracker/performance_instrumentation.rb +32 -0
- data/lib/forge_ops_tracker/railtie.rb +160 -7
- 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 +56 -0
- metadata +27 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b32a031e24fb613c219aee3bf01f0517043b05b4d1788d7ece456096d7561a0
|
|
4
|
+
data.tar.gz: 39c7f16920425d01862607b6d3f85b957c694ab009ce8ffd010f33714632a3a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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>@
|
|
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")
|
|
195
|
-
`performance` dashboard dataset, so "slowest jobs" and "slowest queries" are just a
|
|
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
|
|
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
|
|
202
|
-
per queue), Solid Queue's own state (active workers, active dispatchers, failed/scheduled/
|
|
203
|
-
counts, a queue-depth reading per queue),
|
|
204
|
-
pool capacity)
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|