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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +63 -1
- data/lib/quonfig/client.rb +8 -34
- data/lib/quonfig/config_envelope.rb +16 -1
- data/lib/quonfig/config_loader.rb +61 -19
- data/lib/quonfig/evaluator.rb +25 -10
- data/lib/quonfig/http_connection.rb +12 -2
- data/lib/quonfig/options.rb +59 -5
- data/lib/quonfig/resolver.rb +57 -16
- data/lib/quonfig/sse_config_client.rb +8 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +14 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +231 -115
- data/lib/quonfig/telemetry/transport_queue.rb +311 -0
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
|
@@ -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
|
data/lib/quonfig/version.rb
CHANGED
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
|
+
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
|
+
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
|