forge_ops_tracker 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a535fb7cb193fd614ee40259a9b20e62832149c7a2dbeb7d1d40253c0630f65a
4
- data.tar.gz: 559a19cd52a56addacc87c5533bc0954c7ca2de6eac81a6afb18769f280c06c5
3
+ metadata.gz: 304c9a15155ecdf53fe8e181dd7f43b56a08d7397e7d63555e428185ece06560
4
+ data.tar.gz: d4d2cb88ffec650e579c7279c590e4eaee8f288bc6ef7041d2c2f5785767fa2f
5
5
  SHA512:
6
- metadata.gz: 158342795c1dbb5e7575da43ddcd239c25b52111c268ede3fdfdbb9b5621cf1a10cd120e602f75fe2e57bd46faed9483aaa1a61f8c9f91770474210414400e8f
7
- data.tar.gz: 5b4f11f1758833c58f8925f1354f8a7da49b80d04344cc2c340a2662a186fcfc0a8451709faef4c78a60d618c9f0ea936c460c97b3af8bad2687fbc8f6fbfdae
6
+ metadata.gz: dfd3a51d60e0decd113e2a8bd50db101ebd4ec57ebb468a522413b4f9ffa0c5c15d5440939c7a6f772561ce6ffe5c7078322d2903f0d3ec87006f1dd368c1718
7
+ data.tar.gz: 1646c0b506c357122c513604d031974318df0086a0c2a962c99e80143624be459755fab5dfff3df7718f24bb6caebe699ba5325fafb79b35a92f76ffa0783b63
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-10-13)
4
+
5
+ - Fixed: a batch the server refused was re-sent at every flush for as long as the process lived,
6
+ with everything recorded since added to it. Performance samples, custom and infrastructure
7
+ metrics, session counts and failure events are now dropped when the server answers with a 4xx
8
+ other than 408 or 429 (a 400, 401, 403, 413 or 422, for example), since sending the same batch
9
+ again can only fail the same way. Once a metric buffer had filled up behind such a batch, every
10
+ new metric was dropped too; that no longer happens.
11
+ - A batch that could not be delivered for now (a network error, a timeout, a 5xx, a 408 or a 429)
12
+ is still kept for the next flush, but only for 5 flushes in a row. After the fifth it is dropped
13
+ and the buffer starts clean, so a long outage no longer holds the same data in memory forever.
14
+ - The failure event buffer now holds at most 1000 entries; further ones are dropped until a flush
15
+ lets go of them, the same way the metric buffers already behave.
16
+ - With a `config.logger`, each drop is logged once at debug with what was dropped and why, and a
17
+ non-2xx answer is logged with its status code.
18
+ - Internal: `ForgeOpsTracker::Client#deliver` and its sibling `deliver_*` methods now return
19
+ `:delivered`, `:rejected` or `:failed` instead of `true`/`false`. All three are truthy, so code
20
+ calling `Client` directly should compare against `:delivered`. Nothing in the documented API
21
+ changed.
22
+
3
23
  ## 0.16.0 (2026-10-02)
4
24
 
5
25
  - Changed: outside Rails, the environment now defaults to `production` when nothing sets it (it
@@ -66,7 +66,7 @@ module ForgeOpsTracker
66
66
  end
67
67
 
68
68
  def deliver
69
- client.deliver_change_snapshot(payload)
69
+ DeliveryStatus.from(client.deliver_change_snapshot(payload)) == DeliveryStatus::DELIVERED
70
70
  rescue StandardError => e
71
71
  configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
72
72
  false
@@ -5,8 +5,10 @@ require "uri"
5
5
  module ForgeOpsTracker
6
6
  # Delivers one payload over HTTP. Every failure mode; DNS, connection,
7
7
  # timeout, TLS, a non-2xx response; is caught here and turned into a
8
- # `false` return rather than a raised exception, since a broken or
9
- # unreachable tracker must never be able to break the host app.
8
+ # DeliveryStatus rather than a raised exception, since a broken or
9
+ # unreachable tracker must never be able to break the host app. Every
10
+ # deliver_* method answers one of DeliveryStatus::DELIVERED, REJECTED or
11
+ # FAILED (all three are truthy, so compare, never test for truth).
10
12
  class Client
11
13
  def initialize(configuration)
12
14
  @configuration = configuration
@@ -16,8 +18,8 @@ module ForgeOpsTracker
16
18
  post(configuration.ingestion_uri, payload)
17
19
  end
18
20
 
19
- # Same delivery contract as deliver above (never raises, returns a bare
20
- # true/false), just a different endpoint under the same DSN's host and
21
+ # Same delivery contract as deliver above (never raises, returns a
22
+ # DeliveryStatus), just a different endpoint under the same DSN's host and
21
23
  # api_key; see Configuration#session_checkins_uri.
22
24
  def deliver_session_checkin(payload)
23
25
  post(configuration.session_checkins_uri, payload)
@@ -61,7 +63,7 @@ module ForgeOpsTracker
61
63
  end
62
64
 
63
65
  # Same delivery contract again; see Configuration#changes_uri and ForgeOpsTracker.record_change.
64
- # One change per call. A plan without change tracking answers 403, which is just a false here.
66
+ # One change per call. A plan without change tracking answers 403, which is a REJECTED here.
65
67
  def deliver_change(payload)
66
68
  post(configuration.changes_uri, payload)
67
69
  end
@@ -72,7 +74,7 @@ module ForgeOpsTracker
72
74
  end
73
75
 
74
76
  # Same again; see Configuration#query_plans_uri and QueryExplainer. One plan per call. A plan
75
- # without performance monitoring answers 403, which is just a false here.
77
+ # without performance monitoring answers 403, which is a REJECTED here.
76
78
  def deliver_query_plan(payload)
77
79
  post(configuration.query_plans_uri, payload)
78
80
  end
@@ -81,13 +83,15 @@ module ForgeOpsTracker
81
83
  attr_reader :configuration
82
84
 
83
85
  def post(uri, payload)
84
- return false unless uri
86
+ return DeliveryStatus::FAILED unless uri
85
87
 
86
88
  response = http_for(uri).request(build_request(uri, payload))
87
- response.is_a?(Net::HTTPSuccess)
89
+ status = DeliveryStatus.for_code(response.code)
90
+ log { "delivery to #{uri.path} answered HTTP #{response.code}" } unless status == DeliveryStatus::DELIVERED
91
+ status
88
92
  rescue StandardError => e
89
93
  log { "delivery failed: #{e.class}: #{e.message}" }
90
- false
94
+ DeliveryStatus::FAILED
91
95
  end
92
96
 
93
97
  def http_for(uri)
@@ -39,7 +39,7 @@ module ForgeOpsTracker
39
39
  rescue ThreadError
40
40
  break
41
41
  end
42
- client.public_send(@deliver_with, payload)
42
+ deliver(payload)
43
43
  end
44
44
  sleep 0.01 while @in_flight.positive? && monotonic_now < deadline
45
45
  rescue StandardError => e
@@ -78,7 +78,7 @@ module ForgeOpsTracker
78
78
  payload = @queue.pop
79
79
  @in_flight += 1
80
80
  begin
81
- client.public_send(@deliver_with, payload)
81
+ deliver(payload)
82
82
  ensure
83
83
  @in_flight -= 1
84
84
  end
@@ -87,6 +87,17 @@ module ForgeOpsTracker
87
87
  end
88
88
  end
89
89
 
90
+ # One attempt per payload, as it has always been: a queued event is never held back for a
91
+ # retry, whatever the outcome, so all that is left to do with a refusal is to say so once.
92
+ def deliver(payload)
93
+ case DeliveryStatus.from(client.public_send(@deliver_with, payload))
94
+ when DeliveryStatus::REJECTED
95
+ log { "dropping event: the server rejected it and will not accept a retry" }
96
+ when DeliveryStatus::FAILED
97
+ log { "dropping event: delivery failed" }
98
+ end
99
+ end
100
+
90
101
  # Registered on the first push, not at load, so a process that never reports anything never
91
102
  # waits on exit at all.
92
103
  def register_drain
@@ -0,0 +1,74 @@
1
+ module ForgeOpsTracker
2
+ # What became of one delivery attempt, as every Client#deliver_* method reports it. A bare
3
+ # true/false could not tell "the server will never take this payload" apart from "try again
4
+ # later", so a batch a plan or a validation rule refused was re-sent at every flush for as long
5
+ # as the process lived.
6
+ module DeliveryStatus
7
+ # Any 2xx.
8
+ DELIVERED = :delivered
9
+ # A 4xx other than 408 and 429: sending the same payload again can only fail the same way.
10
+ REJECTED = :rejected
11
+ # A network error, a timeout, a 5xx, a 408 or a 429: worth another attempt later.
12
+ FAILED = :failed
13
+
14
+ RETRYABLE_CLIENT_ERRORS = [ 408, 429 ].freeze
15
+
16
+ def self.for_code(code)
17
+ code = code.to_i
18
+ return DELIVERED if code.between?(200, 299)
19
+ return REJECTED if code.between?(400, 499) && !RETRYABLE_CLIENT_ERRORS.include?(code)
20
+
21
+ FAILED
22
+ end
23
+
24
+ # Reads whatever a client handed back. A status passes through; anything else is read the way
25
+ # the old boolean contract was (truthy delivered, falsy failed for now), so a client injected
26
+ # through a `client:` keyword that still answers true/false keeps working.
27
+ def self.from(result)
28
+ return result if [ DELIVERED, REJECTED, FAILED ].include?(result)
29
+
30
+ result ? DELIVERED : FAILED
31
+ end
32
+ end
33
+
34
+ # Decides, after each flush of one buffer/flusher, whether the batch it just sent is done with.
35
+ # Delivered and rejected batches are let go of at once; one that failed for now is kept, but only
36
+ # for MAX_FAILED_FLUSHES flushes in a row, so an outage (or a server answering 500 to one
37
+ # particular batch) can never pin the same data in memory for the life of the process.
38
+ class FlushOutcome
39
+ MAX_FAILED_FLUSHES = 5
40
+
41
+ # what names the batch in the debug log ("metrics", "performance samples").
42
+ def initialize(configuration, what)
43
+ @configuration = configuration
44
+ @what = what
45
+ @failed_flushes = 0
46
+ end
47
+
48
+ # True when the caller should remove what it just sent (delivered, or dropped), false when it
49
+ # should keep it for the next flush.
50
+ def release?(result)
51
+ case DeliveryStatus.from(result)
52
+ when DeliveryStatus::DELIVERED
53
+ @failed_flushes = 0
54
+ true
55
+ when DeliveryStatus::REJECTED
56
+ @failed_flushes = 0
57
+ log { "dropping buffered #{@what}: the server rejected them and will not accept a retry" }
58
+ true
59
+ else
60
+ @failed_flushes += 1
61
+ return false if @failed_flushes < MAX_FAILED_FLUSHES
62
+
63
+ @failed_flushes = 0
64
+ log { "dropping buffered #{@what}: #{MAX_FAILED_FLUSHES} flushes in a row failed" }
65
+ true
66
+ end
67
+ end
68
+
69
+ private
70
+ def log
71
+ @configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
72
+ end
73
+ end
74
+ end
@@ -8,12 +8,17 @@ module ForgeOpsTracker
8
8
  # kind of thing worth an accurate individual record of, not something to pre-aggregate away the
9
9
  # way PerformanceFlusher's own count/sum/max buckets duration.
10
10
  class FailureEventBuffer
11
+ # Same cap, and the same "drop the newest, not the oldest" reasoning, as MetricBuffer's own
12
+ # MAX_ENTRIES: a burst of failures during an outage must not grow this without bound.
13
+ MAX_ENTRIES = 1000
14
+
11
15
  def initialize(configuration, client: Client.new(configuration))
12
16
  @configuration = configuration
13
17
  @client = client
14
18
  @mutex = Mutex.new
15
19
  @entries = []
16
20
  @thread = nil
21
+ @outcome = FlushOutcome.new(configuration, "failure events")
17
22
 
18
23
  # Flushes whatever's already buffered on a normal process exit, so the last partial window
19
24
  # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
@@ -24,6 +29,8 @@ module ForgeOpsTracker
24
29
  ensure_worker_started
25
30
 
26
31
  @mutex.synchronize do
32
+ return false if @entries.size >= MAX_ENTRIES
33
+
27
34
  @entries << {
28
35
  kind: kind, transaction_name: transaction_name, error_class: error_class, error_message: error_message,
29
36
  environment: configuration.environment.to_s, release: configuration.release,
@@ -33,18 +40,22 @@ module ForgeOpsTracker
33
40
  end
34
41
 
35
42
  # Snapshots and resets the buffered entries, then delivers them as one batch. Same "a failed
36
- # delivery keeps every entry for the next attempt" contract as MetricBuffer#flush.
43
+ # delivery keeps every entry for the next attempt" contract as MetricBuffer#flush, with the
44
+ # same two exceptions: a rejected batch, and one that failed FlushOutcome::MAX_FAILED_FLUSHES
45
+ # flushes in a row, are dropped.
37
46
  def flush
38
47
  snapshot = nil
39
48
 
40
49
  @mutex.synchronize do
41
50
  return if @entries.empty?
42
- snapshot = @entries
51
+ snapshot = @entries.dup
43
52
  end
44
53
 
45
- return unless client.deliver_failure_events(snapshot)
54
+ return unless @outcome.release?(client.deliver_failure_events(snapshot))
46
55
 
47
- @mutex.synchronize { @entries = [] }
56
+ # Exactly the entries just sent: anything recorded while the request was in flight sits after
57
+ # them and stays for the next flush.
58
+ @mutex.synchronize { @entries.shift(snapshot.size) }
48
59
  end
49
60
 
50
61
  private
@@ -3,16 +3,16 @@ require "thread"
3
3
  module ForgeOpsTracker
4
4
  # Collects individual ForgeOpsTracker.capture_infrastructure_metric readings in-process and
5
5
  # periodically flushes them as one batch, same shape as MetricBuffer (a sibling class, not a
6
- # shared base -- this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
6
+ # shared base; this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
7
7
  # readable classes despite their own similar shape too, and these two follow that precedent).
8
8
  # Its main real caller is a short-lived cron/script process that exits right after a handful of
9
9
  # capture calls, so the at_exit flush below is what actually matters in practice; the periodic
10
10
  # background-thread timer still runs for a long-lived process that chooses to call this
11
11
  # continuously instead, but rarely gets the chance to fire in the cron case.
12
12
  class InfrastructureMetricBuffer
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
13
+ # Once this many entries are buffered, further ones are dropped until a flush lets go of them:
14
+ # an unreachable server fails every flush, and an uncapped buffer would keep growing until
15
+ # FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
16
16
  # is delivering at the front of the array, which is what makes removing exactly them exact.
17
17
  MAX_ENTRIES = 1000
18
18
 
@@ -22,6 +22,7 @@ module ForgeOpsTracker
22
22
  @mutex = Mutex.new
23
23
  @entries = []
24
24
  @thread = nil
25
+ @outcome = FlushOutcome.new(configuration, "infrastructure metrics")
25
26
 
26
27
  at_exit { flush }
27
28
  end
@@ -38,7 +39,7 @@ module ForgeOpsTracker
38
39
  end
39
40
  end
40
41
 
41
- # Same reset-on-success/keep-on-failure contract as MetricBuffer#flush; see that class's own
42
+ # Same reset-on-success/keep-on-failure/drop-on-rejection contract as MetricBuffer#flush; see that class's own
42
43
  # comment for the accepted race this shares with it.
43
44
  def flush
44
45
  snapshot = nil
@@ -48,9 +49,9 @@ module ForgeOpsTracker
48
49
  snapshot = @entries.dup
49
50
  end
50
51
 
51
- return unless client.deliver_infrastructure_metrics(snapshot)
52
+ return unless @outcome.release?(client.deliver_infrastructure_metrics(snapshot))
52
53
 
53
- # Exactly the entries just delivered: anything recorded while the request was in flight sits
54
+ # Exactly the entries just sent: anything recorded while the request was in flight sits
54
55
  # after them and stays for the next flush (resetting the whole array here lost it).
55
56
  @mutex.synchronize { @entries.shift(snapshot.size) }
56
57
  end
@@ -10,9 +10,9 @@ 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
13
+ # Once this many entries are buffered, further ones are dropped until a flush lets go of them:
14
+ # an unreachable server fails every flush, and an uncapped buffer would keep growing until
15
+ # FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
16
16
  # is delivering at the front of the array, which is what makes removing exactly them exact.
17
17
  MAX_ENTRIES = 1000
18
18
 
@@ -22,6 +22,7 @@ module ForgeOpsTracker
22
22
  @mutex = Mutex.new
23
23
  @entries = []
24
24
  @thread = nil
25
+ @outcome = FlushOutcome.new(configuration, "metrics")
25
26
 
26
27
  # Flushes whatever's already buffered on a normal process exit, so the last partial window
27
28
  # isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
@@ -44,10 +45,11 @@ module ForgeOpsTracker
44
45
  end
45
46
  end
46
47
 
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
48
+ # Snapshots and resets the buffered entries, then delivers them as one batch. A delivery that
49
+ # failed for now keeps every entry where it is rather than resetting, so the next flush's batch
50
+ # just grows instead of losing what was already buffered (a rejected batch, or one that failed
51
+ # FlushOutcome::MAX_FAILED_FLUSHES flushes in a row, is dropped instead); same reasoning
52
+ # SessionFlusher#flush already documents, including the same small, accepted race (an entry recorded during the
51
53
  # in-flight HTTP request gets folded into this snapshot's own reset rather than kept for the
52
54
  # next one) that class already lives with, for the same reason: simple and consistent beats a
53
55
  # cleverer dedup that would only trade one rare edge case for a subtler one.
@@ -59,9 +61,9 @@ module ForgeOpsTracker
59
61
  snapshot = @entries.dup
60
62
  end
61
63
 
62
- return unless client.deliver_metrics(snapshot)
64
+ return unless @outcome.release?(client.deliver_metrics(snapshot))
63
65
 
64
- # Exactly the entries just delivered: anything recorded while the request was in flight sits
66
+ # Exactly the entries just sent: anything recorded while the request was in flight sits
65
67
  # after them and stays for the next flush (resetting the whole array here lost it).
66
68
  @mutex.synchronize { @entries.shift(snapshot.size) }
67
69
  end
@@ -17,6 +17,7 @@ module ForgeOpsTracker
17
17
  @buckets = {}
18
18
  @period_started_at = Time.now.utc
19
19
  @thread = nil
20
+ @outcome = FlushOutcome.new(configuration, "performance samples")
20
21
 
21
22
  # Flushes whatever's already tallied on a normal process exit, so the last partial window
22
23
  # (anything shorter than a full performance_flush_interval) isn't silently dropped; same
@@ -48,6 +49,9 @@ module ForgeOpsTracker
48
49
  # just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
49
50
  # already documents, and the same reason PerformanceSamplesController accepts a batch rather
50
51
  # than a single row: a failed flush shouldn't have to re-deliver by transaction one at a time.
52
+ # That only holds for a delivery that failed for now, and only FlushOutcome::MAX_FAILED_FLUSHES
53
+ # times in a row: a batch the server rejected, or one that keeps failing, is removed exactly
54
+ # the way a delivered one is, since re-sending it could never succeed.
51
55
  #
52
56
  # Only exactly what this snapshot delivered is removed afterward (subtracted from whatever is in
53
57
  # each bucket by then), never the whole hash reset: #record can run on another thread while the
@@ -84,7 +88,7 @@ module ForgeOpsTracker
84
88
  end
85
89
  end
86
90
 
87
- return unless snapshot && client.deliver_performance_samples(snapshot)
91
+ return unless snapshot && @outcome.release?(client.deliver_performance_samples(snapshot))
88
92
 
89
93
  @mutex.synchronize do
90
94
  sent.each do |key, delivered|
@@ -135,10 +135,11 @@ module ForgeOpsTracker
135
135
  return false
136
136
  end
137
137
 
138
- client.deliver_query_plan(
138
+ result = client.deliver_query_plan(
139
139
  statement: job.statement, db_system: "postgresql", plan: masked_plan,
140
140
  duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
141
141
  )
142
+ DeliveryStatus.from(result) == DeliveryStatus::DELIVERED
142
143
  rescue StandardError => e
143
144
  log { "EXPLAIN failed: #{e.class}: #{e.message}" }
144
145
  false
@@ -16,6 +16,7 @@ module ForgeOpsTracker
16
16
  @crashed_sessions_count = 0
17
17
  @period_started_at = Time.now.utc
18
18
  @thread = nil
19
+ @outcome = FlushOutcome.new(configuration, "session counts")
19
20
 
20
21
  # Flushes whatever's already tallied on a normal process exit, so the last partial window
21
22
  # (anything shorter than a full session_flush_interval) isn't silently dropped.
@@ -34,6 +35,8 @@ module ForgeOpsTracker
34
35
  # Snapshots and resets the in-process counters, then delivers them. A failed delivery keeps
35
36
  # the counts where they are rather than resetting, so the next flush's window just grows
36
37
  # instead of losing what was already tallied; there's no other copy of this data anywhere.
38
+ # Counts the server rejected, or that failed FlushOutcome::MAX_FAILED_FLUSHES flushes in a
39
+ # row, are reset all the same: sending them again could never succeed.
37
40
  def flush
38
41
  snapshot = nil
39
42
 
@@ -50,7 +53,7 @@ module ForgeOpsTracker
50
53
  }
51
54
  end
52
55
 
53
- return unless snapshot && client.deliver_session_checkin(snapshot)
56
+ return unless snapshot && @outcome.release?(client.deliver_session_checkin(snapshot))
54
57
 
55
58
  @mutex.synchronize do
56
59
  @sessions_count = 0
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.16.0"
2
+ VERSION = "0.17.0"
3
3
  end
@@ -4,6 +4,7 @@ require "forge_ops_tracker/configuration"
4
4
  require "forge_ops_tracker/pii_scrubber"
5
5
  require "forge_ops_tracker/sql_statement"
6
6
  require "forge_ops_tracker/event_builder"
7
+ require "forge_ops_tracker/delivery_status"
7
8
  require "forge_ops_tracker/client"
8
9
  require "forge_ops_tracker/delivery_queue"
9
10
  require "forge_ops_tracker/change"
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.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -109,6 +109,7 @@ files:
109
109
  - lib/forge_ops_tracker/client.rb
110
110
  - lib/forge_ops_tracker/configuration.rb
111
111
  - lib/forge_ops_tracker/delivery_queue.rb
112
+ - lib/forge_ops_tracker/delivery_status.rb
112
113
  - lib/forge_ops_tracker/error_subscriber.rb
113
114
  - lib/forge_ops_tracker/event_builder.rb
114
115
  - lib/forge_ops_tracker/failure_event_buffer.rb