quonfig 1.2.0 → 1.4.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.
@@ -31,6 +31,12 @@ module Quonfig
31
31
  # SSE does not change it).
32
32
  attr_reader :held_generation, :install_count
33
33
 
34
+ # Repoint the failover-telemetry sink. The one caller is
35
+ # Client#after_fork_in_child: a forked child gets fresh aggregators (the
36
+ # parent still owns and flushes its own), and the loader has to record
37
+ # into the child's copy rather than the inherited one (qfg-lv4n.1).
38
+ attr_writer :failover_aggregator
39
+
34
40
  # +store+: the Quonfig::ConfigStore to populate on successful fetch.
35
41
  # +options+: a Quonfig::Options instance (supplies sdk_key + config_api_urls).
36
42
  # +logger+: optional logger override (defaults to module LOG).
@@ -140,10 +140,21 @@ module Quonfig
140
140
 
141
141
  # In datadir mode the SDK evaluates config from a local workspace and does
142
142
  # not connect to the delivery service.
143
+ #
144
+ # NOTE: this says nothing about telemetry. Datadir mode is a config-DELIVERY
145
+ # mode; usage telemetry still flows whenever an SDK key is configured. See
146
+ # #telemetry_allowed?.
143
147
  def local_only?
144
148
  !@datadir.nil?
145
149
  end
146
150
 
151
+ # True when an SDK key is configured. The SDK key is what identifies the
152
+ # workspace telemetry is attributed to, so it is the single gate on whether
153
+ # any collection happens at all (qfg-j001 / qfg-5x9x).
154
+ def sdk_key?
155
+ !(@sdk_key.nil? || @sdk_key.to_s.empty?)
156
+ end
157
+
147
158
  def datadir?
148
159
  !@datadir.nil?
149
160
  end
@@ -202,6 +213,13 @@ module Quonfig
202
213
  # Debounce window in milliseconds. Filesystem events arriving
203
214
  # inside the window are coalesced into a single re-read. Ignored
204
215
  # when +:data_dir_auto_reload+ is +false+.
216
+ # @option options [Boolean] :allow_telemetry_in_local_mode (false)
217
+ # @deprecated No-op since 1.3.0 (qfg-5x9x). Telemetry is gated on SDK-key
218
+ # presence alone, so datadir mode no longer suppresses it and this flag
219
+ # has nothing left to unlock. Still accepted so existing callers keep
220
+ # working; slated for removal in 2.0.0. To turn telemetry off, use the
221
+ # standard opt-outs (+:collect_evaluation_summaries+ +false+,
222
+ # +:context_upload_mode+ +:none+).
205
223
  def init(
206
224
  api_urls: nil,
207
225
  telemetry_url: nil,
@@ -226,6 +244,7 @@ module Quonfig
226
244
  context_max_size: DEFAULT_MAX_EVAL_SUMMARIES,
227
245
  collect_evaluation_summaries: true,
228
246
  collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
247
+ # Deprecated no-op since 1.3.0 (qfg-5x9x) — see the @option doc above.
229
248
  allow_telemetry_in_local_mode: false,
230
249
  global_context: {},
231
250
  logger_key: nil,
@@ -287,6 +306,7 @@ module Quonfig
287
306
  @collect_sync_interval = collect_sync_interval
288
307
  @collect_evaluation_summaries = collect_evaluation_summaries
289
308
  @collect_max_evaluation_summaries = collect_max_evaluation_summaries
309
+ # Retained for back-compat only; nothing reads it (qfg-5x9x).
290
310
  @allow_telemetry_in_local_mode = allow_telemetry_in_local_mode
291
311
  @is_fork = false
292
312
  @global_context = global_context
@@ -342,8 +362,21 @@ module Quonfig
342
362
  end
343
363
  end
344
364
 
365
+ # The telemetry gate: an explicit per-collector opt-out (+option+) AND the
366
+ # presence of an SDK key. Mode is deliberately NOT part of this decision.
367
+ #
368
+ # qfg-5x9x: this used to read
369
+ # `option && (!local_only? || @allow_telemetry_in_local_mode)`
370
+ # which zeroed every collect_max_* the moment a datadir was set, so a
371
+ # datadir client holding a perfectly valid SDK key built no aggregators and
372
+ # therefore never constructed a reporter — it emitted nothing at all.
373
+ # Datadir + SDK key is a supported combination and telemetry has to flow
374
+ # there like it does in delivery mode. Conversely a keyless client (the
375
+ # open-source / no-account path) has no workspace to attribute data to, so
376
+ # nothing is collected and the reporter never starts. Mirrors sdk-node's
377
+ # `isTelemetryEnabled` and sdk-go's `Options.TelemetryEnabled()`.
345
378
  def telemetry_allowed?(option)
346
- option && (!local_only? || @allow_telemetry_in_local_mode)
379
+ option && sdk_key?
347
380
  end
348
381
 
349
382
  def remove_trailing_slash(url)
@@ -129,6 +129,15 @@ module Quonfig
129
129
  @worker = Thread.new { run_loop(&on_envelope) }
130
130
  end
131
131
 
132
+ # True while the worker thread that owns the stream (and its reconnect
133
+ # loop) is running. Stays true across a reconnect — the worker owns the
134
+ # retry, so a blip is not "no channel". Used by Client#connection_state
135
+ # so that diagnostic derives from liveness rather than a stored flag
136
+ # (qfg-lv4n.1).
137
+ def alive?
138
+ @worker&.alive? || false
139
+ end
140
+
132
141
  # Shut down. Interrupts the in-flight stream by closing the underlying
133
142
  # socket from this thread — the worker thread observes the resulting
134
143
  # IOError, sees @stopped == true, and exits cleanly.
@@ -51,6 +51,9 @@ module Quonfig
51
51
  @stopped = Concurrent::AtomicBoolean.new(false)
52
52
  @thread = nil
53
53
  @at_exit_registered = false
54
+ # Set on #start. Everything that can EMIT is gated on it so a forked
55
+ # child never speaks for the process that created this reporter.
56
+ @owner_pid = nil
54
57
  end
55
58
 
56
59
  def enabled?
@@ -80,6 +83,11 @@ module Quonfig
80
83
  return if @thread&.alive?
81
84
  return unless enabled?
82
85
 
86
+ # Claim ownership for THIS process. fork(2) copies the reporter, its
87
+ # aggregators, and the process-wide at_exit closure registered below;
88
+ # the pid recorded here is what lets the copy know it is not the
89
+ # owner and must stay silent (qfg-lv4n.1, dd-trace-rb's pattern).
90
+ @owner_pid = Process.pid
83
91
  @stopped.make_false
84
92
  register_at_exit_handler
85
93
  @thread = Thread.new do
@@ -106,6 +114,8 @@ module Quonfig
106
114
  end
107
115
 
108
116
  def stop
117
+ return if foreign_process?('stop')
118
+
109
119
  @stopped.make_true
110
120
  thread = @thread
111
121
  @thread = nil
@@ -121,7 +131,13 @@ module Quonfig
121
131
 
122
132
  # Drain all aggregators and POST the batch. Public so tests can
123
133
  # trigger a sync without waiting for the background loop.
134
+ #
135
+ # Silent in any process other than the one that started the reporter:
136
+ # after a fork the child holds a full copy of the PARENT's un-flushed
137
+ # window, and the parent is still going to flush it itself.
124
138
  def sync
139
+ return if foreign_process?('sync')
140
+
125
141
  events = []
126
142
  if (summaries_event = @evaluation_summaries_aggregator&.drain_event)
127
143
  events << summaries_event
@@ -151,8 +167,41 @@ module Quonfig
151
167
  @at_exit_registered
152
168
  end
153
169
 
170
+ # Pid of the process that started this reporter, or nil if it was never
171
+ # started. Visible for tests / diagnostics.
172
+ attr_reader :owner_pid
173
+
174
+ # Called on the INHERITED reporter in a forked child, from
175
+ # +Quonfig::Client#after_fork_in_child+, once the child has dropped its
176
+ # reference to it. Makes the copied window unreachable so nothing can
177
+ # ever emit it — belt to the +@owner_pid+ braces.
178
+ #
179
+ # Deliberately does NOT stop, close, or join anything: the thread does
180
+ # not exist in the child, and the HTTP connection's fd is shared with
181
+ # the parent.
182
+ def discard_inherited!
183
+ @stopped.make_true
184
+ @thread = nil
185
+ @context_shape_aggregator = nil
186
+ @example_contexts_aggregator = nil
187
+ @evaluation_summaries_aggregator = nil
188
+ @failover_aggregator = nil
189
+ end
190
+
154
191
  private
155
192
 
193
+ # True when this reporter belongs to a different process — i.e. we are
194
+ # a fork(2) copy. Never true before #start (nothing has been claimed,
195
+ # and nothing was registered at_exit either).
196
+ def foreign_process?(operation)
197
+ return false if @owner_pid.nil?
198
+ return false if @owner_pid == Process.pid
199
+
200
+ LOG.debug "[quonfig] Telemetry #{operation} skipped in forked child " \
201
+ "pid=#{Process.pid} owner_pid=#{@owner_pid}"
202
+ true
203
+ end
204
+
156
205
  # Rails / Passenger / Puma workers often terminate via SIGTERM without
157
206
  # a chance to call Client#stop. Register a Kernel.at_exit hook on
158
207
  # first start so the in-flight batch still gets flushed.
@@ -174,6 +223,8 @@ module Quonfig
174
223
  # no-op. Bounded so a stuck reporter thread or dead telemetry
175
224
  # endpoint can't hang process exit.
176
225
  def final_drain_on_exit
226
+ return if foreign_process?('at_exit drain')
227
+
177
228
  @stopped.make_true
178
229
  thread = @thread
179
230
  @thread = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.2.0'
4
+ VERSION = '1.4.0'
5
5
  end
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.2.0
4
+ version: 1.4.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-07-08 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport