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.
@@ -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
- return super unless ForgeOpsTracker.configuration.track_performance && ForgeOpsTracker.configuration.enabled?
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
- Railtie.performance_flusher.record(transaction_name: "#{req.method} #{address}", duration_ms: duration_ms, kind: "http")
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 -- the same "lazily start a
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
- @mutex.synchronize { @entries = [] }
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: Time.now.utc.iso8601,
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
- @buckets = {}
74
- @period_started_at = Time.now.utc
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