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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'set'
4
5
 
5
6
  module Quonfig
6
7
  # Public-API resolver: looks up a config by key in a ConfigStore and runs
@@ -16,6 +17,7 @@ module Quonfig
16
17
  # production read path (with config_loader, SSE updates, telemetry), see
17
18
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
18
19
  class Resolver
20
+ LOG = Quonfig::InternalLogger.new(self)
19
21
  TRUE_VALUES = %w[true 1 t yes].freeze
20
22
  # Prefix the eval-summary aggregator stamps onto redacted confidential
21
23
  # values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
@@ -29,6 +31,9 @@ module Quonfig
29
31
  def initialize(store, evaluator)
30
32
  @store = store
31
33
  @evaluator = evaluator
34
+ # Config keys already warned about a missing hash property (qfg-9dxb.8).
35
+ @hash_missing_warned = Set.new
36
+ @hash_missing_mutex = Mutex.new
32
37
  end
33
38
 
34
39
  def raw(key)
@@ -50,7 +55,8 @@ module Quonfig
50
55
  return nil if eval_result.nil?
51
56
 
52
57
  weighted_index = nil
53
- resolved_value = resolve_value(eval_result.value, config, context) do |idx|
58
+ hash_missing = []
59
+ resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
54
60
  weighted_index = idx
55
61
  end
56
62
  EvalResult.new(
@@ -58,7 +64,8 @@ module Quonfig
58
64
  rule_index: eval_result.rule_index,
59
65
  config: config,
60
66
  weighted_value_index: weighted_index,
61
- reportable_value: redacted_reportable_value(eval_result.value)
67
+ reportable_value: redacted_reportable_value(eval_result.value),
68
+ hash_property_missing: !hash_missing.empty?
62
69
  )
63
70
  end
64
71
 
@@ -68,32 +75,42 @@ module Quonfig
68
75
  # - confidential + decryptWith → look up the key config, decrypt
69
76
  # - everything else passes through unchanged
70
77
  def resolve_value(value, config, context = nil, &on_weighted_index)
78
+ resolve_value_on_path(value, config, context, [], &on_weighted_index)
79
+ end
80
+
81
+ # Integration shims for code that expects a ConfigResolver. Keep these
82
+ # narrow; the real ConfigResolver still owns the production hot path.
83
+ def symbolize_json_names?
84
+ false
85
+ end
86
+
87
+ private
88
+
89
+ # resolve_value plus +key_path+: the config keys already being resolved
90
+ # above this one through decryptWith, so a decryptWith cycle raises a
91
+ # DecryptionError instead of recursing until SystemStackError
92
+ # (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
93
+ # gets an entry for each weighted rollout whose hash property was missing
94
+ # from the context (qfg-9dxb.8).
95
+ def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
71
96
  return nil if value.nil?
72
97
 
73
98
  type = vget(value, :type, 'type')
74
99
 
75
100
  return resolve_provided(value, config) if type == 'provided'
76
101
 
77
- return resolve_weighted(value, config, context, &on_weighted_index) if type == 'weighted_values'
102
+ return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
78
103
 
79
104
  confidential = vget(value, :confidential, 'confidential')
80
105
  decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
81
106
  if confidential && decrypt_with && !decrypt_with.to_s.empty?
82
107
  return resolve_decryption(value, config, context,
83
- decrypt_with)
108
+ decrypt_with, key_path)
84
109
  end
85
110
 
86
111
  value
87
112
  end
88
113
 
89
- # Integration shims for code that expects a ConfigResolver. Keep these
90
- # narrow; the real ConfigResolver still owns the production hot path.
91
- def symbolize_json_names?
92
- false
93
- end
94
-
95
- private
96
-
97
114
  # If +value+ is confidential or has a decryptWith key, return the
98
115
  # `*****<5-hex>` redacted string the eval-summary telemetry aggregator
99
116
  # should ship in place of the resolved plaintext. The hash is computed
@@ -162,7 +179,12 @@ module Quonfig
162
179
  # (or fall back to a per-call random) into [0,1), then walk the variant
163
180
  # weights until cumulative weight >= bucket. Recurses through
164
181
  # resolve_value so nested provided/encrypted variants work too.
165
- def resolve_weighted(value, config, context, &on_weighted_index)
182
+ #
183
+ # A hash property that is configured but missing from the context (or
184
+ # nil) hashes an empty value, the same bucket as a present "", and warns
185
+ # once per config key (qfg-9dxb.8). With no hash property configured the
186
+ # pick stays random per call.
187
+ def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
166
188
  payload = vget(value, :value, 'value') || {}
167
189
  weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
168
190
  return value unless weighted.is_a?(Array) && !weighted.empty?
@@ -182,23 +204,42 @@ module Quonfig
182
204
  end
183
205
 
184
206
  cfg_key = config_key(config)
207
+ if hash_value.nil? && !hash_property.to_s.empty?
208
+ hash_value = ''
209
+ hash_missing&.push(true)
210
+ warn_hash_property_missing(cfg_key, hash_property)
211
+ end
185
212
  picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
186
213
  variant, index = picker.resolve
187
214
  on_weighted_index&.call(index)
188
215
  variant_value = vget(variant, :value, 'value')
189
- resolve_value(variant_value, config, context, &on_weighted_index)
216
+ resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
217
+ end
218
+
219
+ def warn_hash_property_missing(cfg_key, hash_property)
220
+ return unless @hash_missing_mutex.synchronize { @hash_missing_warned.add?(cfg_key) }
221
+
222
+ LOG.warn "quonfig: weighted rollout for \"#{cfg_key}\" hashes on \"#{hash_property}\" " \
223
+ 'which is missing from context; hashing an empty value instead'
190
224
  end
191
225
 
192
226
  # Recursively resolve the decryption-key config (it may itself be a
193
227
  # provided ENV_VAR), then AES-GCM decrypt the value with that key.
194
- def resolve_decryption(value, config, context, decrypt_with)
228
+ def resolve_decryption(value, config, context, decrypt_with, key_path)
229
+ key_path += [config_key(config).to_s]
230
+ if key_path.include?(decrypt_with.to_s)
231
+ raise Quonfig::Errors::DecryptionError.new(
232
+ config_key(config), %(decryption key config "#{decrypt_with}" is part of a decryptWith cycle)
233
+ )
234
+ end
235
+
195
236
  key_cfg = @store.get(decrypt_with)
196
237
  raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
197
238
 
198
239
  key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
199
240
  raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
200
241
 
201
- resolved_key = resolve_value(key_match.value, key_cfg, context)
242
+ resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
202
243
  secret_key = vget(resolved_key, :value, 'value').to_s
203
244
  raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
204
245
 
@@ -604,6 +604,14 @@ module Quonfig
604
604
  return nil
605
605
  end
606
606
 
607
+ # qfg-9dxb.3: drop a JSON event that is not a config envelope (no meta
608
+ # object with a non-empty version) exactly like malformed JSON — it must
609
+ # never reach install and wipe an established client's keys.
610
+ unless Quonfig::ConfigEnvelope.wire_envelope?(parsed)
611
+ (@logger || LOG).error 'SSE Streaming Error: non-envelope event (missing meta.version); dropped'
612
+ return nil
613
+ end
614
+
607
615
  envelope = Quonfig::ConfigEnvelope.new(
608
616
  configs: parsed['configs'] || [],
609
617
  meta: parsed['meta'] || {}
@@ -10,11 +10,16 @@ module Quonfig
10
10
  # matching sdk-node and sdk-go. This is NOT the old Prefab protobuf.
11
11
  class ExampleContextsAggregator
12
12
  ONE_HOUR_SECONDS = 60 * 60
13
+ # Bound on the once-per-hour rate-limit map (P6 of the telemetry
14
+ # transport policy, qfg-y8je.8): a new key arriving when it is full
15
+ # prunes expired entries, and is not recorded if it is still full.
16
+ SEEN_CAP = 100_000
13
17
 
14
18
  attr_reader :data, :cache
15
19
 
16
- def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS)
20
+ def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS, seen_cap: SEEN_CAP)
17
21
  @max_contexts = max_contexts
22
+ @seen_cap = seen_cap
18
23
  @data = Concurrent::Array.new
19
24
  @cache = Quonfig::RateLimitCache.new(rate_limit_seconds)
20
25
  end
@@ -30,6 +35,7 @@ module Quonfig
30
35
  return if key.nil? || key.empty?
31
36
 
32
37
  return unless @data.size < @max_contexts && !@cache.fresh?(key)
38
+ return unless room_in_cache?
33
39
 
34
40
  @cache.set(key)
35
41
  @data.push([Quonfig::TimeHelpers.now_in_ms, context])
@@ -59,6 +65,13 @@ module Quonfig
59
65
 
60
66
  private
61
67
 
68
+ def room_in_cache?
69
+ return true if @cache.data.size < @seen_cap
70
+
71
+ @cache.prune
72
+ @cache.data.size < @seen_cap
73
+ end
74
+
62
75
  def grouped_key_for(context)
63
76
  return context.grouped_key if context.respond_to?(:grouped_key)
64
77
 
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Quonfig
4
6
  module Telemetry
5
- # Owns the background thread that periodically drains the context
6
- # aggregators and POSTs a JSON telemetry batch to
7
- # +<telemetry_destination>/api/v1/telemetry/+.
7
+ # Owns the background thread that drains the aggregators once per tick and
8
+ # hands the serialized window to a TransportQueue, which retains failed
9
+ # batches byte-for-byte and resends them under the telemetry transport
10
+ # policy (qfg-y8je.8: 60s ticks, one POST in flight, 15s timeout / 5s
11
+ # connect, 30s floor after a failure, Retry-After up to 10 min,
12
+ # 5 batches / 2MB / 5 min retention, disable on 401/403/404; see
13
+ # TransportQueue).
8
14
  #
9
15
  # Wire shape matches api-telemetry's TelemetryEventsSchema:
10
16
  #
@@ -24,16 +30,47 @@ module Quonfig
24
30
  class TelemetryReporter
25
31
  LOG = Quonfig::InternalLogger.new(self)
26
32
 
27
- DEFAULT_INITIAL_DELAY_SECONDS = 8
28
- DEFAULT_MAX_DELAY_SECONDS = 600
33
+ TELEMETRY_PATH = '/api/v1/telemetry/'
34
+ BODY_SNIPPET_BYTES = 1024
35
+ private_constant :BODY_SNIPPET_BYTES
36
+
37
+ # Build the aggregators the options enable and a reporter over them, or
38
+ # nil when every collector is off. Client#initialize_telemetry uses this;
39
+ # +clock+ is a test seam (defaults to the monotonic clock).
40
+ def self.build(options:, instance_hash:, failover_aggregator: nil, clock: nil)
41
+ shapes = (ContextShapeAggregator.new(max_shapes: options.collect_max_shapes) if options.collect_max_shapes.to_i.positive?)
42
+ examples = (ExampleContextsAggregator.new(max_contexts: options.collect_max_example_contexts) if options.collect_max_example_contexts.to_i.positive?)
43
+ summaries = (EvaluationSummariesAggregator.new(max_keys: options.collect_max_evaluation_summaries) if options.collect_max_evaluation_summaries.to_i.positive?)
44
+ return nil if shapes.nil? && examples.nil? && summaries.nil?
45
+
46
+ new(
47
+ options: options,
48
+ instance_hash: instance_hash,
49
+ context_shape_aggregator: shapes,
50
+ example_contexts_aggregator: examples,
51
+ evaluation_summaries_aggregator: summaries,
52
+ failover_aggregator: failover_aggregator,
53
+ clock: clock
54
+ )
55
+ end
56
+
57
+ # Resolved transport settings (the +telemetry_*+ options and
58
+ # +collect_sync_interval+). +flush_interval_ms+ is nil when the interval
59
+ # is a callable.
60
+ attr_reader :config
29
61
 
62
+ # +sync_interval+ (seconds, or a callable returning seconds) defaults to
63
+ # +options.collect_sync_interval+ (60). +http_connection+ is a test seam:
64
+ # anything answering +post(path, body_string)+ with a response that has
65
+ # +status+.
30
66
  def initialize(options:, instance_hash:,
31
67
  context_shape_aggregator: nil,
32
68
  example_contexts_aggregator: nil,
33
69
  evaluation_summaries_aggregator: nil,
34
70
  failover_aggregator: nil,
35
71
  sync_interval: nil,
36
- http_connection: nil)
72
+ http_connection: nil,
73
+ clock: nil)
37
74
  @options = options
38
75
  @instance_hash = instance_hash
39
76
  @sdk_key = options.sdk_key
@@ -47,8 +84,31 @@ module Quonfig
47
84
  # aggregator at the failover call sites; the reporter only drains it.
48
85
  @failover_aggregator = failover_aggregator
49
86
  @http_connection = http_connection
50
- @sync_interval = calculate_sync_interval(sync_interval)
51
- @stopped = Concurrent::AtomicBoolean.new(false)
87
+ @sync_interval = sync_interval.nil? ? options.collect_sync_interval : sync_interval
88
+ @config = {
89
+ flush_interval_ms: @sync_interval.is_a?(Numeric) ? (@sync_interval * 1000).to_i : nil,
90
+ timeout_ms: options.telemetry_timeout_ms,
91
+ connect_timeout_ms: options.telemetry_connect_timeout_ms,
92
+ max_retained_batches: options.telemetry_max_retained_batches,
93
+ max_retained_bytes: options.telemetry_max_retained_bytes,
94
+ max_retained_age_ms: options.telemetry_max_retained_age_ms
95
+ }.freeze
96
+ @queue = TransportQueue.new(
97
+ sender: method(:post_batch),
98
+ telemetry_url: "#{@telemetry_destination}#{TELEMETRY_PATH}",
99
+ timeout_ms: @config[:timeout_ms],
100
+ max_retained_batches: @config[:max_retained_batches],
101
+ max_retained_bytes: @config[:max_retained_bytes],
102
+ max_retained_age_ms: @config[:max_retained_age_ms],
103
+ clock: clock || MonotonicClock,
104
+ on_disabled: method(:on_disabled)
105
+ )
106
+ # Held for a whole tick (serialize + drain), so at most one POST is in
107
+ # flight (P2); a tick that finds it held is skipped.
108
+ @tick_mutex = Mutex.new
109
+ @state_mutex = Mutex.new
110
+ @wake = ConditionVariable.new
111
+ @closed = false
52
112
  @thread = nil
53
113
  @at_exit_registered = false
54
114
  # Set on #start. Everything that can EMIT is gated on it so a forked
@@ -69,97 +129,104 @@ module Quonfig
69
129
  # summaries are recorded separately via
70
130
  # +record_evaluation(...)+ since they require the evaluation result.
71
131
  def record(context)
72
- return if context.nil?
132
+ return if context.nil? || @queue.disabled?
73
133
 
74
134
  @context_shape_aggregator&.push(context)
75
135
  @example_contexts_aggregator&.record(context)
76
136
  end
77
137
 
78
138
  def record_evaluation(**kwargs)
139
+ return if @queue.disabled?
140
+
79
141
  @evaluation_summaries_aggregator&.record(**kwargs)
80
142
  end
81
143
 
82
144
  def start
83
145
  return if @thread&.alive?
84
146
  return unless enabled?
147
+ return if @closed || @queue.disabled?
85
148
 
86
149
  # Claim ownership for THIS process. fork(2) copies the reporter, its
87
150
  # aggregators, and the process-wide at_exit closure registered below;
88
151
  # the pid recorded here is what lets the copy know it is not the
89
152
  # owner and must stay silent (qfg-lv4n.1, dd-trace-rb's pattern).
90
153
  @owner_pid = Process.pid
91
- @stopped.make_false
92
154
  register_at_exit_handler
93
155
  @thread = Thread.new do
94
156
  Thread.current.name = 'quonfig-telemetry-reporter'
95
157
  LOG.debug "Telemetry reporter started instance_hash=#{@instance_hash} destination=#{@telemetry_destination}"
96
-
97
- until @stopped.true?
98
- begin
99
- sleep_duration = @sync_interval.call
100
- slept = 0.0
101
- step = 0.5
102
- while slept < sleep_duration && !@stopped.true?
103
- sleep([step, sleep_duration - slept].min)
104
- slept += step
105
- end
106
- break if @stopped.true?
107
-
108
- sync
109
- rescue StandardError => e
110
- LOG.warn "[quonfig] Telemetry reporter error: #{e.class}: #{e.message}"
111
- end
112
- end
158
+ run_loop
113
159
  end
114
160
  end
115
161
 
116
- def stop
117
- return if foreign_process?('stop')
162
+ # One tick of the contract's model: skip if closed, disabled or a POST is
163
+ # in flight (P2; the live window keeps aggregating); expire aged batches;
164
+ # skip if the 30s floor or Retry-After has not elapsed; serialize the
165
+ # live window once and append it; drain oldest-first.
166
+ def tick
167
+ return if foreign_process?('tick')
168
+ return unless @tick_mutex.try_lock
118
169
 
119
- @stopped.make_true
120
- thread = @thread
121
- @thread = nil
122
- thread&.wakeup if thread&.alive?
123
- # Final drain attempt on stop so tests / short-lived processes
124
- # don't silently drop pending telemetry.
125
170
  begin
126
- sync
127
- rescue StandardError => e
128
- LOG.debug "[quonfig] Final telemetry sync failed: #{e.class}: #{e.message}"
171
+ run_tick
172
+ ensure
173
+ @tick_mutex.unlock
129
174
  end
130
175
  end
131
176
 
132
- # Drain all aggregators and POST the batch. Public so tests can
133
- # trigger a sync without waiting for the background loop.
177
+ # Send the live window now. Waits for an in-flight POST first (bounded by
178
+ # the request timeout), then runs a tick, so after a failure it respects
179
+ # the 30s floor and Retry-After. Never raises.
134
180
  #
135
181
  # Silent in any process other than the one that started the reporter:
136
182
  # after a fork the child holds a full copy of the PARENT's un-flushed
137
183
  # window, and the parent is still going to flush it itself.
138
- def sync
184
+ def flush
139
185
  return if foreign_process?('sync')
140
186
 
141
- events = []
142
- if (summaries_event = @evaluation_summaries_aggregator&.drain_event)
143
- events << summaries_event
144
- end
145
- if (shape_event = @context_shape_aggregator&.drain_event)
146
- events << shape_event
147
- end
148
- if (example_event = @example_contexts_aggregator&.drain_event)
149
- events << example_event
150
- end
151
- if (failover_event = @failover_aggregator&.drain_event)
152
- events << failover_event
187
+ @tick_mutex.synchronize { run_tick }
188
+ rescue StandardError => e
189
+ LOG.debug "Telemetry flush failed: #{e.class}: #{e.message}"
190
+ end
191
+ alias sync flush
192
+
193
+ # Shutdown (P8): stop the reporter thread (aborting an in-flight POST),
194
+ # then give the live window one POST with a 5s deadline. The retained
195
+ # queue is not drained. Idempotent; never raises; never blocks exit for
196
+ # longer than that deadline.
197
+ def close
198
+ return if foreign_process?('close')
199
+
200
+ thread = @state_mutex.synchronize do
201
+ return if @closed
202
+
203
+ @closed = true
204
+ current = @thread
205
+ @thread = nil
206
+ current
153
207
  end
208
+ stop_thread(thread)
209
+ return if @queue.disabled?
154
210
 
155
- return if events.empty?
211
+ body = serialize_window
212
+ return if body.nil?
156
213
 
157
- payload = {
158
- 'instanceHash' => @instance_hash,
159
- 'events' => events
214
+ @queue.send_final(body, [TransportQueue::SHUTDOWN_FLUSH_DEADLINE_MS, @config[:timeout_ms]].min)
215
+ rescue StandardError => e
216
+ LOG.debug "Telemetry close failed: #{e.class}: #{e.message}"
217
+ end
218
+ alias stop close
219
+
220
+ # Test-visible state (the contract's retained_count / retained_bytes /
221
+ # telemetry_enabled).
222
+ def debug_state
223
+ {
224
+ retained_count: @queue.retained_count,
225
+ retained_bytes: @queue.retained_bytes,
226
+ enabled: !@queue.disabled?,
227
+ in_flight: @queue.in_flight?,
228
+ thread_alive: @thread&.alive? || false
160
229
  }
161
-
162
- post(payload)
163
230
  end
164
231
 
165
232
  # Visible for tests.
@@ -180,7 +247,7 @@ module Quonfig
180
247
  # not exist in the child, and the HTTP connection's fd is shared with
181
248
  # the parent.
182
249
  def discard_inherited!
183
- @stopped.make_true
250
+ @closed = true
184
251
  @thread = nil
185
252
  @context_shape_aggregator = nil
186
253
  @example_contexts_aggregator = nil
@@ -190,6 +257,106 @@ module Quonfig
190
257
 
191
258
  private
192
259
 
260
+ def run_tick
261
+ return if @closed || @queue.disabled?
262
+
263
+ @queue.expire
264
+ return unless @queue.send_allowed?
265
+
266
+ body = serialize_window
267
+ @queue.append(body) if body
268
+ @queue.drain
269
+ end
270
+
271
+ # Fixed cadence: tick k fires at k * interval regardless of how long a
272
+ # drain takes. The interval never grows (the old exponential backoff,
273
+ # which grew even on success, is gone: P4).
274
+ def run_loop
275
+ next_at = MonotonicClock.now_ms + next_interval_ms
276
+ until loop_done?
277
+ wait_ms = next_at - MonotonicClock.now_ms
278
+ if wait_ms.positive?
279
+ @state_mutex.synchronize { @wake.wait(@state_mutex, wait_ms / 1000.0) unless loop_done? }
280
+ next
281
+ end
282
+ next_at += next_interval_ms
283
+ next_at = MonotonicClock.now_ms + next_interval_ms if next_at <= MonotonicClock.now_ms
284
+ begin
285
+ tick
286
+ rescue StandardError => e
287
+ LOG.debug "Telemetry tick failed: #{e.class}: #{e.message}"
288
+ end
289
+ end
290
+ end
291
+
292
+ def loop_done?
293
+ @closed || @queue.disabled?
294
+ end
295
+
296
+ def next_interval_ms
297
+ seconds = @sync_interval.respond_to?(:call) ? @sync_interval.call : @sync_interval
298
+ [(seconds.to_f * 1000).to_i, 1].max
299
+ end
300
+
301
+ def wake_thread
302
+ @state_mutex.synchronize { @wake.broadcast }
303
+ end
304
+
305
+ # Stop the reporter thread. A thread parked in its wait exits on the
306
+ # broadcast; one still busy (a POST in flight) is killed, which aborts
307
+ # the POST like sdk-node's close() does. The aborted batch is never resent.
308
+ def stop_thread(thread)
309
+ return if thread.nil? || thread == Thread.current || !thread.alive?
310
+
311
+ wake_thread
312
+ thread.kill unless thread.join(0.1)
313
+ end
314
+
315
+ def on_disabled
316
+ # Nothing aggregates for a dead endpoint: drop what the window holds.
317
+ serialize_window
318
+ wake_thread
319
+ end
320
+
321
+ # Drain the aggregators into one serialized payload. This is the only
322
+ # serialization: the queue stores and resends these exact bytes (P5, P9).
323
+ def serialize_window
324
+ events = [
325
+ @evaluation_summaries_aggregator&.drain_event,
326
+ @context_shape_aggregator&.drain_event,
327
+ @example_contexts_aggregator&.drain_event,
328
+ # nil unless the window saw failover activity.
329
+ @failover_aggregator&.drain_event
330
+ ].compact
331
+ return nil if events.empty?
332
+
333
+ JSON.generate('instanceHash' => @instance_hash, 'events' => events)
334
+ end
335
+
336
+ # The TransportQueue sender: one POST of +body+ verbatim.
337
+ def post_batch(body, timeout_ms)
338
+ response = telemetry_connection(timeout_ms).post(TELEMETRY_PATH, body)
339
+ headers = response.respond_to?(:headers) ? response.headers : nil
340
+ snippet = response.respond_to?(:body) ? response.body.to_s.byteslice(0, BODY_SNIPPET_BYTES) : ''
341
+ TransportQueue::Result.new(
342
+ status: response.status.to_i,
343
+ retry_after: headers && headers['retry-after'],
344
+ body_snippet: snippet
345
+ )
346
+ end
347
+
348
+ # A connection bounded by +timeout_ms+ overall and the connect timeout
349
+ # (never longer than +timeout_ms+) for TCP connect + TLS (P1).
350
+ def telemetry_connection(timeout_ms)
351
+ return @http_connection if @http_connection
352
+
353
+ Quonfig::HttpConnection.new(
354
+ @telemetry_destination, @sdk_key,
355
+ timeout_ms: timeout_ms,
356
+ open_timeout_ms: [@config[:connect_timeout_ms], timeout_ms].min
357
+ )
358
+ end
359
+
193
360
  # True when this reporter belongs to a different process — i.e. we are
194
361
  # a fork(2) copy. Never true before #start (nothing has been claimed,
195
362
  # and nothing was registered at_exit either).
@@ -204,7 +371,7 @@ module Quonfig
204
371
 
205
372
  # Rails / Passenger / Puma workers often terminate via SIGTERM without
206
373
  # a chance to call Client#stop. Register a Kernel.at_exit hook on
207
- # first start so the in-flight batch still gets flushed.
374
+ # first start so the live window still gets its one final flush.
208
375
  def register_at_exit_handler
209
376
  return if @at_exit_registered
210
377
 
@@ -212,62 +379,11 @@ module Quonfig
212
379
  @at_exit_registered = true
213
380
  end
214
381
 
215
- # Wait this long for the background reporter thread to exit before
216
- # giving up. Bounded so a thread blocked on a dead telemetry endpoint
217
- # can't hang process exit.
218
- AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
219
- private_constant :AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS
220
-
221
- # Idempotent final drain. Safe to call after #stop has already
222
- # drained: aggregators return nil when empty and #sync becomes a
223
- # no-op. Bounded so a stuck reporter thread or dead telemetry
382
+ # Idempotent final flush (#close). Safe after #stop: a second close is a
383
+ # no-op. Bounded by the 5s shutdown deadline, so a dead telemetry
224
384
  # endpoint can't hang process exit.
225
385
  def final_drain_on_exit
226
- return if foreign_process?('at_exit drain')
227
-
228
- @stopped.make_true
229
- thread = @thread
230
- @thread = nil
231
- if thread&.alive?
232
- thread.wakeup
233
- thread.join(AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS)
234
- end
235
- sync
236
- rescue StandardError => e
237
- LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
238
- end
239
-
240
- def post(payload)
241
- conn = http_connection
242
- return if conn.nil?
243
-
244
- response = conn.post('/api/v1/telemetry/', payload)
245
- status = response.respond_to?(:status) ? response.status : nil
246
- if status && status >= 400
247
- LOG.warn "[quonfig] Telemetry POST failed: #{status}"
248
- else
249
- LOG.debug "[quonfig] Telemetry POST ok: events=#{payload['events'].size}"
250
- end
251
- response
252
- end
253
-
254
- def http_connection
255
- @http_connection ||= begin
256
- return nil if @sdk_key.nil? || @telemetry_destination.nil?
257
-
258
- Quonfig::HttpConnection.new(@telemetry_destination, @sdk_key)
259
- end
260
- end
261
-
262
- def calculate_sync_interval(sync_interval)
263
- return proc { sync_interval } if sync_interval.is_a?(Numeric)
264
- return sync_interval if sync_interval.respond_to?(:call)
265
-
266
- Quonfig::ExponentialBackoff.new(
267
- initial_delay: DEFAULT_INITIAL_DELAY_SECONDS,
268
- max_delay: DEFAULT_MAX_DELAY_SECONDS,
269
- multiplier: 1.5
270
- )
386
+ close
271
387
  end
272
388
  end
273
389
  end