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
|
@@ -2,6 +2,34 @@ require "rails/railtie"
|
|
|
2
2
|
|
|
3
3
|
module ForgeOpsTracker
|
|
4
4
|
class Railtie < ::Rails::Railtie
|
|
5
|
+
# Shared across every performance-timing subscription/integration below (controller, query,
|
|
6
|
+
# job, raw Sidekiq, outbound Net::HTTP): one flusher, one background thread, one set of
|
|
7
|
+
# buckets, since PerformanceFlusher already buckets on [transaction_name, kind] and safely
|
|
8
|
+
# handles concurrent calls from several sources at once. Exposed as a class method, not a
|
|
9
|
+
# local variable inside one initializer, specifically so the Sidekiq/Net::HTTP integrations
|
|
10
|
+
# (each guarded by their own separate initializer, since each only loads if its own optional
|
|
11
|
+
# library is present) can record into the exact same instance rather than each starting a
|
|
12
|
+
# redundant flusher/thread of their own.
|
|
13
|
+
class << self
|
|
14
|
+
def performance_flusher
|
|
15
|
+
@performance_flusher ||= ForgeOpsTracker::PerformanceFlusher.new(ForgeOpsTracker.configuration)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Same reasoning as performance_flusher above, exposed the identical way: shared across the
|
|
19
|
+
# two failure-detecting call sites below (the perform.active_job subscription, Net::HTTP's
|
|
20
|
+
# own Timing#request), one buffer/thread rather than each starting a redundant one.
|
|
21
|
+
def failure_event_buffer
|
|
22
|
+
@failure_event_buffer ||= ForgeOpsTracker::FailureEventBuffer.new(ForgeOpsTracker.configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Same reasoning again, exposed the identical way: shared across every call site that might
|
|
26
|
+
# finish a captured trace (Middleware::SpanTracing today; nothing else yet, but the same
|
|
27
|
+
# "one instance, not one per call site" reasoning applies regardless).
|
|
28
|
+
def span_queue
|
|
29
|
+
@span_queue ||= ForgeOpsTracker::SpanQueue.new(ForgeOpsTracker.configuration)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
5
33
|
initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
|
|
6
34
|
configuration = ForgeOpsTracker.configuration
|
|
7
35
|
configuration.app_root ||= app.root.to_s
|
|
@@ -14,5 +42,249 @@ module ForgeOpsTracker
|
|
|
14
42
|
initializer "forge_ops_tracker.track_sessions" do |app|
|
|
15
43
|
app.middleware.use ForgeOpsTracker::Middleware::SessionTracking, configuration: ForgeOpsTracker.configuration
|
|
16
44
|
end
|
|
45
|
+
|
|
46
|
+
# Warden::Manager (mounted automatically by Devise, or directly by any other Warden-based
|
|
47
|
+
# auth setup) sets env["warden"] itself; this only ever reads it, so it works regardless of
|
|
48
|
+
# exactly where in the middleware stack this ends up relative to Warden::Manager, as long as
|
|
49
|
+
# that key exists in env by the time this middleware's own #call runs, which is true for any
|
|
50
|
+
# Warden-based auth setup: it mounts its own middleware once, for the whole app, long before
|
|
51
|
+
# any individual request begins.
|
|
52
|
+
initializer "forge_ops_tracker.track_current_user" do |app|
|
|
53
|
+
app.middleware.use ForgeOpsTracker::Middleware::UserContext, configuration: ForgeOpsTracker.configuration
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Gives every request its own fresh BreadcrumbBuffer to accumulate into (see
|
|
57
|
+
# ForgeOpsTracker::Middleware::BreadcrumbContext for why this has to be a middleware, the same
|
|
58
|
+
# "Rails.error.subscribe never receives the Rack env" reasoning UserContext's own comment
|
|
59
|
+
# gives): must run before the sql.active_record/process_action.action_controller subscriptions
|
|
60
|
+
# below can have anywhere to record into, but Rack middleware ordering guarantees that already,
|
|
61
|
+
# since this wraps app.call(env), and those notifications only ever fire from *inside* that
|
|
62
|
+
# call.
|
|
63
|
+
initializer "forge_ops_tracker.track_breadcrumbs" do |app|
|
|
64
|
+
app.middleware.use ForgeOpsTracker::Middleware::BreadcrumbContext, configuration: ForgeOpsTracker.configuration
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Same "must run before the notifications below have anywhere to record into" reasoning as
|
|
68
|
+
# track_breadcrumbs's own comment just above, for the exact same structural reason (Rack
|
|
69
|
+
# middleware always wraps the actual controller dispatch those notifications fire from inside
|
|
70
|
+
# of, regardless of this initializer's own position relative to track_breadcrumbs/
|
|
71
|
+
# track_current_user/track_sessions).
|
|
72
|
+
initializer "forge_ops_tracker.track_tracing" do |app|
|
|
73
|
+
app.middleware.use ForgeOpsTracker::Middleware::SpanTracing, configuration: ForgeOpsTracker.configuration
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Rails already fires "process_action.action_controller" after every request with the
|
|
77
|
+
# controller/action and how long it took (event.duration, in ms) built in, so unlike session
|
|
78
|
+
# tracking above (which needed a Rack middleware because there's no single existing hook for
|
|
79
|
+
# "a request finished"), this needs no middleware of its own at all, just a subscriber.
|
|
80
|
+
# Guarded the same way the error subscriber and session-tracking middleware both are: on by
|
|
81
|
+
# default, off entirely if track_performance is turned off or the gem itself isn't enabled
|
|
82
|
+
# (no DSN configured, or the current environment isn't in enabled_environments).
|
|
83
|
+
#
|
|
84
|
+
# sql.active_record/perform.active_job are subscribed here too, not in their own separate
|
|
85
|
+
# initializers: all three (plus Sidekiq/Net::HTTP below) are genuinely the same concern
|
|
86
|
+
# (automatic performance timing), sharing the one performance_flusher above; see
|
|
87
|
+
# PerformanceInstrumentation for the actual transaction_name/skip decisions each one makes,
|
|
88
|
+
# kept out of these blocks specifically so that decision logic is unit-testable without a real
|
|
89
|
+
# Rails/ActiveJob boot (this gem's own test suite doesn't require Rails at all; see that
|
|
90
|
+
# file's own comment).
|
|
91
|
+
#
|
|
92
|
+
# The same two subscriptions also record a breadcrumb, gated on track_breadcrumbs
|
|
93
|
+
# independently of track_performance: an app could want the trail without the timing data, or
|
|
94
|
+
# vice versa, and each already has to check its own flag regardless, so there's no real cost
|
|
95
|
+
# to keeping them independent rather than tying breadcrumbs to whether performance monitoring
|
|
96
|
+
# happens to be on.
|
|
97
|
+
#
|
|
98
|
+
# Every event.time below is wrapped in Time.at(...) before being handed to SpanBuffer#record or
|
|
99
|
+
# job_queue_wait_ms, both of which need a real Time. ActiveSupport::Notifications::Event#time
|
|
100
|
+
# returns a plain Float (seconds since the epoch) on the Rails version this gem currently
|
|
101
|
+
# targets, not a Time; Time.at(...) also passes a real Time straight through unchanged, so this
|
|
102
|
+
# stays correct if a future Rails version goes back to returning one.
|
|
103
|
+
initializer "forge_ops_tracker.track_performance" do
|
|
104
|
+
configuration = ForgeOpsTracker.configuration
|
|
105
|
+
flusher = Railtie.performance_flusher
|
|
106
|
+
|
|
107
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
108
|
+
next unless configuration.enabled?
|
|
109
|
+
|
|
110
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
111
|
+
transaction_name = PerformanceInstrumentation.controller_transaction_name(event.payload)
|
|
112
|
+
|
|
113
|
+
if configuration.track_performance
|
|
114
|
+
flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "controller")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if configuration.track_breadcrumbs
|
|
118
|
+
status = event.payload[:status]
|
|
119
|
+
ForgeOpsTracker.add_breadcrumb(
|
|
120
|
+
"#{event.payload[:method]} #{transaction_name}",
|
|
121
|
+
category: "controller",
|
|
122
|
+
level: status && status >= 500 ? "error" : "info",
|
|
123
|
+
data: { status: status, path: event.payload[:path] }.compact
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Records this request's own root span, not gated on track_performance/track_breadcrumbs
|
|
128
|
+
# above (its own independent configuration.track_tracing flag instead, the same "several
|
|
129
|
+
# genuinely independent mechanisms recorded from the same instrumentation point" pattern
|
|
130
|
+
# this exact call site already established for breadcrumbs): this is the one call in the
|
|
131
|
+
# whole gem that ever passes root: true, since process_action.action_controller is the only
|
|
132
|
+
# instrumentation point that wraps a genuinely whole request. See SpanBuffer#record's own
|
|
133
|
+
# comment for why root: true has to force parent_span_id nil explicitly here rather than
|
|
134
|
+
# reading it off the (already-popped-by-now) stack.
|
|
135
|
+
if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
|
|
136
|
+
buffer.pop
|
|
137
|
+
buffer.record(
|
|
138
|
+
span_id: buffer.root_span_id, name: transaction_name, kind: "controller",
|
|
139
|
+
started_at: Time.at(event.time), duration_ms: event.duration, root: true
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
145
|
+
next unless configuration.enabled?
|
|
146
|
+
|
|
147
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
148
|
+
transaction_name = PerformanceInstrumentation.query_transaction_name(event.payload)
|
|
149
|
+
next if transaction_name.nil? # SCHEMA/cached, see PerformanceInstrumentation's own comment
|
|
150
|
+
|
|
151
|
+
if configuration.track_performance
|
|
152
|
+
flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "query")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if configuration.track_breadcrumbs
|
|
156
|
+
ForgeOpsTracker.add_breadcrumb(transaction_name, category: "query", data: { duration_ms: event.duration.round(1) })
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A leaf span, parented onto whatever's currently open (the controller root, or a
|
|
160
|
+
# manually-wrapped ForgeOpsTracker.span block around it): a query has no children of its
|
|
161
|
+
# own to nest anything under, so unlike the controller root above, this never touches the
|
|
162
|
+
# stack itself, just records against whatever's already on top of it.
|
|
163
|
+
if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
|
|
164
|
+
buffer.record(
|
|
165
|
+
span_id: SecureRandom.hex(8), name: transaction_name, kind: "database",
|
|
166
|
+
started_at: Time.at(event.time), duration_ms: event.duration
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
|
|
172
|
+
# Permissive enough for either concern below to run on its own: track_performance off but
|
|
173
|
+
# track_failures on (or vice versa) still needs this subscription to fire at all.
|
|
174
|
+
next unless (configuration.track_performance || configuration.track_failures) && configuration.enabled?
|
|
175
|
+
|
|
176
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
177
|
+
|
|
178
|
+
if configuration.track_performance
|
|
179
|
+
transaction_name = PerformanceInstrumentation.job_transaction_name(event.payload)
|
|
180
|
+
flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "job")
|
|
181
|
+
|
|
182
|
+
# A second, independent sample from the same notification: how long this job actually
|
|
183
|
+
# waited in its queue before this perform even started, not how long perform itself took
|
|
184
|
+
# (the "job" kind above). Grouped by queue name, not job class, since queue-level backlog
|
|
185
|
+
# is the actionable unit here (a slow queue affects every job class routed through it),
|
|
186
|
+
# the same reason Solid Queue's own StatsReporter already reports queue_depth per queue
|
|
187
|
+
# name rather than per job class. See PerformanceInstrumentation.job_queue_wait_ms's own
|
|
188
|
+
# comment for why this is nil, and skipped, for a job that was never actually enqueued.
|
|
189
|
+
wait_ms = PerformanceInstrumentation.job_queue_wait_ms(event.payload, at: Time.at(event.time))
|
|
190
|
+
if wait_ms
|
|
191
|
+
flusher.record(transaction_name: event.payload[:job].queue_name, duration_ms: wait_ms, kind: "job_latency")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Independent of track_performance above, same reasoning breadcrumbs already established
|
|
196
|
+
# at this exact call site: a job whose perform raised, via
|
|
197
|
+
# PerformanceInstrumentation.job_failure_details' own use of ActiveSupport::Notifications'
|
|
198
|
+
# automatic payload[:exception_object] (see that method's own comment).
|
|
199
|
+
if configuration.track_failures
|
|
200
|
+
failure = PerformanceInstrumentation.job_failure_details(event.payload)
|
|
201
|
+
if failure
|
|
202
|
+
Railtie.failure_event_buffer.record(
|
|
203
|
+
kind: "job", transaction_name: PerformanceInstrumentation.job_transaction_name(event.payload),
|
|
204
|
+
error_class: failure[:error_class], error_message: failure[:error_message]
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Raw (non-ActiveJob) Sidekiq::Worker job timing, plus Sidekiq's own queue-depth/processed/
|
|
212
|
+
# failed/etc. gauges: a separate initializer, since unlike ActiveRecord/ActionController
|
|
213
|
+
# (always present in any Rails app this gem's own gemspec already implies), Sidekiq is
|
|
214
|
+
# genuinely optional and isn't a declared dependency of this gem at all.
|
|
215
|
+
initializer "forge_ops_tracker.track_sidekiq" do
|
|
216
|
+
next unless defined?(::Sidekiq)
|
|
217
|
+
|
|
218
|
+
require "forge_ops_tracker/integrations/sidekiq"
|
|
219
|
+
|
|
220
|
+
configuration = ForgeOpsTracker.configuration
|
|
221
|
+
::Sidekiq.configure_server do |config|
|
|
222
|
+
config.server_middleware do |chain|
|
|
223
|
+
# No extra args to chain.add: Sidekiq calls Middleware.new(*args) itself for every job,
|
|
224
|
+
# splatting whatever was passed here; the middleware's own keyword-argument defaults
|
|
225
|
+
# (ForgeOpsTracker.configuration, Railtie.performance_flusher) already resolve correctly
|
|
226
|
+
# when called with none, and passing a keyword hash through *args here is genuinely
|
|
227
|
+
# ambiguous (Sidekiq's own chain.add doc example passes a literal positional Hash, not
|
|
228
|
+
# keyword syntax, for exactly this reason).
|
|
229
|
+
chain.add ForgeOpsTracker::Integrations::Sidekiq::Middleware
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
ForgeOpsTracker::Integrations::Sidekiq::StatsReporter.start(configuration: configuration)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
initializer "forge_ops_tracker.track_solid_queue" do
|
|
237
|
+
next unless defined?(::SolidQueue)
|
|
238
|
+
|
|
239
|
+
require "forge_ops_tracker/integrations/solid_queue"
|
|
240
|
+
ForgeOpsTracker::Integrations::SolidQueue::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
initializer "forge_ops_tracker.track_puma" do
|
|
244
|
+
next unless defined?(::Puma)
|
|
245
|
+
|
|
246
|
+
require "forge_ops_tracker/integrations/puma"
|
|
247
|
+
ForgeOpsTracker::Integrations::Puma::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Same defined? guard as track_sidekiq/track_solid_queue/track_puma above, not
|
|
251
|
+
# track_net_http's unconditional one: ActiveRecord is neither Ruby stdlib nor a declared
|
|
252
|
+
# dependency of this gem itself (this gem has none at all beyond Rails::Railtie being
|
|
253
|
+
# defined; see the gemspec's own comment on why even Sidekiq/Puma are dev-only dependencies),
|
|
254
|
+
# so an API-only Rails app with ActiveRecord genuinely removed is a real, if uncommon, case to
|
|
255
|
+
# guard against rather than assume away.
|
|
256
|
+
initializer "forge_ops_tracker.track_active_record_pool" do
|
|
257
|
+
next unless defined?(::ActiveRecord::Base)
|
|
258
|
+
|
|
259
|
+
require "forge_ops_tracker/integrations/active_record_pool"
|
|
260
|
+
ForgeOpsTracker::Integrations::ActiveRecordPool::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Net::HTTP is always present (Ruby's own standard library), so this always prepends; the
|
|
264
|
+
# actual on/off decision happens at call time inside Timing#request itself (checking
|
|
265
|
+
# configuration.track_performance/enabled? the same way the subscriptions above do), so
|
|
266
|
+
# toggling that flag after boot still takes effect immediately, the same as every other
|
|
267
|
+
# automatic instrumentation point in this gem.
|
|
268
|
+
initializer "forge_ops_tracker.track_net_http" do
|
|
269
|
+
require "forge_ops_tracker/integrations/net_http"
|
|
270
|
+
Net::HTTP.prepend(ForgeOpsTracker::Integrations::NetHTTP::Timing)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# defined?(::RedisClient) guarded, the same way track_sidekiq/track_solid_queue/track_puma
|
|
274
|
+
# above are: RedisClient is neither Ruby stdlib nor a declared dependency of this gem (see the
|
|
275
|
+
# gemspec's own comment on why even Sidekiq/Puma are dev-only dependencies), so a host app with
|
|
276
|
+
# no Redis at all is a real case to guard against, not assume away. Runs after Rails itself has
|
|
277
|
+
# finished booting (config.after_initialize, not a plain initializer like track_net_http
|
|
278
|
+
# above): unlike Net::HTTP (Ruby stdlib, always loaded), whether RedisClient is defined yet at
|
|
279
|
+
# plain initializer time depends on load order relative to whatever gem requires it (redis,
|
|
280
|
+
# sidekiq, ActiveJob's redis queue adapter, ...), which this gem has no control over; deferring
|
|
281
|
+
# the defined? check to after every railtie (including this app's own dependencies) has
|
|
282
|
+
# finished initializing avoids a load-order-dependent false negative.
|
|
283
|
+
config.after_initialize do
|
|
284
|
+
next unless defined?(::RedisClient)
|
|
285
|
+
|
|
286
|
+
require "forge_ops_tracker/integrations/redis_client"
|
|
287
|
+
::RedisClient.prepend(ForgeOpsTracker::Integrations::RedisClientTiming)
|
|
288
|
+
end
|
|
17
289
|
end
|
|
18
290
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Accumulates one request's own nested call tree in-process; the tracing analog to
|
|
5
|
+
# BreadcrumbBuffer's per-thread trail. ForgeOpsTracker::Middleware::SpanTracing resets this to a
|
|
6
|
+
# fresh buffer at the start of every request, with a freshly generated trace_id and root
|
|
7
|
+
# span_id, and reads it back once the request is over to decide whether this trace is worth
|
|
8
|
+
# sending at all (see that middleware's own comment for exactly where and why).
|
|
9
|
+
#
|
|
10
|
+
# The stack exists so nesting can be reconstructed correctly even though most of what's recorded
|
|
11
|
+
# here (a query, an outbound HTTP call, a Redis call) only has a start+duration known *after* the
|
|
12
|
+
# fact, not a real "this span is now open" moment the way a manually-wrapped ForgeOpsTracker.span
|
|
13
|
+
# block has. Whatever's on top of the stack at the moment a leaf span is recorded is that leaf's
|
|
14
|
+
# real parent: the nearest currently-open span, whether that's the request's own root, or a
|
|
15
|
+
# service-level span a customer wrapped their own code in. The root span_id is pushed onto the
|
|
16
|
+
# stack immediately, at construction, specifically so every span recorded before the controller
|
|
17
|
+
# action itself finishes (which is every one of them, since process_action.action_controller's
|
|
18
|
+
# own subscription only fires once the whole action, including everything nested inside it, has
|
|
19
|
+
# already run) naturally nests under it without the root having to exist as a real recorded span
|
|
20
|
+
# first.
|
|
21
|
+
class SpanBuffer
|
|
22
|
+
attr_reader :trace_id, :root_span_id, :spans
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@trace_id = SecureRandom.hex(16)
|
|
26
|
+
@root_span_id = SecureRandom.hex(8)
|
|
27
|
+
@spans = []
|
|
28
|
+
@stack = [ @root_span_id ]
|
|
29
|
+
@root_duration_ms = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_parent_id
|
|
33
|
+
@stack.last
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def push(span_id)
|
|
37
|
+
@stack.push(span_id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pop
|
|
41
|
+
@stack.pop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# root: true only for the one call that records the request's own root span (see Railtie's
|
|
45
|
+
# process_action.action_controller subscription): forces parent_span_id nil explicitly, since
|
|
46
|
+
# by the time that fires, the root's own span_id has already been popped off the stack (there's
|
|
47
|
+
# nothing left on it to read back as "the parent" at that point - correctly, since the root has
|
|
48
|
+
# no parent of its own).
|
|
49
|
+
def record(span_id:, name:, kind:, started_at:, duration_ms:, data: {}, root: false)
|
|
50
|
+
parent_span_id = root ? nil : current_parent_id
|
|
51
|
+
spans << {
|
|
52
|
+
span_id: span_id, parent_span_id: parent_span_id, name: name, kind: kind,
|
|
53
|
+
started_at: started_at.utc.iso8601(3), duration_ms: duration_ms, data: data
|
|
54
|
+
}
|
|
55
|
+
@root_duration_ms = duration_ms if root
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# nil (never sent) until the root span has actually been recorded; a request that somehow never
|
|
59
|
+
# fires process_action.action_controller at all (a request Rails itself never routes) has no
|
|
60
|
+
# duration to compare against a threshold, so it's never mistakenly treated as "slow."
|
|
61
|
+
def slow?(threshold_ms)
|
|
62
|
+
@root_duration_ms && @root_duration_ms >= threshold_ms
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Same shape and reasoning as DeliveryQueue: a small in-process background thread + bounded
|
|
5
|
+
# queue, so delivering a captured trace never adds latency to the very request it was just
|
|
6
|
+
# measuring - the one thing that would be actively counterproductive here, blocking an already-
|
|
7
|
+
# slow request even longer just to report that it was slow. One whole trace (a trace_id plus
|
|
8
|
+
# every span belonging to it) is one queue entry, delivered as one client call, not batched
|
|
9
|
+
# together with other traces the way PerformanceFlusher/FailureEventBuffer batch over a time
|
|
10
|
+
# window: a trace is already a complete, immediately-relevant unit the moment a request
|
|
11
|
+
# finishes, so it's delivered promptly rather than held for up to a full flush interval.
|
|
12
|
+
class SpanQueue
|
|
13
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@client = client
|
|
16
|
+
@queue = SizedQueue.new(configuration.queue_size)
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@thread = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Dropping a whole trace silently (never blocking the caller) if the queue is already full,
|
|
22
|
+
# the same backpressure-must-never-reach-the-host-app posture DeliveryQueue#push already
|
|
23
|
+
# documents; a burst of several slow requests at once must never stall a request that's
|
|
24
|
+
# already been identified as slow even further.
|
|
25
|
+
def push(trace_id:, spans:)
|
|
26
|
+
ensure_worker_started
|
|
27
|
+
@queue.push({ trace_id: trace_id, spans: spans }, true)
|
|
28
|
+
true
|
|
29
|
+
rescue ThreadError
|
|
30
|
+
log { "span delivery queue full, dropping trace" }
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
attr_reader :configuration, :client
|
|
36
|
+
|
|
37
|
+
def ensure_worker_started
|
|
38
|
+
return if @thread&.alive?
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @thread&.alive?
|
|
42
|
+
|
|
43
|
+
@thread = Thread.new { run }
|
|
44
|
+
@thread.abort_on_exception = false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run
|
|
49
|
+
loop do
|
|
50
|
+
entry = @queue.pop
|
|
51
|
+
client.deliver_spans(trace_id: entry[:trace_id], spans: entry[:spans])
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
log { "span delivery thread error: #{e.class}: #{e.message}" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def log
|
|
58
|
+
configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "securerandom"
|
|
1
2
|
require "forge_ops_tracker/version"
|
|
2
3
|
require "forge_ops_tracker/configuration"
|
|
3
4
|
require "forge_ops_tracker/pii_scrubber"
|
|
@@ -6,7 +7,20 @@ require "forge_ops_tracker/client"
|
|
|
6
7
|
require "forge_ops_tracker/delivery_queue"
|
|
7
8
|
require "forge_ops_tracker/error_subscriber"
|
|
8
9
|
require "forge_ops_tracker/session_flusher"
|
|
10
|
+
require "forge_ops_tracker/histogram_bucketer"
|
|
11
|
+
require "forge_ops_tracker/performance_flusher"
|
|
12
|
+
require "forge_ops_tracker/performance_instrumentation"
|
|
13
|
+
require "forge_ops_tracker/metric_buffer"
|
|
14
|
+
require "forge_ops_tracker/infrastructure_metric_buffer"
|
|
15
|
+
require "forge_ops_tracker/failure_event_buffer"
|
|
16
|
+
require "forge_ops_tracker/periodic_poller"
|
|
17
|
+
require "forge_ops_tracker/breadcrumb_buffer"
|
|
18
|
+
require "forge_ops_tracker/span_buffer"
|
|
19
|
+
require "forge_ops_tracker/span_queue"
|
|
9
20
|
require "forge_ops_tracker/middleware/session_tracking"
|
|
21
|
+
require "forge_ops_tracker/middleware/user_context"
|
|
22
|
+
require "forge_ops_tracker/middleware/breadcrumb_context"
|
|
23
|
+
require "forge_ops_tracker/middleware/span_tracing"
|
|
10
24
|
|
|
11
25
|
module ForgeOpsTracker
|
|
12
26
|
class << self
|
|
@@ -17,6 +31,98 @@ module ForgeOpsTracker
|
|
|
17
31
|
def configure
|
|
18
32
|
yield configuration
|
|
19
33
|
end
|
|
34
|
+
|
|
35
|
+
# Records a named business metric (a signup, a payment, anything a customer wants to name),
|
|
36
|
+
# buffered and flushed periodically as a batch rather than one network call per capture (see
|
|
37
|
+
# MetricBuffer). value defaults to 1.0 so a bare counter-style call ("a signup happened")
|
|
38
|
+
# needs no argument; pass an explicit one for a metric with a real magnitude ("a $49 payment
|
|
39
|
+
# happened"). A no-op, same as every other capture path in this gem, when the gem isn't
|
|
40
|
+
# enabled (no DSN configured, or the current environment isn't in enabled_environments).
|
|
41
|
+
def capture_metric(name, value: 1.0)
|
|
42
|
+
return unless configuration.enabled?
|
|
43
|
+
|
|
44
|
+
metric_buffer.record(metric_name: name, value: value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Records one infrastructure reading (CPU/memory/disk, or anything else a customer's own
|
|
48
|
+
# script reads) from one of their own hosts. hostname defaults to Configuration#server_name
|
|
49
|
+
# (already derived from Socket.gethostname), so a script running on the box it's reporting
|
|
50
|
+
# about doesn't need to pass one explicitly. Same buffered-batch delivery and no-op-when-
|
|
51
|
+
# disabled contract as capture_metric above.
|
|
52
|
+
def capture_infrastructure_metric(name, value:, hostname: nil)
|
|
53
|
+
return unless configuration.enabled?
|
|
54
|
+
|
|
55
|
+
infrastructure_metric_buffer.record(metric_name: name, value: value, hostname: hostname || configuration.server_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Manually attaches an affected user to whatever gets reported for the rest of this request
|
|
59
|
+
# (or, outside a request entirely, in a background job or a console session, for the rest of
|
|
60
|
+
# this thread). Sets the same thread-local ForgeOpsTracker::Middleware::UserContext's own
|
|
61
|
+
# Warden auto-detection uses, so this composes with it rather than being a second, separate
|
|
62
|
+
# mechanism: call this after that middleware ran to override its guess, or call it standalone
|
|
63
|
+
# in an app with no Warden at all. id/email/username are all independently optional; a call
|
|
64
|
+
# with none of them (or all nil) clears whatever was set.
|
|
65
|
+
def set_user(id: nil, email: nil, username: nil)
|
|
66
|
+
user = { id: id, email: email, username: username }.compact
|
|
67
|
+
Thread.current[:forge_ops_tracker_current_user] = user.empty? ? nil : user
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Adds one breadcrumb to the current request's trail (or, outside a request entirely, a
|
|
71
|
+
# background job or console session's own trail for the rest of this thread: see
|
|
72
|
+
# ForgeOpsTracker::Middleware::BreadcrumbContext for how a request gets its own buffer to start
|
|
73
|
+
# with). Lazily creates a buffer on this thread if BreadcrumbContext never ran (Sidekiq/Solid
|
|
74
|
+
# Queue jobs don't go through Rack middleware at all, an IRB/rails console session even less
|
|
75
|
+
# so), the same "works standalone, no specific setup required" shape ForgeOpsTracker.set_user
|
|
76
|
+
# already has. Works whether or not track_breadcrumbs is on: that flag only gates the automatic
|
|
77
|
+
# sql.active_record/process_action.action_controller/Net::HTTP sources Railtie installs, never
|
|
78
|
+
# this manual call.
|
|
79
|
+
def add_breadcrumb(message, category: "custom", level: "info", data: {})
|
|
80
|
+
buffer = (Thread.current[:forge_ops_tracker_breadcrumbs] ||= BreadcrumbBuffer.new(configuration))
|
|
81
|
+
buffer.add(category: category, message: message, level: level, data: data)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Wraps a customer's own service-level code as one named, nested span in the current request's
|
|
85
|
+
# trace (e.g. `ForgeOpsTracker.span("PaymentService#charge") { ... }`), so its database/Redis/
|
|
86
|
+
# outbound-HTTP calls show up nested underneath it in the waterfall rather than appearing to
|
|
87
|
+
# hang directly off the controller root - there's no automatic way to detect "this is a
|
|
88
|
+
# logically distinct service layer" the way a SQL query or an HTTP request already has a real
|
|
89
|
+
# instrumentation hook to detect, so this is deliberately explicit, opt-in, by hand, unlike
|
|
90
|
+
# every other span kind this gem records automatically.
|
|
91
|
+
#
|
|
92
|
+
# A plain pass-through (still runs the block, records nothing) with no error and no special
|
|
93
|
+
# handling whenever there's no current request trace to nest under at all: outside a request
|
|
94
|
+
# entirely (a background job, a console session), when track_tracing is off, or when the gem
|
|
95
|
+
# isn't enabled - the same "never break the host app's own code path" posture every other
|
|
96
|
+
# capture method in this gem already takes. Unlike add_breadcrumb, this does NOT lazily create
|
|
97
|
+
# a standalone buffer for itself: a lone span with no request-level trace around it, and no
|
|
98
|
+
# middleware left running to ever flush it, would just accumulate in this thread's memory
|
|
99
|
+
# forever with nothing to ever send it, which is worse than never recording it at all.
|
|
100
|
+
def span(name, kind: "service", data: {})
|
|
101
|
+
return yield unless configuration.enabled? && configuration.track_tracing
|
|
102
|
+
|
|
103
|
+
buffer = Thread.current[:forge_ops_tracker_spans]
|
|
104
|
+
return yield unless buffer
|
|
105
|
+
|
|
106
|
+
span_id = SecureRandom.hex(8)
|
|
107
|
+
started_at = Time.now.utc
|
|
108
|
+
buffer.push(span_id)
|
|
109
|
+
begin
|
|
110
|
+
yield
|
|
111
|
+
ensure
|
|
112
|
+
buffer.pop
|
|
113
|
+
duration_ms = (Time.now.utc - started_at) * 1000.0
|
|
114
|
+
buffer.record(span_id: span_id, name: name, kind: kind, started_at: started_at, duration_ms: duration_ms, data: data)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
def metric_buffer
|
|
120
|
+
@metric_buffer ||= MetricBuffer.new(configuration)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def infrastructure_metric_buffer
|
|
124
|
+
@infrastructure_metric_buffer ||= InfrastructureMetricBuffer.new(configuration)
|
|
125
|
+
end
|
|
20
126
|
end
|
|
21
127
|
end
|
|
22
128
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forge_ops_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ForgeOps
|
|
@@ -23,6 +23,62 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '3.13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sidekiq
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: puma
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: webrick
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.8'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.8'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: redis-client
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.30'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.30'
|
|
26
82
|
description: Hooks Rails' error reporter and reports exceptions to a ForgeOps exception
|
|
27
83
|
tracker instance over HTTP, without ever raising back into the host application.
|
|
28
84
|
executables: []
|
|
@@ -33,15 +89,34 @@ files:
|
|
|
33
89
|
- LICENSE.txt
|
|
34
90
|
- README.md
|
|
35
91
|
- lib/forge_ops_tracker.rb
|
|
92
|
+
- lib/forge_ops_tracker/breadcrumb_buffer.rb
|
|
36
93
|
- lib/forge_ops_tracker/client.rb
|
|
37
94
|
- lib/forge_ops_tracker/configuration.rb
|
|
38
95
|
- lib/forge_ops_tracker/delivery_queue.rb
|
|
39
96
|
- lib/forge_ops_tracker/error_subscriber.rb
|
|
40
97
|
- lib/forge_ops_tracker/event_builder.rb
|
|
98
|
+
- lib/forge_ops_tracker/failure_event_buffer.rb
|
|
99
|
+
- lib/forge_ops_tracker/histogram_bucketer.rb
|
|
100
|
+
- lib/forge_ops_tracker/infrastructure_metric_buffer.rb
|
|
101
|
+
- lib/forge_ops_tracker/integrations/active_record_pool.rb
|
|
102
|
+
- lib/forge_ops_tracker/integrations/net_http.rb
|
|
103
|
+
- lib/forge_ops_tracker/integrations/puma.rb
|
|
104
|
+
- lib/forge_ops_tracker/integrations/redis_client.rb
|
|
105
|
+
- lib/forge_ops_tracker/integrations/sidekiq.rb
|
|
106
|
+
- lib/forge_ops_tracker/integrations/solid_queue.rb
|
|
107
|
+
- lib/forge_ops_tracker/metric_buffer.rb
|
|
108
|
+
- lib/forge_ops_tracker/middleware/breadcrumb_context.rb
|
|
41
109
|
- lib/forge_ops_tracker/middleware/session_tracking.rb
|
|
110
|
+
- lib/forge_ops_tracker/middleware/span_tracing.rb
|
|
111
|
+
- lib/forge_ops_tracker/middleware/user_context.rb
|
|
112
|
+
- lib/forge_ops_tracker/performance_flusher.rb
|
|
113
|
+
- lib/forge_ops_tracker/performance_instrumentation.rb
|
|
114
|
+
- lib/forge_ops_tracker/periodic_poller.rb
|
|
42
115
|
- lib/forge_ops_tracker/pii_scrubber.rb
|
|
43
116
|
- lib/forge_ops_tracker/railtie.rb
|
|
44
117
|
- lib/forge_ops_tracker/session_flusher.rb
|
|
118
|
+
- lib/forge_ops_tracker/span_buffer.rb
|
|
119
|
+
- lib/forge_ops_tracker/span_queue.rb
|
|
45
120
|
- lib/forge_ops_tracker/version.rb
|
|
46
121
|
homepage: https://getforgeops.net
|
|
47
122
|
licenses:
|