forge_ops_tracker 0.7.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 +51 -0
- data/README.md +84 -11
- 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 +24 -1
|
@@ -11,19 +11,101 @@ module ForgeOpsTracker
|
|
|
11
11
|
# transaction_name is "<HTTP method> <host>", not the full URL: a request's own path or
|
|
12
12
|
# query string could carry an id or a token, the same low-cardinality/no-secrets-in-a-label
|
|
13
13
|
# reasoning every other transaction_name in this system already follows.
|
|
14
|
+
#
|
|
15
|
+
# Known, honest gap, confirmed directly against Net::HTTP's own source and real behavior, not
|
|
16
|
+
# assumed: Net::HTTP.get_response and the Net::HTTP.start { |http| ... } block form both open
|
|
17
|
+
# the TCP connection inside #start, *before* the block (and so before #request, the only
|
|
18
|
+
# method this wraps) ever runs, so a connection-refused/DNS-failure error raised during that
|
|
19
|
+
# setup is structurally invisible here, for timing, breadcrumbs, and the failure detection
|
|
20
|
+
# below alike (a pre-existing limitation this round's own test suite is what actually
|
|
21
|
+
# surfaced, not something newly introduced). Net::HTTP.new(...).request(...), the other
|
|
22
|
+
# common calling convention (no explicit #start at all), is different and unaffected:
|
|
23
|
+
# confirmed directly that Net::HTTP connects lazily, inside #request itself, the first time
|
|
24
|
+
# it's called on a not-yet-started instance, so that path's own connection failures are
|
|
25
|
+
# caught exactly like any other #request failure.
|
|
14
26
|
module Timing
|
|
15
27
|
# The guard (return super unless ...) skips timing, but the ensure block below still runs
|
|
16
28
|
# on every exit path regardless, same as any ensure; start staying nil is what actually
|
|
17
29
|
# skips recording there, not a second guard duplicating this one.
|
|
18
30
|
def request(req, body = nil, &block)
|
|
19
|
-
|
|
31
|
+
configuration = ForgeOpsTracker.configuration
|
|
32
|
+
return super unless (configuration.track_performance || configuration.track_breadcrumbs || configuration.track_failures || configuration.track_tracing) && configuration.enabled?
|
|
20
33
|
|
|
21
34
|
start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
22
|
-
super
|
|
35
|
+
response = super
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
# Only track_failures below needs the actual exception object (for error_class/
|
|
38
|
+
# error_message); everything else keys off response staying nil, already true on this
|
|
39
|
+
# path without capturing anything. re-raised unchanged: this gem must never swallow or
|
|
40
|
+
# alter what the host app's own call to Net::HTTP#request would have raised.
|
|
41
|
+
raised_error = e
|
|
42
|
+
raise
|
|
23
43
|
ensure
|
|
24
44
|
if start
|
|
25
45
|
duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
26
|
-
|
|
46
|
+
# Computed once, unconditionally, not separately inside each flag-gated block below:
|
|
47
|
+
# both the breadcrumb and track_failures sections need it, and a value only ever
|
|
48
|
+
# assigned inside one conditional branch but read from another relies on Ruby's own
|
|
49
|
+
# "a local is declared the moment its assignment is lexically parsed, regardless of
|
|
50
|
+
# whether that branch actually ran" scoping, a real but easy-to-break-on-reorder
|
|
51
|
+
# subtlety not worth the risk here.
|
|
52
|
+
status = response&.code&.to_i
|
|
53
|
+
if configuration.track_performance
|
|
54
|
+
Railtie.performance_flusher.record(transaction_name: "#{req.method} #{address}", duration_ms: duration_ms, kind: "http")
|
|
55
|
+
end
|
|
56
|
+
# The same "method + host, never the full path/query string" reasoning
|
|
57
|
+
# transaction_name above already follows: a URL's own path or query could carry an id
|
|
58
|
+
# or a token, and this is a breadcrumb message, not a value this gem controls the
|
|
59
|
+
# cardinality of. Mirrors sdks/typescript's own fetch/xhr breadcrumbs: category
|
|
60
|
+
# "http", status + duration_ms in data, level "warning" for a non-2xx/3xx response.
|
|
61
|
+
if configuration.track_breadcrumbs
|
|
62
|
+
# No status at all means the request itself raised (a timeout, a connection
|
|
63
|
+
# refused, ...) before a response ever came back: worth "error", not the misleading
|
|
64
|
+
# "info" a bare status-based check would default a nil status to, the same "the
|
|
65
|
+
# request truly failed" case sdks/typescript's own fetch breadcrumb catch block
|
|
66
|
+
# marks "error" for.
|
|
67
|
+
level = if status.nil?
|
|
68
|
+
"error"
|
|
69
|
+
elsif status >= 400
|
|
70
|
+
"warning"
|
|
71
|
+
else
|
|
72
|
+
"info"
|
|
73
|
+
end
|
|
74
|
+
ForgeOpsTracker.add_breadcrumb(
|
|
75
|
+
"#{req.method} #{address}",
|
|
76
|
+
category: "http",
|
|
77
|
+
level: level,
|
|
78
|
+
data: { status: status, duration_ms: duration_ms.round(1) }.compact
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
# Independent of track_performance/track_breadcrumbs above, same "several genuinely
|
|
82
|
+
# independent mechanisms recorded from the same instrumentation point" pattern this
|
|
83
|
+
# exact call site already established for breadcrumbs. A failure is the request
|
|
84
|
+
# raising, or a 5xx: not a 4xx, the identical distinction the breadcrumb level just
|
|
85
|
+
# above already draws between "the dependency is unhealthy" (worth "error"/failure)
|
|
86
|
+
# and "the dependency responded, just not with success" (only "warning").
|
|
87
|
+
if configuration.track_failures && (status.nil? || status >= 500)
|
|
88
|
+
Railtie.failure_event_buffer.record(
|
|
89
|
+
kind: "dependency", transaction_name: "#{req.method} #{address}",
|
|
90
|
+
error_class: raised_error&.class&.name, error_message: raised_error&.message
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
# A leaf span, the same "parented onto whatever's currently open, never touches the
|
|
94
|
+
# stack itself" shape the sql.active_record subscription's own span recording already
|
|
95
|
+
# takes: an outbound HTTP call has no children of its own to nest anything under
|
|
96
|
+
# either.
|
|
97
|
+
if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
|
|
98
|
+
# duration_ms itself is exact (computed from the monotonic clock above); started_at
|
|
99
|
+
# here is only derived from it (now minus duration), an approximation off the wall
|
|
100
|
+
# clock rather than a second real timestamp captured at the top of this method - a
|
|
101
|
+
# few milliseconds of drift on where the bar starts in a waterfall, never on how long
|
|
102
|
+
# it actually took.
|
|
103
|
+
start_time = ::Time.now.utc - (duration_ms / 1000.0)
|
|
104
|
+
buffer.record(
|
|
105
|
+
span_id: SecureRandom.hex(8), name: "#{req.method} #{address}", kind: "http",
|
|
106
|
+
started_at: start_time, duration_ms: duration_ms, data: { status: status }.compact
|
|
107
|
+
)
|
|
108
|
+
end
|
|
27
109
|
end
|
|
28
110
|
end
|
|
29
111
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Integrations
|
|
3
|
+
module RedisClientTiming
|
|
4
|
+
# Times every Redis command, applied once via RedisClient.prepend(Timing) from Railtie, the
|
|
5
|
+
# same Module#prepend + super shape Integrations::NetHTTP::Timing already uses for the
|
|
6
|
+
# identical reason: neither Ruby's Redis ecosystem nor RedisClient itself fires an
|
|
7
|
+
# ActiveSupport::Notifications event of its own, so wrapping #call directly is the only hook
|
|
8
|
+
# available. Targets RedisClient specifically (the modern, actively-maintained low-level
|
|
9
|
+
# client both the current redis gem (5.x+) and standalone RedisClient users go through), not
|
|
10
|
+
# the older redis-rb 4.x Redis::Client - confirmed directly (not assumed) that redis 5.x no
|
|
11
|
+
# longer defines Redis::Client at all, so a hook there would silently do nothing on any
|
|
12
|
+
# currently-installed version of the gem.
|
|
13
|
+
#
|
|
14
|
+
# Unlike controller/query timing, this is span-only: there's no "redis" PerformanceSample
|
|
15
|
+
# kind for this to feed into the aggregate Performance page (that page has no Redis panel at
|
|
16
|
+
# all yet), so this only ever contributes to a captured trace's own waterfall, plus a
|
|
17
|
+
# breadcrumb and a dependency failure record, the same two other independent mechanisms
|
|
18
|
+
# NetHTTP::Timing's own call site already establishes.
|
|
19
|
+
#
|
|
20
|
+
# name is "Redis <COMMAND>" (the command verb alone, upcased: GET, SET, LPUSH, ...), never
|
|
21
|
+
# the key or any argument - the same low-cardinality, no-secrets-in-a-label reasoning every
|
|
22
|
+
# other transaction_name/span name in this gem already follows; a Redis key can easily carry
|
|
23
|
+
# a customer's own id or other sensitive value.
|
|
24
|
+
def call(*command, **kwargs)
|
|
25
|
+
configuration = ForgeOpsTracker.configuration
|
|
26
|
+
return super unless (configuration.track_breadcrumbs || configuration.track_failures || configuration.track_tracing) && configuration.enabled?
|
|
27
|
+
|
|
28
|
+
name = "Redis #{command.first.to_s.upcase}"
|
|
29
|
+
start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
30
|
+
result = super
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
raised_error = e
|
|
33
|
+
raise
|
|
34
|
+
ensure
|
|
35
|
+
if start
|
|
36
|
+
duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
37
|
+
|
|
38
|
+
if configuration.track_breadcrumbs
|
|
39
|
+
ForgeOpsTracker.add_breadcrumb(
|
|
40
|
+
name, category: "redis", level: raised_error ? "error" : "info",
|
|
41
|
+
data: { duration_ms: duration_ms.round(1) }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if configuration.track_failures && raised_error
|
|
46
|
+
Railtie.failure_event_buffer.record(
|
|
47
|
+
kind: "dependency", transaction_name: name,
|
|
48
|
+
error_class: raised_error.class.name, error_message: raised_error.message
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
|
|
53
|
+
buffer.record(
|
|
54
|
+
span_id: SecureRandom.hex(8), name: name, kind: "redis",
|
|
55
|
+
started_at: ::Time.now.utc - (duration_ms / 1000.0), duration_ms: duration_ms
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -2,7 +2,7 @@ require "thread"
|
|
|
2
2
|
|
|
3
3
|
module ForgeOpsTracker
|
|
4
4
|
# Collects individual ForgeOpsTracker.capture_metric calls in-process and periodically flushes
|
|
5
|
-
# them as one batch, rather than one network call per capture
|
|
5
|
+
# them as one batch, rather than one network call per capture: the same "lazily start a
|
|
6
6
|
# background thread on first use" pattern SessionFlusher/PerformanceFlusher already use, so each
|
|
7
7
|
# forked Puma/Passenger worker gets its own fresh thread instead of inheriting a dead one across
|
|
8
8
|
# fork. Unlike those two, this collects a *list* of individually-meaningful entries rather than
|
|
@@ -10,6 +10,12 @@ module ForgeOpsTracker
|
|
|
10
10
|
# they'll want a genuinely accurate count/sum of later, not something to pre-aggregate away
|
|
11
11
|
# client-side, so CustomMetricsController stores one row per entry as-is.
|
|
12
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
|
+
|
|
13
19
|
def initialize(configuration, client: Client.new(configuration))
|
|
14
20
|
@configuration = configuration
|
|
15
21
|
@client = client
|
|
@@ -23,9 +29,13 @@ module ForgeOpsTracker
|
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def record(metric_name:, value:)
|
|
32
|
+
return false unless keepable?(value)
|
|
33
|
+
|
|
26
34
|
ensure_worker_started
|
|
27
35
|
|
|
28
36
|
@mutex.synchronize do
|
|
37
|
+
return false if @entries.size >= MAX_ENTRIES
|
|
38
|
+
|
|
29
39
|
@entries << {
|
|
30
40
|
metric_name: metric_name, value: value,
|
|
31
41
|
environment: configuration.environment.to_s, release: configuration.release,
|
|
@@ -46,17 +56,24 @@ module ForgeOpsTracker
|
|
|
46
56
|
|
|
47
57
|
@mutex.synchronize do
|
|
48
58
|
return if @entries.empty?
|
|
49
|
-
snapshot = @entries
|
|
59
|
+
snapshot = @entries.dup
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
return unless client.deliver_metrics(snapshot)
|
|
53
63
|
|
|
54
|
-
|
|
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) }
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
private
|
|
58
70
|
attr_reader :configuration, :client
|
|
59
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
|
+
|
|
60
77
|
def ensure_worker_started
|
|
61
78
|
return if @thread&.alive?
|
|
62
79
|
|
|
@@ -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
|
|
@@ -32,10 +32,14 @@ module ForgeOpsTracker
|
|
|
32
32
|
ensure_worker_started
|
|
33
33
|
|
|
34
34
|
@mutex.synchronize do
|
|
35
|
-
bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0 })
|
|
35
|
+
bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0, histogram: Hash.new(0) })
|
|
36
36
|
bucket[:count] += 1
|
|
37
37
|
bucket[:duration_sum_ms] += duration_ms
|
|
38
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
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
|
|
@@ -44,14 +48,26 @@ module ForgeOpsTracker
|
|
|
44
48
|
# just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
|
|
45
49
|
# already documents, and the same reason PerformanceSamplesController accepts a batch rather
|
|
46
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.
|
|
47
59
|
def flush
|
|
48
60
|
snapshot = nil
|
|
61
|
+
sent = nil
|
|
49
62
|
period_started_at = nil
|
|
63
|
+
period_ended_at = nil
|
|
50
64
|
|
|
51
65
|
@mutex.synchronize do
|
|
52
66
|
return if @buckets.empty?
|
|
53
67
|
|
|
54
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 } }
|
|
55
71
|
snapshot = @buckets.map do |(transaction_name, kind), bucket|
|
|
56
72
|
{
|
|
57
73
|
transaction_name: transaction_name,
|
|
@@ -59,10 +75,11 @@ module ForgeOpsTracker
|
|
|
59
75
|
environment: configuration.environment.to_s,
|
|
60
76
|
release: configuration.release,
|
|
61
77
|
period_started_at: period_started_at.iso8601,
|
|
62
|
-
period_ended_at:
|
|
78
|
+
period_ended_at: period_ended_at.iso8601,
|
|
63
79
|
request_count: bucket[:count],
|
|
64
80
|
duration_sum_ms: bucket[:duration_sum_ms],
|
|
65
|
-
max_duration_ms: bucket[:max_duration_ms]
|
|
81
|
+
max_duration_ms: bucket[:max_duration_ms],
|
|
82
|
+
histogram: bucket[:histogram].dup
|
|
66
83
|
}
|
|
67
84
|
end
|
|
68
85
|
end
|
|
@@ -70,8 +87,19 @@ module ForgeOpsTracker
|
|
|
70
87
|
return unless snapshot && client.deliver_performance_samples(snapshot)
|
|
71
88
|
|
|
72
89
|
@mutex.synchronize do
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
75
103
|
end
|
|
76
104
|
end
|
|
77
105
|
|
|
@@ -43,6 +43,38 @@ module ForgeOpsTracker
|
|
|
43
43
|
payload[:job].class.name
|
|
44
44
|
end
|
|
45
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
|
+
|
|
46
78
|
# True for a job dispatched through ActiveJob but actually run on Sidekiq: Sidekiq's own
|
|
47
79
|
# server middleware chain wraps every job it runs, ActiveJob-dispatched or not, so without
|
|
48
80
|
# this check a job on the Sidekiq/ActiveJob combination would be recorded twice: once here
|
|
@@ -14,6 +14,20 @@ module ForgeOpsTracker
|
|
|
14
14
|
def performance_flusher
|
|
15
15
|
@performance_flusher ||= ForgeOpsTracker::PerformanceFlusher.new(ForgeOpsTracker.configuration)
|
|
16
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
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
|
|
@@ -39,6 +53,26 @@ module ForgeOpsTracker
|
|
|
39
53
|
app.middleware.use ForgeOpsTracker::Middleware::UserContext, configuration: ForgeOpsTracker.configuration
|
|
40
54
|
end
|
|
41
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
|
+
|
|
42
76
|
# Rails already fires "process_action.action_controller" after every request with the
|
|
43
77
|
# controller/action and how long it took (event.duration, in ms) built in, so unlike session
|
|
44
78
|
# tracking above (which needed a Rack middleware because there's no single existing hook for
|
|
@@ -54,34 +88,123 @@ module ForgeOpsTracker
|
|
|
54
88
|
# kept out of these blocks specifically so that decision logic is unit-testable without a real
|
|
55
89
|
# Rails/ActiveJob boot (this gem's own test suite doesn't require Rails at all; see that
|
|
56
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.
|
|
57
103
|
initializer "forge_ops_tracker.track_performance" do
|
|
58
104
|
configuration = ForgeOpsTracker.configuration
|
|
59
105
|
flusher = Railtie.performance_flusher
|
|
60
106
|
|
|
61
107
|
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
62
|
-
next unless configuration.
|
|
108
|
+
next unless configuration.enabled?
|
|
63
109
|
|
|
64
110
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
65
111
|
transaction_name = PerformanceInstrumentation.controller_transaction_name(event.payload)
|
|
66
|
-
|
|
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
|
|
67
142
|
end
|
|
68
143
|
|
|
69
144
|
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
70
|
-
next unless configuration.
|
|
145
|
+
next unless configuration.enabled?
|
|
71
146
|
|
|
72
147
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
73
148
|
transaction_name = PerformanceInstrumentation.query_transaction_name(event.payload)
|
|
74
149
|
next if transaction_name.nil? # SCHEMA/cached, see PerformanceInstrumentation's own comment
|
|
75
150
|
|
|
76
|
-
|
|
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
|
|
77
169
|
end
|
|
78
170
|
|
|
79
171
|
ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
|
|
80
|
-
|
|
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?
|
|
81
175
|
|
|
82
176
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
208
|
end
|
|
86
209
|
end
|
|
87
210
|
|
|
@@ -124,6 +247,19 @@ module ForgeOpsTracker
|
|
|
124
247
|
ForgeOpsTracker::Integrations::Puma::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
|
|
125
248
|
end
|
|
126
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
|
+
|
|
127
263
|
# Net::HTTP is always present (Ruby's own standard library), so this always prepends; the
|
|
128
264
|
# actual on/off decision happens at call time inside Timing#request itself (checking
|
|
129
265
|
# configuration.track_performance/enabled? the same way the subscriptions above do), so
|
|
@@ -133,5 +269,22 @@ module ForgeOpsTracker
|
|
|
133
269
|
require "forge_ops_tracker/integrations/net_http"
|
|
134
270
|
Net::HTTP.prepend(ForgeOpsTracker::Integrations::NetHTTP::Timing)
|
|
135
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
|
|
136
289
|
end
|
|
137
290
|
end
|