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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Collects individual ForgeOpsTracker.capture_metric calls in-process and periodically flushes
|
|
5
|
+
# them as one batch, rather than one network call per capture: the same "lazily start a
|
|
6
|
+
# background thread on first use" pattern SessionFlusher/PerformanceFlusher already use, so each
|
|
7
|
+
# forked Puma/Passenger worker gets its own fresh thread instead of inheriting a dead one across
|
|
8
|
+
# fork. Unlike those two, this collects a *list* of individually-meaningful entries rather than
|
|
9
|
+
# summing them into buckets: a customer's own signup or payment is exactly the kind of thing
|
|
10
|
+
# they'll want a genuinely accurate count/sum of later, not something to pre-aggregate away
|
|
11
|
+
# client-side, so CustomMetricsController stores one row per entry as-is.
|
|
12
|
+
class MetricBuffer
|
|
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
|
+
|
|
19
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
@client = client
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@entries = []
|
|
24
|
+
@thread = nil
|
|
25
|
+
|
|
26
|
+
# Flushes whatever's already buffered on a normal process exit, so the last partial window
|
|
27
|
+
# isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
|
|
28
|
+
at_exit { flush }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def record(metric_name:, value:)
|
|
32
|
+
return false unless keepable?(value)
|
|
33
|
+
|
|
34
|
+
ensure_worker_started
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return false if @entries.size >= MAX_ENTRIES
|
|
38
|
+
|
|
39
|
+
@entries << {
|
|
40
|
+
metric_name: metric_name, value: value,
|
|
41
|
+
environment: configuration.environment.to_s, release: configuration.release,
|
|
42
|
+
recorded_at: Time.now.utc.iso8601
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Snapshots and resets the buffered entries, then delivers them as one batch. A failed
|
|
48
|
+
# delivery keeps every entry where it is rather than resetting, so the next flush's batch
|
|
49
|
+
# just grows instead of losing what was already buffered; same reasoning SessionFlusher#flush
|
|
50
|
+
# already documents, including the same small, accepted race (an entry recorded during the
|
|
51
|
+
# in-flight HTTP request gets folded into this snapshot's own reset rather than kept for the
|
|
52
|
+
# next one) that class already lives with, for the same reason: simple and consistent beats a
|
|
53
|
+
# cleverer dedup that would only trade one rare edge case for a subtler one.
|
|
54
|
+
def flush
|
|
55
|
+
snapshot = nil
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
return if @entries.empty?
|
|
59
|
+
snapshot = @entries.dup
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return unless client.deliver_metrics(snapshot)
|
|
63
|
+
|
|
64
|
+
# Exactly the entries just delivered: anything recorded while the request was in flight sits
|
|
65
|
+
# after them and stays for the next flush (resetting the whole array here lost it).
|
|
66
|
+
@mutex.synchronize { @entries.shift(snapshot.size) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
attr_reader :configuration, :client
|
|
71
|
+
|
|
72
|
+
# A NaN or infinite value is not valid JSON and would make the server reject the whole batch.
|
|
73
|
+
def keepable?(value)
|
|
74
|
+
value.is_a?(Numeric) && value.to_f.finite?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ensure_worker_started
|
|
78
|
+
return if @thread&.alive?
|
|
79
|
+
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
return if @thread&.alive?
|
|
82
|
+
|
|
83
|
+
@thread = Thread.new { run }
|
|
84
|
+
@thread.abort_on_exception = false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run
|
|
89
|
+
loop do
|
|
90
|
+
sleep configuration.metric_flush_interval
|
|
91
|
+
flush
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
configuration.logger&.debug { "[ForgeOpsTracker] metric flush thread error: #{e.class}: #{e.message}" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Middleware
|
|
3
|
+
# Gives every request a fresh, empty BreadcrumbBuffer to accumulate into (via
|
|
4
|
+
# ForgeOpsTracker.add_breadcrumb, or the automatic sql.active_record/process_action.
|
|
5
|
+
# action_controller/Net::HTTP subscriptions Railtie installs), so one request's trail never
|
|
6
|
+
# bleeds into another's, then hands ErrorSubscriber#report a way to read it back the exact same
|
|
7
|
+
# way ForgeOpsTracker::Middleware::UserContext's own thread-local does for the current user
|
|
8
|
+
# (Rails.error.subscribe's own #report interface never receives the Rack env, so this
|
|
9
|
+
# thread-local is the only channel available). A sibling to SessionTracking/UserContext, not
|
|
10
|
+
# folded into either: distinct concern, same "own file, own class" shape every middleware in
|
|
11
|
+
# this gem already takes.
|
|
12
|
+
#
|
|
13
|
+
# The ensure-clear is load-bearing, not optional, same reasoning UserContext's own doc
|
|
14
|
+
# comment gives: Puma reuses threads across requests, so leaving a buffer set would leak one
|
|
15
|
+
# request's breadcrumbs into a later, unrelated request handled on the same thread.
|
|
16
|
+
class BreadcrumbContext
|
|
17
|
+
def initialize(app, configuration: ForgeOpsTracker.configuration)
|
|
18
|
+
@app = app
|
|
19
|
+
@configuration = configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(env)
|
|
23
|
+
return app.call(env) unless configuration.track_breadcrumbs && configuration.enabled?
|
|
24
|
+
|
|
25
|
+
Thread.current[:forge_ops_tracker_breadcrumbs] = BreadcrumbBuffer.new(configuration)
|
|
26
|
+
app.call(env)
|
|
27
|
+
ensure
|
|
28
|
+
Thread.current[:forge_ops_tracker_breadcrumbs] = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
attr_reader :app, :configuration
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Middleware
|
|
3
|
+
# Gives every request its own fresh SpanBuffer (see that class for the stack/nesting
|
|
4
|
+
# mechanics), the same "Thread.current, cleared in an ensure" shape BreadcrumbContext already
|
|
5
|
+
# uses and for the identical reason: Puma reuses threads across requests, so a buffer left set
|
|
6
|
+
# would leak one request's spans into a later, unrelated one handled on the same thread.
|
|
7
|
+
#
|
|
8
|
+
# The actual send-or-don't decision lives here, not in SpanBuffer or SpanQueue: only once
|
|
9
|
+
# app.call returns does this middleware know the root span's own final duration (recorded by
|
|
10
|
+
# Railtie's process_action.action_controller subscription, which fires from *inside* that
|
|
11
|
+
# call, after every nested query/HTTP/Redis call has already run), so this is the first point
|
|
12
|
+
# that can compare it against configuration.trace_capture_threshold_ms and decide the trace was
|
|
13
|
+
# never worth sending in the first place - the entire reason a normal, fast request never costs
|
|
14
|
+
# a single byte over the wire, unlike PerformanceSample's own always-on aggregate reporting.
|
|
15
|
+
class SpanTracing
|
|
16
|
+
def initialize(app, configuration: ForgeOpsTracker.configuration)
|
|
17
|
+
@app = app
|
|
18
|
+
@configuration = configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
return app.call(env) unless configuration.track_tracing && configuration.enabled?
|
|
23
|
+
|
|
24
|
+
buffer = SpanBuffer.new
|
|
25
|
+
Thread.current[:forge_ops_tracker_spans] = buffer
|
|
26
|
+
app.call(env)
|
|
27
|
+
ensure
|
|
28
|
+
if buffer&.slow?(configuration.trace_capture_threshold_ms)
|
|
29
|
+
Railtie.span_queue.push(trace_id: buffer.trace_id, spans: buffer.spans)
|
|
30
|
+
end
|
|
31
|
+
Thread.current[:forge_ops_tracker_spans] = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
attr_reader :app, :configuration
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Middleware
|
|
3
|
+
# Wraps every request: if Warden is mounted (env["warden"] present, whether or not the app
|
|
4
|
+
# uses Devise specifically, since Devise just mounts Warden automatically, but any Warden-
|
|
5
|
+
# based auth setup works here, confirmed directly against the installed warden gem's own
|
|
6
|
+
# Warden::Manager/Warden::Proxy source) and it has a signed-in user, stashes a serialized
|
|
7
|
+
# version of that user in a thread-local for the duration of this request so
|
|
8
|
+
# ErrorSubscriber#report (which never receives the Rack env at all: Rails.error.subscribe's
|
|
9
|
+
# own interface hands it error/context/severity, nothing request-shaped) can read it back and
|
|
10
|
+
# attach it to whatever gets reported. A sibling to SessionTracking, not folded into it:
|
|
11
|
+
# distinct concern, same "own file, own class" shape every middleware in this gem already
|
|
12
|
+
# takes.
|
|
13
|
+
#
|
|
14
|
+
# The ensure-clear is load-bearing, not optional: Puma reuses threads across requests, so
|
|
15
|
+
# leaving this set would leak one request's user into a later, unrelated request handled on
|
|
16
|
+
# the same thread.
|
|
17
|
+
class UserContext
|
|
18
|
+
def initialize(app, configuration: ForgeOpsTracker.configuration)
|
|
19
|
+
@app = app
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(env)
|
|
24
|
+
return app.call(env) unless configuration.track_current_user && configuration.enabled?
|
|
25
|
+
|
|
26
|
+
if env.key?("warden") && (user = env["warden"].user)
|
|
27
|
+
serialized = serialize(user)
|
|
28
|
+
Thread.current[:forge_ops_tracker_current_user] = serialized unless serialized.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
app.call(env)
|
|
32
|
+
ensure
|
|
33
|
+
Thread.current[:forge_ops_tracker_current_user] = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
attr_reader :app, :configuration
|
|
38
|
+
|
|
39
|
+
# Duck-types rather than assuming a Devise-shaped model: id if the object responds to it,
|
|
40
|
+
# email if it responds to it, username else name if either responds to it. A custom
|
|
41
|
+
# Warden setup with a differently-shaped user object just gets whatever subset applies
|
|
42
|
+
# here; ForgeOpsTracker.set_user covers anything this can't infer.
|
|
43
|
+
def serialize(user)
|
|
44
|
+
{
|
|
45
|
+
id: (user.id if user.respond_to?(:id)),
|
|
46
|
+
email: (user.email if user.respond_to?(:email)),
|
|
47
|
+
username: (user.username if user.respond_to?(:username)) || (user.name if user.respond_to?(:name))
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Times every request/job/query/outbound call in-process, bucketed by [transaction_name, kind]
|
|
5
|
+
# (see Railtie's several ActiveSupport::Notifications subscriptions, each a different kind), and
|
|
6
|
+
# periodically flushes each distinct bucket as one small aggregate report, rather than one
|
|
7
|
+
# network call per event. One shared flusher for every kind, not one per kind: the aggregation
|
|
8
|
+
# shape (count + duration sum + max over a period) is identical regardless of what's actually
|
|
9
|
+
# being timed. Same "lazily start a background thread on first use, not at load time" pattern
|
|
10
|
+
# SessionFlusher already uses, so each forked Puma/Passenger worker gets its own fresh thread
|
|
11
|
+
# instead of inheriting a dead one across fork.
|
|
12
|
+
class PerformanceFlusher
|
|
13
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@client = client
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@buckets = {}
|
|
18
|
+
@period_started_at = Time.now.utc
|
|
19
|
+
@thread = nil
|
|
20
|
+
|
|
21
|
+
# Flushes whatever's already tallied on a normal process exit, so the last partial window
|
|
22
|
+
# (anything shorter than a full performance_flush_interval) isn't silently dropped; same
|
|
23
|
+
# reasoning SessionFlusher's own at_exit hook documents.
|
|
24
|
+
at_exit { flush }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# kind ("controller"/"job"/"query"/"http", see Railtie's own subscriptions) is part of the
|
|
28
|
+
# bucket key alongside transaction_name, not folded into transaction_name itself: a job and a
|
|
29
|
+
# controller action (or, less likely but still real, a query and a job) can share a name
|
|
30
|
+
# without colliding into the same bucket.
|
|
31
|
+
def record(transaction_name:, duration_ms:, kind: "controller")
|
|
32
|
+
ensure_worker_started
|
|
33
|
+
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0, histogram: Hash.new(0) })
|
|
36
|
+
bucket[:count] += 1
|
|
37
|
+
bucket[:duration_sum_ms] += duration_ms
|
|
38
|
+
bucket[:max_duration_ms] = duration_ms if duration_ms > bucket[:max_duration_ms]
|
|
39
|
+
# The distribution count/sum/max above can't reconstruct: see HistogramBucketer's own
|
|
40
|
+
# comment for why an approximate percentile from these bucket counts, not a true one from
|
|
41
|
+
# the raw values this gem deliberately never stores, is what the server computes from this.
|
|
42
|
+
bucket[:histogram][HistogramBucketer.bucket_for(duration_ms)] += 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Snapshots and resets the in-process buckets, then delivers them as one batch. A failed
|
|
47
|
+
# delivery keeps every bucket where it is rather than resetting, so the next flush's window
|
|
48
|
+
# just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
|
|
49
|
+
# already documents, and the same reason PerformanceSamplesController accepts a batch rather
|
|
50
|
+
# than a single row: a failed flush shouldn't have to re-deliver by transaction one at a time.
|
|
51
|
+
#
|
|
52
|
+
# Only exactly what this snapshot delivered is removed afterward (subtracted from whatever is in
|
|
53
|
+
# each bucket by then), never the whole hash reset: #record can run on another thread while the
|
|
54
|
+
# HTTP request is in flight, so a call for a bucket already in the snapshot, or a brand-new one,
|
|
55
|
+
# can land between the snapshot and delivery succeeding, and resetting afterward would silently
|
|
56
|
+
# discard it. The SDKs ported from this gem all fixed that; this class had the same bug.
|
|
57
|
+
# max_duration_ms is left as whatever is currently on the bucket, sent or not: a max can't be
|
|
58
|
+
# "subtracted" back out, and leaving it never overstates the next period's own max.
|
|
59
|
+
def flush
|
|
60
|
+
snapshot = nil
|
|
61
|
+
sent = nil
|
|
62
|
+
period_started_at = nil
|
|
63
|
+
period_ended_at = nil
|
|
64
|
+
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return if @buckets.empty?
|
|
67
|
+
|
|
68
|
+
period_started_at = @period_started_at
|
|
69
|
+
period_ended_at = Time.now.utc
|
|
70
|
+
sent = @buckets.transform_values { |bucket| { count: bucket[:count], duration_sum_ms: bucket[:duration_sum_ms], histogram: bucket[:histogram].dup } }
|
|
71
|
+
snapshot = @buckets.map do |(transaction_name, kind), bucket|
|
|
72
|
+
{
|
|
73
|
+
transaction_name: transaction_name,
|
|
74
|
+
kind: kind,
|
|
75
|
+
environment: configuration.environment.to_s,
|
|
76
|
+
release: configuration.release,
|
|
77
|
+
period_started_at: period_started_at.iso8601,
|
|
78
|
+
period_ended_at: period_ended_at.iso8601,
|
|
79
|
+
request_count: bucket[:count],
|
|
80
|
+
duration_sum_ms: bucket[:duration_sum_ms],
|
|
81
|
+
max_duration_ms: bucket[:max_duration_ms],
|
|
82
|
+
histogram: bucket[:histogram].dup
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return unless snapshot && client.deliver_performance_samples(snapshot)
|
|
88
|
+
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
sent.each do |key, delivered|
|
|
91
|
+
current = @buckets[key]
|
|
92
|
+
next unless current
|
|
93
|
+
|
|
94
|
+
current[:count] -= delivered[:count]
|
|
95
|
+
current[:duration_sum_ms] = [ current[:duration_sum_ms] - delivered[:duration_sum_ms], 0.0 ].max
|
|
96
|
+
delivered[:histogram].each do |bucket_key, count|
|
|
97
|
+
current[:histogram][bucket_key] -= count
|
|
98
|
+
current[:histogram].delete(bucket_key) if current[:histogram][bucket_key] <= 0
|
|
99
|
+
end
|
|
100
|
+
@buckets.delete(key) if current[:count] <= 0
|
|
101
|
+
end
|
|
102
|
+
@period_started_at = period_ended_at
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
attr_reader :configuration, :client
|
|
108
|
+
|
|
109
|
+
def ensure_worker_started
|
|
110
|
+
return if @thread&.alive?
|
|
111
|
+
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
return if @thread&.alive?
|
|
114
|
+
|
|
115
|
+
@thread = Thread.new { run }
|
|
116
|
+
@thread.abort_on_exception = false
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run
|
|
121
|
+
loop do
|
|
122
|
+
sleep configuration.performance_flush_interval
|
|
123
|
+
flush
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
configuration.logger&.debug { "[ForgeOpsTracker] performance flush thread error: #{e.class}: #{e.message}" }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
# The actual "what do we call this, and should we even record it" decisions Railtie's
|
|
3
|
+
# ActiveSupport::Notifications subscriptions make, pulled out into plain module-function methods
|
|
4
|
+
# rather than left inline in each subscribe block. Railtie itself only ever loads when
|
|
5
|
+
# Rails::Railtie is already defined (see lib/forge_ops_tracker.rb's own final line), so nothing
|
|
6
|
+
# in that file can be exercised by this gem's own test suite without adding Rails itself as a
|
|
7
|
+
# development dependency just to fire a notification event by hand; every method here takes a
|
|
8
|
+
# plain payload Hash instead of a real ActiveSupport::Notifications::Event, so the decision logic
|
|
9
|
+
# itself (the SCHEMA/cached skip, the ActiveJob-wrapper dedup) is fully unit-testable with no
|
|
10
|
+
# Rails/Sidekiq/ActiveJob dependency at all. Railtie's own subscribe blocks stay thin, calling
|
|
11
|
+
# straight into these.
|
|
12
|
+
module PerformanceInstrumentation
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def controller_transaction_name(payload)
|
|
16
|
+
"#{payload[:controller]}##{payload[:action]}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# nil means "don't record this one": Rails' own sql.active_record fires for two kinds of
|
|
20
|
+
# noise that would drown out real query timing otherwise. "SCHEMA" is Rails' own internal
|
|
21
|
+
# introspection (column lookups, etc.), never something a customer's own code triggered;
|
|
22
|
+
# payload[:cached] is a genuinely instant hash lookup off Rails' own query cache, not a real
|
|
23
|
+
# round trip to the database, so timing it would understate every cached query's own real
|
|
24
|
+
# cost the next time it isn't cached.
|
|
25
|
+
#
|
|
26
|
+
# payload[:name] (Rails' own auto-generated label, e.g. "User Load", "Order Create"), not
|
|
27
|
+
# payload[:sql] (the literal query text): a customer's own query text could carry a literal
|
|
28
|
+
# value in some edge cases despite ActiveRecord's own parameterization, and even where it
|
|
29
|
+
# doesn't, raw SQL text is far higher cardinality than this system's transaction_name is
|
|
30
|
+
# designed for everywhere else. Falls back to the literal string "SQL" for the rare query
|
|
31
|
+
# with no name at all, rather than skipping it outright.
|
|
32
|
+
def query_transaction_name(payload)
|
|
33
|
+
return nil if payload[:name] == "SCHEMA" || payload[:cached]
|
|
34
|
+
|
|
35
|
+
# A plain nil-or-empty check, not ActiveSupport's #presence: this module is unit-tested
|
|
36
|
+
# without Rails/ActiveSupport loaded at all (see this file's own top comment), so it can't
|
|
37
|
+
# rely on a method ActiveSupport adds to String/NilClass.
|
|
38
|
+
name = payload[:name]
|
|
39
|
+
name.nil? || name.empty? ? "SQL" : name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def job_transaction_name(payload)
|
|
43
|
+
payload[:job].class.name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# nil means "don't record this one": a job run via perform_now (directly, or as ActiveJob's
|
|
47
|
+
# own synchronous fallback) never goes through enqueue/serialize at all, so its enqueued_at
|
|
48
|
+
# stays nil (confirmed directly against ActiveJob::Core/Enqueuing's own source, not assumed),
|
|
49
|
+
# and a job that was never actually enqueued has no queue wait time to report; a bogus
|
|
50
|
+
# zero-or-negative duration would be actively misleading in a "how backed up is this queue"
|
|
51
|
+
# view, not just uninteresting.
|
|
52
|
+
#
|
|
53
|
+
# Takes at: explicitly (the perform.active_job event's own start time) rather than calling
|
|
54
|
+
# Time.now itself: keeps this unit-testable with a plain fixed Time, the same reason every
|
|
55
|
+
# other method in this module takes a plain payload Hash instead of reaching for real
|
|
56
|
+
# Rails/ActiveJob state (see this file's own top comment).
|
|
57
|
+
def job_queue_wait_ms(payload, at:)
|
|
58
|
+
enqueued_at = payload[:job].enqueued_at
|
|
59
|
+
return nil unless enqueued_at
|
|
60
|
+
|
|
61
|
+
(at - enqueued_at) * 1000.0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# nil means "this job didn't fail": ActiveSupport::Notifications.instrument (which
|
|
65
|
+
# perform.active_job already goes through) automatically populates payload[:exception_object]
|
|
66
|
+
# with whatever the instrumented block raised, confirmed directly against the installed
|
|
67
|
+
# activesupport gem's own documented behavior, not assumed; a job that completed normally
|
|
68
|
+
# never gets this key at all. Returns the raised exception's own class name/message, not the
|
|
69
|
+
# exception object itself, matching every other detail this module already extracts as plain
|
|
70
|
+
# strings rather than passing real framework objects back out to Railtie.
|
|
71
|
+
def job_failure_details(payload)
|
|
72
|
+
exception = payload[:exception_object]
|
|
73
|
+
return nil unless exception
|
|
74
|
+
|
|
75
|
+
{ error_class: exception.class.name, error_message: exception.message.to_s }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# True for a job dispatched through ActiveJob but actually run on Sidekiq: Sidekiq's own
|
|
79
|
+
# server middleware chain wraps every job it runs, ActiveJob-dispatched or not, so without
|
|
80
|
+
# this check a job on the Sidekiq/ActiveJob combination would be recorded twice: once here
|
|
81
|
+
# (kind: "job", via the raw Sidekiq middleware) and once already by the perform.active_job
|
|
82
|
+
# subscription that already covers every ActiveJob job regardless of which queue backend
|
|
83
|
+
# actually runs it (Solid Queue, Sidekiq, or anything else). The class name itself is
|
|
84
|
+
# ActiveJob's own, not this gem's: confirmed directly against how ActiveJob wraps a job for
|
|
85
|
+
# Sidekiq, not assumed from either project's docs alone.
|
|
86
|
+
def sidekiq_worker_is_active_job_wrapper?(worker)
|
|
87
|
+
return false unless defined?(::ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper)
|
|
88
|
+
|
|
89
|
+
worker.is_a?(::ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
# A small shared background-thread loop for the Sidekiq/Solid Queue/Puma stats reporters (see
|
|
3
|
+
# integrations/): unlike PerformanceFlusher and the two metric buffers, which genuinely differ
|
|
4
|
+
# in what they buffer and are kept as separate sibling classes on purpose (see
|
|
5
|
+
# InfrastructureMetricBuffer's own comment), these three reporters only differ in *what they
|
|
6
|
+
# poll*, not *how they poll it*: lazily start a thread on first use (so a forked worker gets
|
|
7
|
+
# its own live thread regardless of when it forked relative to load, same reasoning
|
|
8
|
+
# PerformanceFlusher's own lazy start documents), sleep the interval, call the block, rescue and
|
|
9
|
+
# log so one bad tick never kills the loop, forever. Genuinely mechanical enough across all
|
|
10
|
+
# three to share, unlike those.
|
|
11
|
+
#
|
|
12
|
+
# No at_exit flush: unlike the flushers/buffers, which hold real accumulated data that would
|
|
13
|
+
# otherwise be lost, a gauge reading skipped on process exit is simply the same as one skipped
|
|
14
|
+
# because the interval hadn't ticked yet: nothing to lose.
|
|
15
|
+
class PeriodicPoller
|
|
16
|
+
def self.start(interval, logger: nil, &block)
|
|
17
|
+
new(interval, logger: logger, &block).tap(&:start)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(interval, logger: nil, &block)
|
|
21
|
+
@interval = interval
|
|
22
|
+
@logger = logger
|
|
23
|
+
@block = block
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@thread = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
return if @thread&.alive?
|
|
30
|
+
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
return if @thread&.alive?
|
|
33
|
+
|
|
34
|
+
@thread = Thread.new { run }
|
|
35
|
+
@thread.abort_on_exception = false
|
|
36
|
+
end
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
def run
|
|
42
|
+
loop do
|
|
43
|
+
sleep @interval
|
|
44
|
+
@block.call
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@logger&.debug { "[ForgeOpsTracker] periodic poller error: #{e.class}: #{e.message}" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|