quonfig 1.4.1 → 1.6.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.
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Quonfig
6
+ module Telemetry
7
+ # Monotonic milliseconds for the telemetry transport: the 30s floor,
8
+ # Retry-After, retained-batch age and the WARN cadence all read it. Tests
9
+ # inject a manual clock through TelemetryReporter.build(clock:).
10
+ module MonotonicClock
11
+ def self.now_ms
12
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
13
+ end
14
+ end
15
+
16
+ # Telemetry transport policy (qfg-y8je.8; policy P1-P10 in
17
+ # project/plans/2026-09-24-sdk-telemetry-transport-policy.md, contract tests
18
+ # in integration-test-data/chaos/telemetry-transport-contract.md; mirrors
19
+ # sdk-node's src/telemetry/transportQueue.ts).
20
+ #
21
+ # Owns the retained queue of serialized batches, the send gate (30s floor
22
+ # after a failure + Retry-After), the drain loop, disable-on-auth and the P7
23
+ # logging episodes. It knows nothing about aggregators or payload shape: it
24
+ # stores and resends opaque bytes, never re-serializing or merging them.
25
+ #
26
+ # Not thread-safe on its own: TelemetryReporter serializes every call under
27
+ # its tick mutex, which is also what keeps one POST in flight (P2).
28
+ class TransportQueue
29
+ LOG = Quonfig::InternalLogger.new(self)
30
+
31
+ # No send sooner than this after a failed POST (P4).
32
+ RESEND_FLOOR_MS = 30_000
33
+ # Retry-After is honored up to this (P4).
34
+ RETRY_AFTER_CAP_MS = 600_000
35
+ # At most one drop WARN per this interval while dropping continues (P7).
36
+ DROP_WARN_INTERVAL_MS = 600_000
37
+ # close() gives the live window one POST with this deadline (P8).
38
+ SHUTDOWN_FLUSH_DEADLINE_MS = 5_000
39
+
40
+ # Outcome of one POST that got an HTTP response.
41
+ Result = Struct.new(:status, :retry_after, :body_snippet, keyword_init: true)
42
+
43
+ Batch = Struct.new(:body, :bytes, :created_at, :oversize, keyword_init: true)
44
+
45
+ # 2xx -> :ok; 401, 403, 404 -> :auth; 408, 429, 5xx -> :retryable; every
46
+ # other status (other 4xx, 3xx, 1xx) -> :rejected (P3).
47
+ def self.classify_status(status)
48
+ return :ok if status >= 200 && status < 300
49
+ return :auth if [401, 403, 404].include?(status)
50
+ return :retryable if [408, 429].include?(status) || (status >= 500 && status < 600)
51
+
52
+ :rejected
53
+ end
54
+
55
+ # Parse a Retry-After header into a wait in ms: delta-seconds, or an
56
+ # HTTP-date relative to now (past dates -> 0). Unparseable -> nil.
57
+ # Clamped to RETRY_AFTER_CAP_MS.
58
+ def self.parse_retry_after_ms(header, wall_now: Time.now)
59
+ return nil if header.nil?
60
+
61
+ value = header.to_s.strip
62
+ return nil if value.empty?
63
+
64
+ ms =
65
+ if value.match?(/\A\d+\z/)
66
+ value.to_i * 1000
67
+ else
68
+ begin
69
+ [((Time.httpdate(value) - wall_now) * 1000).round, 0].max
70
+ rescue ArgumentError
71
+ return nil
72
+ end
73
+ end
74
+ [ms, RETRY_AFTER_CAP_MS].min
75
+ end
76
+
77
+ # +sender+ is called as +sender.call(body, timeout_ms)+ and returns a
78
+ # Result, or raises (a Faraday/Timeout error for a timeout, anything else
79
+ # for a network failure).
80
+ def initialize(sender:, telemetry_url:, timeout_ms:, max_retained_batches:,
81
+ max_retained_bytes:, max_retained_age_ms:, clock: MonotonicClock,
82
+ on_disabled: nil)
83
+ @sender = sender
84
+ @telemetry_url = telemetry_url
85
+ @timeout_ms = timeout_ms
86
+ @max_retained_batches = max_retained_batches
87
+ @max_retained_bytes = max_retained_bytes
88
+ @max_retained_age_ms = max_retained_age_ms
89
+ @clock = clock
90
+ @on_disabled = on_disabled
91
+
92
+ @queue = []
93
+ @in_flight = false
94
+ @last_failure_at = nil
95
+ @retry_after_until = nil
96
+ @disabled = false
97
+
98
+ # Outage episode (P7).
99
+ @failures_since_success = 0
100
+ @first_failure_at = nil
101
+ @last_result = nil
102
+ @last_drop_warn_at = nil
103
+ @drops_since_warn = 0
104
+ @drops_this_outage = 0
105
+
106
+ # Rejected-batch (other 4xx) cadence.
107
+ @last_reject_error_at = nil
108
+ @rejects_since_error = 0
109
+ end
110
+
111
+ def in_flight? = @in_flight
112
+ def disabled? = @disabled
113
+
114
+ # Every queued batch, including a not-yet-sent oversize one.
115
+ def retained_count = @queue.size
116
+
117
+ def retained_bytes = @queue.sum(&:bytes)
118
+
119
+ # Discard batches older than the max age (strictly greater). Tick step 2.
120
+ def expire
121
+ now = @clock.now_ms
122
+ while (head = @queue.first) && now - head.created_at > @max_retained_age_ms
123
+ @queue.shift
124
+ record_drop("batch older than #{(@max_retained_age_ms / 60_000.0).round} min")
125
+ end
126
+ end
127
+
128
+ # The 30s floor after a failure and any Retry-After have both elapsed.
129
+ # Tick step 3.
130
+ def send_allowed?
131
+ now = @clock.now_ms
132
+ return false if @last_failure_at && now < @last_failure_at + RESEND_FLOOR_MS
133
+ return false if @retry_after_until && now < @retry_after_until
134
+
135
+ true
136
+ end
137
+
138
+ # Append a serialized window and enforce the caps, dropping oldest. An
139
+ # oversize batch is never counted against the caps and never evicted by
140
+ # them: it is sent once and then dropped (see #drain). Tick step 4.
141
+ def append(body)
142
+ oversize = body.bytesize > @max_retained_bytes
143
+ @queue << Batch.new(body: body, bytes: body.bytesize, created_at: @clock.now_ms, oversize: oversize)
144
+
145
+ kept = @queue.reject(&:oversize)
146
+ count = kept.size
147
+ bytes = kept.sum(&:bytes)
148
+ while count > @max_retained_batches || bytes > @max_retained_bytes
149
+ index = @queue.index { |b| !b.oversize }
150
+ break if index.nil?
151
+
152
+ evicted = @queue.delete_at(index)
153
+ count -= 1
154
+ bytes -= evicted.bytes
155
+ record_drop('retained queue full')
156
+ end
157
+ end
158
+
159
+ # POST queued batches oldest-first, one at a time; stop at the first
160
+ # failure. Tick step 5.
161
+ def drain
162
+ until @queue.empty? || @disabled
163
+ batch = @queue.first
164
+ outcome = post(batch.body, @timeout_ms)
165
+
166
+ if outcome.is_a?(String)
167
+ on_retryable_failure(batch, outcome, nil)
168
+ break
169
+ end
170
+
171
+ case self.class.classify_status(outcome.status)
172
+ when :ok
173
+ @queue.shift
174
+ on_success
175
+ when :retryable
176
+ on_retryable_failure(batch, outcome.status.to_s, outcome.retry_after)
177
+ break
178
+ when :auth
179
+ disable(outcome.status)
180
+ return
181
+ else
182
+ # Rejected: drop this batch, report, carry on with the next one.
183
+ @queue.shift
184
+ on_rejected(outcome.status, batch.bytes, outcome.body_snippet)
185
+ end
186
+ end
187
+
188
+ # Oversize batches are never carried across ticks.
189
+ @queue.select(&:oversize).each do |batch|
190
+ remove(batch)
191
+ record_drop('batch larger than the byte cap')
192
+ end
193
+ end
194
+
195
+ # close(): one POST of the live window bounded by +deadline_ms+. Never
196
+ # retains, never touches the outage episode, never raises.
197
+ def send_final(body, deadline_ms)
198
+ outcome = post(body, deadline_ms)
199
+ result =
200
+ if outcome.is_a?(String)
201
+ outcome
202
+ elsif self.class.classify_status(outcome.status) != :ok
203
+ outcome.status.to_s
204
+ end
205
+ return if result.nil?
206
+
207
+ LOG.debug "Telemetry final flush at shutdown failed (#{result}); #{body.bytesize} bytes dropped, " \
208
+ "#{retained_count} retained batch(es) abandoned"
209
+ end
210
+
211
+ private
212
+
213
+ # Remove by identity (two batches may carry equal bytes).
214
+ def remove(batch)
215
+ @queue.reject! { |b| b.equal?(batch) }
216
+ end
217
+
218
+ # One POST. Returns a Result, or a String describing a request that got
219
+ # no HTTP response ("timeout", "network error: ...").
220
+ def post(body, timeout_ms)
221
+ @in_flight = true
222
+ @sender.call(body, timeout_ms)
223
+ rescue Faraday::TimeoutError, Timeout::Error
224
+ 'timeout'
225
+ rescue StandardError => e
226
+ "network error: #{e.class}: #{e.message}"
227
+ ensure
228
+ @in_flight = false
229
+ end
230
+
231
+ def on_success
232
+ return if @failures_since_success.zero?
233
+
234
+ seconds = ((@clock.now_ms - (@first_failure_at || @clock.now_ms)) / 1000.0).round
235
+ LOG.info "Telemetry recovered: POST succeeded after #{@failures_since_success} failed attempt(s) " \
236
+ "over #{seconds}s; #{@drops_this_outage} batch(es) were dropped."
237
+ @failures_since_success = 0
238
+ @first_failure_at = nil
239
+ @drops_this_outage = 0
240
+ @last_drop_warn_at = nil
241
+ @drops_since_warn = 0
242
+ end
243
+
244
+ def on_retryable_failure(batch, result, retry_after)
245
+ now = @clock.now_ms
246
+ @failures_since_success += 1
247
+ @first_failure_at ||= now
248
+ @last_failure_at = now
249
+ @last_result = result
250
+ wait = self.class.parse_retry_after_ms(retry_after)
251
+ @retry_after_until = now + wait if wait
252
+
253
+ next_ms = [@last_failure_at + RESEND_FLOOR_MS, @retry_after_until || 0].max - now
254
+ LOG.debug "Telemetry POST failed (#{result}); #{retained_count} batch(es) / #{retained_bytes} bytes " \
255
+ "retained, next send in >= #{(next_ms / 1000.0).ceil}s"
256
+
257
+ return unless batch.oversize
258
+
259
+ remove(batch)
260
+ record_drop('batch larger than the byte cap')
261
+ end
262
+
263
+ def disable(status)
264
+ hint = status == 404 ? 'wrong telemetry_url' : 'the SDK key was rejected'
265
+ LOG.error "Telemetry disabled for this process: #{@telemetry_url} answered #{status} (#{hint}). " \
266
+ 'Flag evaluation is unaffected.'
267
+ @queue.clear
268
+ @disabled = true
269
+ @on_disabled&.call
270
+ end
271
+
272
+ def on_rejected(status, bytes, body_snippet)
273
+ now = @clock.now_ms
274
+ if @last_reject_error_at.nil? || now - @last_reject_error_at >= DROP_WARN_INTERVAL_MS
275
+ more = @rejects_since_error.positive? ? ", #{@rejects_since_error} more since the last report" : ''
276
+ LOG.error "Telemetry batch rejected with #{status} and dropped (#{bytes} bytes#{more}): " \
277
+ "#{body_snippet}. This is likely an SDK bug; please report it."
278
+ @last_reject_error_at = now
279
+ @rejects_since_error = 0
280
+ else
281
+ @rejects_since_error += 1
282
+ LOG.debug "Telemetry batch rejected with #{status} and dropped (#{bytes} bytes)"
283
+ end
284
+ end
285
+
286
+ def record_drop(reason)
287
+ now = @clock.now_ms
288
+ @drops_since_warn += 1
289
+ @drops_this_outage += 1
290
+ last_result = @last_result || 'none'
291
+ if @last_drop_warn_at.nil?
292
+ LOG.warn "Telemetry is dropping data: #{reason} (last POST result: #{last_result}). " \
293
+ "#{@drops_this_outage} batch(es) dropped so far; retained queue " \
294
+ "#{retained_count}/#{@max_retained_batches} batches, #{retained_bytes} bytes. " \
295
+ 'Flag evaluation is unaffected; further drops log at debug with a summary every 10 min.'
296
+ @last_drop_warn_at = now
297
+ @drops_since_warn = 0
298
+ elsif now - @last_drop_warn_at >= DROP_WARN_INTERVAL_MS
299
+ minutes = ((now - @last_drop_warn_at) / 60_000.0).round
300
+ LOG.warn "Telemetry still dropping data: #{@drops_since_warn} batch(es) dropped in the last " \
301
+ "#{minutes} min (last POST result: #{last_result}); retained queue #{retained_count} " \
302
+ "batches, #{retained_bytes} bytes."
303
+ @last_drop_warn_at = now
304
+ @drops_since_warn = 0
305
+ else
306
+ LOG.debug "Telemetry dropped a batch: #{reason}; #{@drops_since_warn} since the last warning"
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.4.1'
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/quonfig.rb CHANGED
@@ -62,6 +62,7 @@ require 'quonfig/telemetry/context_shape_aggregator'
62
62
  require 'quonfig/telemetry/example_contexts_aggregator'
63
63
  require 'quonfig/telemetry/evaluation_summaries_aggregator'
64
64
  require 'quonfig/telemetry/failover_aggregator'
65
+ require 'quonfig/telemetry/transport_queue'
65
66
  require 'quonfig/telemetry/telemetry_reporter'
66
67
  require 'quonfig/client'
67
68
  require 'quonfig/bound_client'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-11 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -132,6 +132,7 @@ files:
132
132
  - lib/quonfig/telemetry/example_contexts_aggregator.rb
133
133
  - lib/quonfig/telemetry/failover_aggregator.rb
134
134
  - lib/quonfig/telemetry/telemetry_reporter.rb
135
+ - lib/quonfig/telemetry/transport_queue.rb
135
136
  - lib/quonfig/time_helpers.rb
136
137
  - lib/quonfig/types.rb
137
138
  - lib/quonfig/version.rb