forge_ops_tracker 0.5.0 → 0.10.1

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