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.
@@ -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
@@ -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.track_performance && configuration.enabled?
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
- flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "controller")
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.track_performance && configuration.enabled?
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
- flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "query")
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
- next unless configuration.track_performance && configuration.enabled?
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
- transaction_name = PerformanceInstrumentation.job_transaction_name(event.payload)
84
- flusher.record(transaction_name: transaction_name, duration_ms: event.duration, kind: "job")
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