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
@@ -2,6 +2,34 @@ require "rails/railtie"
2
2
 
3
3
  module ForgeOpsTracker
4
4
  class Railtie < ::Rails::Railtie
5
+ # Shared across every performance-timing subscription/integration below (controller, query,
6
+ # job, raw Sidekiq, outbound Net::HTTP): one flusher, one background thread, one set of
7
+ # buckets, since PerformanceFlusher already buckets on [transaction_name, kind] and safely
8
+ # handles concurrent calls from several sources at once. Exposed as a class method, not a
9
+ # local variable inside one initializer, specifically so the Sidekiq/Net::HTTP integrations
10
+ # (each guarded by their own separate initializer, since each only loads if its own optional
11
+ # library is present) can record into the exact same instance rather than each starting a
12
+ # redundant flusher/thread of their own.
13
+ class << self
14
+ def performance_flusher
15
+ @performance_flusher ||= ForgeOpsTracker::PerformanceFlusher.new(ForgeOpsTracker.configuration)
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
31
+ end
32
+
5
33
  initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
6
34
  configuration = ForgeOpsTracker.configuration
7
35
  configuration.app_root ||= app.root.to_s
@@ -14,5 +42,249 @@ module ForgeOpsTracker
14
42
  initializer "forge_ops_tracker.track_sessions" do |app|
15
43
  app.middleware.use ForgeOpsTracker::Middleware::SessionTracking, configuration: ForgeOpsTracker.configuration
16
44
  end
45
+
46
+ # Warden::Manager (mounted automatically by Devise, or directly by any other Warden-based
47
+ # auth setup) sets env["warden"] itself; this only ever reads it, so it works regardless of
48
+ # exactly where in the middleware stack this ends up relative to Warden::Manager, as long as
49
+ # that key exists in env by the time this middleware's own #call runs, which is true for any
50
+ # Warden-based auth setup: it mounts its own middleware once, for the whole app, long before
51
+ # any individual request begins.
52
+ initializer "forge_ops_tracker.track_current_user" do |app|
53
+ app.middleware.use ForgeOpsTracker::Middleware::UserContext, configuration: ForgeOpsTracker.configuration
54
+ end
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
+
76
+ # Rails already fires "process_action.action_controller" after every request with the
77
+ # controller/action and how long it took (event.duration, in ms) built in, so unlike session
78
+ # tracking above (which needed a Rack middleware because there's no single existing hook for
79
+ # "a request finished"), this needs no middleware of its own at all, just a subscriber.
80
+ # Guarded the same way the error subscriber and session-tracking middleware both are: on by
81
+ # default, off entirely if track_performance is turned off or the gem itself isn't enabled
82
+ # (no DSN configured, or the current environment isn't in enabled_environments).
83
+ #
84
+ # sql.active_record/perform.active_job are subscribed here too, not in their own separate
85
+ # initializers: all three (plus Sidekiq/Net::HTTP below) are genuinely the same concern
86
+ # (automatic performance timing), sharing the one performance_flusher above; see
87
+ # PerformanceInstrumentation for the actual transaction_name/skip decisions each one makes,
88
+ # kept out of these blocks specifically so that decision logic is unit-testable without a real
89
+ # Rails/ActiveJob boot (this gem's own test suite doesn't require Rails at all; see that
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.
103
+ initializer "forge_ops_tracker.track_performance" do
104
+ configuration = ForgeOpsTracker.configuration
105
+ flusher = Railtie.performance_flusher
106
+
107
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
108
+ next unless configuration.enabled?
109
+
110
+ event = ActiveSupport::Notifications::Event.new(*args)
111
+ transaction_name = PerformanceInstrumentation.controller_transaction_name(event.payload)
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
142
+ end
143
+
144
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
145
+ next unless configuration.enabled?
146
+
147
+ event = ActiveSupport::Notifications::Event.new(*args)
148
+ transaction_name = PerformanceInstrumentation.query_transaction_name(event.payload)
149
+ next if transaction_name.nil? # SCHEMA/cached, see PerformanceInstrumentation's own comment
150
+
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
169
+ end
170
+
171
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
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?
175
+
176
+ event = ActiveSupport::Notifications::Event.new(*args)
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
208
+ end
209
+ end
210
+
211
+ # Raw (non-ActiveJob) Sidekiq::Worker job timing, plus Sidekiq's own queue-depth/processed/
212
+ # failed/etc. gauges: a separate initializer, since unlike ActiveRecord/ActionController
213
+ # (always present in any Rails app this gem's own gemspec already implies), Sidekiq is
214
+ # genuinely optional and isn't a declared dependency of this gem at all.
215
+ initializer "forge_ops_tracker.track_sidekiq" do
216
+ next unless defined?(::Sidekiq)
217
+
218
+ require "forge_ops_tracker/integrations/sidekiq"
219
+
220
+ configuration = ForgeOpsTracker.configuration
221
+ ::Sidekiq.configure_server do |config|
222
+ config.server_middleware do |chain|
223
+ # No extra args to chain.add: Sidekiq calls Middleware.new(*args) itself for every job,
224
+ # splatting whatever was passed here; the middleware's own keyword-argument defaults
225
+ # (ForgeOpsTracker.configuration, Railtie.performance_flusher) already resolve correctly
226
+ # when called with none, and passing a keyword hash through *args here is genuinely
227
+ # ambiguous (Sidekiq's own chain.add doc example passes a literal positional Hash, not
228
+ # keyword syntax, for exactly this reason).
229
+ chain.add ForgeOpsTracker::Integrations::Sidekiq::Middleware
230
+ end
231
+ end
232
+
233
+ ForgeOpsTracker::Integrations::Sidekiq::StatsReporter.start(configuration: configuration)
234
+ end
235
+
236
+ initializer "forge_ops_tracker.track_solid_queue" do
237
+ next unless defined?(::SolidQueue)
238
+
239
+ require "forge_ops_tracker/integrations/solid_queue"
240
+ ForgeOpsTracker::Integrations::SolidQueue::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
241
+ end
242
+
243
+ initializer "forge_ops_tracker.track_puma" do
244
+ next unless defined?(::Puma)
245
+
246
+ require "forge_ops_tracker/integrations/puma"
247
+ ForgeOpsTracker::Integrations::Puma::StatsReporter.start(configuration: ForgeOpsTracker.configuration)
248
+ end
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
+
263
+ # Net::HTTP is always present (Ruby's own standard library), so this always prepends; the
264
+ # actual on/off decision happens at call time inside Timing#request itself (checking
265
+ # configuration.track_performance/enabled? the same way the subscriptions above do), so
266
+ # toggling that flag after boot still takes effect immediately, the same as every other
267
+ # automatic instrumentation point in this gem.
268
+ initializer "forge_ops_tracker.track_net_http" do
269
+ require "forge_ops_tracker/integrations/net_http"
270
+ Net::HTTP.prepend(ForgeOpsTracker::Integrations::NetHTTP::Timing)
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
17
289
  end
18
290
  end
@@ -0,0 +1,65 @@
1
+ require "securerandom"
2
+
3
+ module ForgeOpsTracker
4
+ # Accumulates one request's own nested call tree in-process; the tracing analog to
5
+ # BreadcrumbBuffer's per-thread trail. ForgeOpsTracker::Middleware::SpanTracing resets this to a
6
+ # fresh buffer at the start of every request, with a freshly generated trace_id and root
7
+ # span_id, and reads it back once the request is over to decide whether this trace is worth
8
+ # sending at all (see that middleware's own comment for exactly where and why).
9
+ #
10
+ # The stack exists so nesting can be reconstructed correctly even though most of what's recorded
11
+ # here (a query, an outbound HTTP call, a Redis call) only has a start+duration known *after* the
12
+ # fact, not a real "this span is now open" moment the way a manually-wrapped ForgeOpsTracker.span
13
+ # block has. Whatever's on top of the stack at the moment a leaf span is recorded is that leaf's
14
+ # real parent: the nearest currently-open span, whether that's the request's own root, or a
15
+ # service-level span a customer wrapped their own code in. The root span_id is pushed onto the
16
+ # stack immediately, at construction, specifically so every span recorded before the controller
17
+ # action itself finishes (which is every one of them, since process_action.action_controller's
18
+ # own subscription only fires once the whole action, including everything nested inside it, has
19
+ # already run) naturally nests under it without the root having to exist as a real recorded span
20
+ # first.
21
+ class SpanBuffer
22
+ attr_reader :trace_id, :root_span_id, :spans
23
+
24
+ def initialize
25
+ @trace_id = SecureRandom.hex(16)
26
+ @root_span_id = SecureRandom.hex(8)
27
+ @spans = []
28
+ @stack = [ @root_span_id ]
29
+ @root_duration_ms = nil
30
+ end
31
+
32
+ def current_parent_id
33
+ @stack.last
34
+ end
35
+
36
+ def push(span_id)
37
+ @stack.push(span_id)
38
+ end
39
+
40
+ def pop
41
+ @stack.pop
42
+ end
43
+
44
+ # root: true only for the one call that records the request's own root span (see Railtie's
45
+ # process_action.action_controller subscription): forces parent_span_id nil explicitly, since
46
+ # by the time that fires, the root's own span_id has already been popped off the stack (there's
47
+ # nothing left on it to read back as "the parent" at that point - correctly, since the root has
48
+ # no parent of its own).
49
+ def record(span_id:, name:, kind:, started_at:, duration_ms:, data: {}, root: false)
50
+ parent_span_id = root ? nil : current_parent_id
51
+ spans << {
52
+ span_id: span_id, parent_span_id: parent_span_id, name: name, kind: kind,
53
+ started_at: started_at.utc.iso8601(3), duration_ms: duration_ms, data: data
54
+ }
55
+ @root_duration_ms = duration_ms if root
56
+ end
57
+
58
+ # nil (never sent) until the root span has actually been recorded; a request that somehow never
59
+ # fires process_action.action_controller at all (a request Rails itself never routes) has no
60
+ # duration to compare against a threshold, so it's never mistakenly treated as "slow."
61
+ def slow?(threshold_ms)
62
+ @root_duration_ms && @root_duration_ms >= threshold_ms
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,61 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Same shape and reasoning as DeliveryQueue: a small in-process background thread + bounded
5
+ # queue, so delivering a captured trace never adds latency to the very request it was just
6
+ # measuring - the one thing that would be actively counterproductive here, blocking an already-
7
+ # slow request even longer just to report that it was slow. One whole trace (a trace_id plus
8
+ # every span belonging to it) is one queue entry, delivered as one client call, not batched
9
+ # together with other traces the way PerformanceFlusher/FailureEventBuffer batch over a time
10
+ # window: a trace is already a complete, immediately-relevant unit the moment a request
11
+ # finishes, so it's delivered promptly rather than held for up to a full flush interval.
12
+ class SpanQueue
13
+ def initialize(configuration, client: Client.new(configuration))
14
+ @configuration = configuration
15
+ @client = client
16
+ @queue = SizedQueue.new(configuration.queue_size)
17
+ @mutex = Mutex.new
18
+ @thread = nil
19
+ end
20
+
21
+ # Dropping a whole trace silently (never blocking the caller) if the queue is already full,
22
+ # the same backpressure-must-never-reach-the-host-app posture DeliveryQueue#push already
23
+ # documents; a burst of several slow requests at once must never stall a request that's
24
+ # already been identified as slow even further.
25
+ def push(trace_id:, spans:)
26
+ ensure_worker_started
27
+ @queue.push({ trace_id: trace_id, spans: spans }, true)
28
+ true
29
+ rescue ThreadError
30
+ log { "span delivery queue full, dropping trace" }
31
+ false
32
+ end
33
+
34
+ private
35
+ attr_reader :configuration, :client
36
+
37
+ def ensure_worker_started
38
+ return if @thread&.alive?
39
+
40
+ @mutex.synchronize do
41
+ return if @thread&.alive?
42
+
43
+ @thread = Thread.new { run }
44
+ @thread.abort_on_exception = false
45
+ end
46
+ end
47
+
48
+ def run
49
+ loop do
50
+ entry = @queue.pop
51
+ client.deliver_spans(trace_id: entry[:trace_id], spans: entry[:spans])
52
+ rescue StandardError => e
53
+ log { "span delivery thread error: #{e.class}: #{e.message}" }
54
+ end
55
+ end
56
+
57
+ def log
58
+ configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
59
+ end
60
+ end
61
+ end
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.5.0"
2
+ VERSION = "0.10.1"
3
3
  end
@@ -1,3 +1,4 @@
1
+ require "securerandom"
1
2
  require "forge_ops_tracker/version"
2
3
  require "forge_ops_tracker/configuration"
3
4
  require "forge_ops_tracker/pii_scrubber"
@@ -6,7 +7,20 @@ require "forge_ops_tracker/client"
6
7
  require "forge_ops_tracker/delivery_queue"
7
8
  require "forge_ops_tracker/error_subscriber"
8
9
  require "forge_ops_tracker/session_flusher"
10
+ require "forge_ops_tracker/histogram_bucketer"
11
+ require "forge_ops_tracker/performance_flusher"
12
+ require "forge_ops_tracker/performance_instrumentation"
13
+ require "forge_ops_tracker/metric_buffer"
14
+ require "forge_ops_tracker/infrastructure_metric_buffer"
15
+ require "forge_ops_tracker/failure_event_buffer"
16
+ require "forge_ops_tracker/periodic_poller"
17
+ require "forge_ops_tracker/breadcrumb_buffer"
18
+ require "forge_ops_tracker/span_buffer"
19
+ require "forge_ops_tracker/span_queue"
9
20
  require "forge_ops_tracker/middleware/session_tracking"
21
+ require "forge_ops_tracker/middleware/user_context"
22
+ require "forge_ops_tracker/middleware/breadcrumb_context"
23
+ require "forge_ops_tracker/middleware/span_tracing"
10
24
 
11
25
  module ForgeOpsTracker
12
26
  class << self
@@ -17,6 +31,98 @@ module ForgeOpsTracker
17
31
  def configure
18
32
  yield configuration
19
33
  end
34
+
35
+ # Records a named business metric (a signup, a payment, anything a customer wants to name),
36
+ # buffered and flushed periodically as a batch rather than one network call per capture (see
37
+ # MetricBuffer). value defaults to 1.0 so a bare counter-style call ("a signup happened")
38
+ # needs no argument; pass an explicit one for a metric with a real magnitude ("a $49 payment
39
+ # happened"). A no-op, same as every other capture path in this gem, when the gem isn't
40
+ # enabled (no DSN configured, or the current environment isn't in enabled_environments).
41
+ def capture_metric(name, value: 1.0)
42
+ return unless configuration.enabled?
43
+
44
+ metric_buffer.record(metric_name: name, value: value)
45
+ end
46
+
47
+ # Records one infrastructure reading (CPU/memory/disk, or anything else a customer's own
48
+ # script reads) from one of their own hosts. hostname defaults to Configuration#server_name
49
+ # (already derived from Socket.gethostname), so a script running on the box it's reporting
50
+ # about doesn't need to pass one explicitly. Same buffered-batch delivery and no-op-when-
51
+ # disabled contract as capture_metric above.
52
+ def capture_infrastructure_metric(name, value:, hostname: nil)
53
+ return unless configuration.enabled?
54
+
55
+ infrastructure_metric_buffer.record(metric_name: name, value: value, hostname: hostname || configuration.server_name)
56
+ end
57
+
58
+ # Manually attaches an affected user to whatever gets reported for the rest of this request
59
+ # (or, outside a request entirely, in a background job or a console session, for the rest of
60
+ # this thread). Sets the same thread-local ForgeOpsTracker::Middleware::UserContext's own
61
+ # Warden auto-detection uses, so this composes with it rather than being a second, separate
62
+ # mechanism: call this after that middleware ran to override its guess, or call it standalone
63
+ # in an app with no Warden at all. id/email/username are all independently optional; a call
64
+ # with none of them (or all nil) clears whatever was set.
65
+ def set_user(id: nil, email: nil, username: nil)
66
+ user = { id: id, email: email, username: username }.compact
67
+ Thread.current[:forge_ops_tracker_current_user] = user.empty? ? nil : user
68
+ end
69
+
70
+ # Adds one breadcrumb to the current request's trail (or, outside a request entirely, a
71
+ # background job or console session's own trail for the rest of this thread: see
72
+ # ForgeOpsTracker::Middleware::BreadcrumbContext for how a request gets its own buffer to start
73
+ # with). Lazily creates a buffer on this thread if BreadcrumbContext never ran (Sidekiq/Solid
74
+ # Queue jobs don't go through Rack middleware at all, an IRB/rails console session even less
75
+ # so), the same "works standalone, no specific setup required" shape ForgeOpsTracker.set_user
76
+ # already has. Works whether or not track_breadcrumbs is on: that flag only gates the automatic
77
+ # sql.active_record/process_action.action_controller/Net::HTTP sources Railtie installs, never
78
+ # this manual call.
79
+ def add_breadcrumb(message, category: "custom", level: "info", data: {})
80
+ buffer = (Thread.current[:forge_ops_tracker_breadcrumbs] ||= BreadcrumbBuffer.new(configuration))
81
+ buffer.add(category: category, message: message, level: level, data: data)
82
+ end
83
+
84
+ # Wraps a customer's own service-level code as one named, nested span in the current request's
85
+ # trace (e.g. `ForgeOpsTracker.span("PaymentService#charge") { ... }`), so its database/Redis/
86
+ # outbound-HTTP calls show up nested underneath it in the waterfall rather than appearing to
87
+ # hang directly off the controller root - there's no automatic way to detect "this is a
88
+ # logically distinct service layer" the way a SQL query or an HTTP request already has a real
89
+ # instrumentation hook to detect, so this is deliberately explicit, opt-in, by hand, unlike
90
+ # every other span kind this gem records automatically.
91
+ #
92
+ # A plain pass-through (still runs the block, records nothing) with no error and no special
93
+ # handling whenever there's no current request trace to nest under at all: outside a request
94
+ # entirely (a background job, a console session), when track_tracing is off, or when the gem
95
+ # isn't enabled - the same "never break the host app's own code path" posture every other
96
+ # capture method in this gem already takes. Unlike add_breadcrumb, this does NOT lazily create
97
+ # a standalone buffer for itself: a lone span with no request-level trace around it, and no
98
+ # middleware left running to ever flush it, would just accumulate in this thread's memory
99
+ # forever with nothing to ever send it, which is worse than never recording it at all.
100
+ def span(name, kind: "service", data: {})
101
+ return yield unless configuration.enabled? && configuration.track_tracing
102
+
103
+ buffer = Thread.current[:forge_ops_tracker_spans]
104
+ return yield unless buffer
105
+
106
+ span_id = SecureRandom.hex(8)
107
+ started_at = Time.now.utc
108
+ buffer.push(span_id)
109
+ begin
110
+ yield
111
+ ensure
112
+ buffer.pop
113
+ duration_ms = (Time.now.utc - started_at) * 1000.0
114
+ buffer.record(span_id: span_id, name: name, kind: kind, started_at: started_at, duration_ms: duration_ms, data: data)
115
+ end
116
+ end
117
+
118
+ private
119
+ def metric_buffer
120
+ @metric_buffer ||= MetricBuffer.new(configuration)
121
+ end
122
+
123
+ def infrastructure_metric_buffer
124
+ @infrastructure_metric_buffer ||= InfrastructureMetricBuffer.new(configuration)
125
+ end
20
126
  end
21
127
  end
22
128
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forge_ops_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -23,6 +23,62 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sidekiq
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: puma
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webrick
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.8'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.8'
68
+ - !ruby/object:Gem::Dependency
69
+ name: redis-client
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.30'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.30'
26
82
  description: Hooks Rails' error reporter and reports exceptions to a ForgeOps exception
27
83
  tracker instance over HTTP, without ever raising back into the host application.
28
84
  executables: []
@@ -33,15 +89,34 @@ files:
33
89
  - LICENSE.txt
34
90
  - README.md
35
91
  - lib/forge_ops_tracker.rb
92
+ - lib/forge_ops_tracker/breadcrumb_buffer.rb
36
93
  - lib/forge_ops_tracker/client.rb
37
94
  - lib/forge_ops_tracker/configuration.rb
38
95
  - lib/forge_ops_tracker/delivery_queue.rb
39
96
  - lib/forge_ops_tracker/error_subscriber.rb
40
97
  - lib/forge_ops_tracker/event_builder.rb
98
+ - lib/forge_ops_tracker/failure_event_buffer.rb
99
+ - lib/forge_ops_tracker/histogram_bucketer.rb
100
+ - lib/forge_ops_tracker/infrastructure_metric_buffer.rb
101
+ - lib/forge_ops_tracker/integrations/active_record_pool.rb
102
+ - lib/forge_ops_tracker/integrations/net_http.rb
103
+ - lib/forge_ops_tracker/integrations/puma.rb
104
+ - lib/forge_ops_tracker/integrations/redis_client.rb
105
+ - lib/forge_ops_tracker/integrations/sidekiq.rb
106
+ - lib/forge_ops_tracker/integrations/solid_queue.rb
107
+ - lib/forge_ops_tracker/metric_buffer.rb
108
+ - lib/forge_ops_tracker/middleware/breadcrumb_context.rb
41
109
  - lib/forge_ops_tracker/middleware/session_tracking.rb
110
+ - lib/forge_ops_tracker/middleware/span_tracing.rb
111
+ - lib/forge_ops_tracker/middleware/user_context.rb
112
+ - lib/forge_ops_tracker/performance_flusher.rb
113
+ - lib/forge_ops_tracker/performance_instrumentation.rb
114
+ - lib/forge_ops_tracker/periodic_poller.rb
42
115
  - lib/forge_ops_tracker/pii_scrubber.rb
43
116
  - lib/forge_ops_tracker/railtie.rb
44
117
  - lib/forge_ops_tracker/session_flusher.rb
118
+ - lib/forge_ops_tracker/span_buffer.rb
119
+ - lib/forge_ops_tracker/span_queue.rb
45
120
  - lib/forge_ops_tracker/version.rb
46
121
  homepage: https://getforgeops.net
47
122
  licenses: