forge_ops_tracker 0.7.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +105 -13
- data/lib/forge_ops_tracker/breadcrumb_buffer.rb +45 -0
- data/lib/forge_ops_tracker/client.rb +17 -0
- data/lib/forge_ops_tracker/configuration.rb +61 -0
- data/lib/forge_ops_tracker/error_subscriber.rb +23 -1
- data/lib/forge_ops_tracker/event_builder.rb +10 -3
- data/lib/forge_ops_tracker/failure_event_buffer.rb +73 -0
- data/lib/forge_ops_tracker/histogram_bucketer.rb +32 -0
- data/lib/forge_ops_tracker/infrastructure_metric_buffer.rb +19 -2
- data/lib/forge_ops_tracker/integrations/active_record_pool.rb +41 -0
- data/lib/forge_ops_tracker/integrations/net_http.rb +85 -3
- data/lib/forge_ops_tracker/integrations/redis_client.rb +62 -0
- data/lib/forge_ops_tracker/metric_buffer.rb +20 -3
- data/lib/forge_ops_tracker/middleware/breadcrumb_context.rb +35 -0
- data/lib/forge_ops_tracker/middleware/span_tracing.rb +38 -0
- data/lib/forge_ops_tracker/performance_flusher.rb +33 -5
- data/lib/forge_ops_tracker/performance_instrumentation.rb +32 -0
- data/lib/forge_ops_tracker/railtie.rb +160 -7
- data/lib/forge_ops_tracker/span_buffer.rb +65 -0
- data/lib/forge_ops_tracker/span_queue.rb +61 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +56 -0
- metadata +27 -4
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Integrations
|
|
3
|
+
module ActiveRecordPool
|
|
4
|
+
# Reports this process's own ActiveRecord connection pool utilization as infrastructure
|
|
5
|
+
# metrics on a periodic timer, the same shape as Sidekiq::StatsReporter/SolidQueue::StatsReporter
|
|
6
|
+
# (see either's own comment), and, like both of those, only ever loaded when ActiveRecord is
|
|
7
|
+
# actually present (checked with defined? in Railtie, not assumed).
|
|
8
|
+
#
|
|
9
|
+
# Scoped to the current process's own primary connection pool only, via the bare
|
|
10
|
+
# ActiveRecord::Base.connection_pool this process is actually using: a known v1 limitation,
|
|
11
|
+
# not an oversight, for an app with multiple databases (ActiveRecord::Base.connection_pool is
|
|
12
|
+
# only ever one of potentially several pools such an app has configured). Reporting every
|
|
13
|
+
# configured pool would need ActiveRecord::Base.connection_handler's own registry, a genuinely
|
|
14
|
+
# separate piece of work; this integration reports what a single-database app (the common
|
|
15
|
+
# case, and the only case this gem's own test suite/README examples ever set up) actually has.
|
|
16
|
+
class StatsReporter
|
|
17
|
+
def self.start(configuration: ForgeOpsTracker.configuration)
|
|
18
|
+
return unless configuration.track_performance && configuration.enabled?
|
|
19
|
+
|
|
20
|
+
PeriodicPoller.start(configuration.gauge_poll_interval, logger: configuration.logger) { new.report }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def report
|
|
24
|
+
stat = ::ActiveRecord::Base.connection_pool.stat
|
|
25
|
+
size = stat[:size].to_i
|
|
26
|
+
busy = stat[:busy].to_i
|
|
27
|
+
|
|
28
|
+
# 0/0 (a pool nobody's ever checked a connection out of yet, or, in practice, never) reads
|
|
29
|
+
# as 0% utilized, not NaN/an error: a pool that's never been used is exactly as "not under
|
|
30
|
+
# pressure" as a pool with plenty of headroom, not a value worth surfacing as broken.
|
|
31
|
+
utilization_pct = size.zero? ? 0.0 : (100.0 * busy / size).round(1)
|
|
32
|
+
|
|
33
|
+
ForgeOpsTracker.capture_infrastructure_metric("active_record_pool.utilization_pct", value: utilization_pct)
|
|
34
|
+
ForgeOpsTracker.capture_infrastructure_metric("active_record_pool.size", value: size)
|
|
35
|
+
ForgeOpsTracker.capture_infrastructure_metric("active_record_pool.busy", value: busy)
|
|
36
|
+
ForgeOpsTracker.capture_infrastructure_metric("active_record_pool.waiting", value: stat[:waiting].to_i)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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
|