Class: Quonfig::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/quonfig/client.rb

Overview

Public Quonfig SDK client.

Wires the JSON stack: Quonfig::ConfigStore + Quonfig::Evaluator + Quonfig::Resolver. Three modes are supported:

  1. datadir: (offline) -- load a workspace from the local filesystem.
  2. store: (test harness) -- caller-supplied ConfigStore, no I/O.
  3. network mode (default) -- HTTP fetch from api_urls populates the ConfigStore, then (if enabled) an SSE subscription keeps it live.

Network mode is the happy path for production SDK usage. The protobuf stack was retired in qfg-dk6.32; HTTP + SSE were wired back through Client in qfg-s7h.

Constant Summary collapse

LOG =
Quonfig::InternalLogger.new(self)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = nil, store: nil, **option_kwargs) ⇒ Client

Returns a new instance of Client.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/quonfig/client.rb', line 49

def initialize(options = nil, store: nil, **option_kwargs)
  @options =
    if options.is_a?(Quonfig::Options)
      options
    elsif options.is_a?(Hash)
      Quonfig::Options.new(options.merge(option_kwargs))
    else
      Quonfig::Options.new(option_kwargs)
    end
  Quonfig::InternalLogger.user_logger = @options.logger if @options.logger
  @global_context = build_initial_global_context(@options)
  @instance_hash = SecureRandom.uuid
  @store = store || Quonfig::ConfigStore.new
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
  @resolver = Quonfig::Resolver.new(@store, @evaluator)
  @semantic_logger_filters = {}
  @sse_client = nil
  @poll_supervisor = nil
  @stopped = false
  @telemetry_reporter = nil
  # Shared failover-telemetry aggregator (qfg-41nh.18). Created once and
  # shared between the ConfigLoader (which records hedge-fired /
  # guard-rejected / resolved-from at the failover call sites) and the
  # TelemetryReporter (which drains it on each flush). Created before the
  # ConfigLoader so the initial HTTP fetch — often the ONLY HTTP fetch when
  # SSE is healthy — is captured. Recording is a per-config-refresh mutex +
  # increment (negligible); nothing is EMITTED unless telemetry is enabled
  # (the reporter, which owns the drain, only starts then). Survives fork:
  # the pre-fork stop drains it, the child's rebuilt reporter drains onward.
  @failover_aggregator = Quonfig::Telemetry::FailoverAggregator.new
  @state_mutex = Mutex.new
  @last_successful_refresh = nil
  @sse_state = :idle
  @sse_ever_connected = false
  @fallback_engage_timer = nil
  @sse_terminal_failure = false

  # If the caller injected a store, we're in test/bootstrap mode; skip I/O.
  return if store

  if @options.datadir
    load_datadir_into_store
    start_datadir_watcher if @options.data_dir_auto_reload
  else
    initialize_network_mode
  end

  initialize_telemetry

  # Register only for non-store-injected clients (a caller-supplied store
  # is the test/bootstrap path; the fork hook does not apply there).
  self.class.register_instance(self) unless store
end

Instance Attribute Details

#config_loaderObject (readonly)

Returns the value of attribute config_loader.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def config_loader
  @config_loader
end

#evaluatorObject (readonly)

Returns the value of attribute evaluator.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def evaluator
  @evaluator
end

#instance_hashObject (readonly)

Returns the value of attribute instance_hash.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def instance_hash
  @instance_hash
end

#optionsObject (readonly)

Returns the value of attribute options.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def options
  @options
end

#resolverObject (readonly)

Returns the value of attribute resolver.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def resolver
  @resolver
end

#storeObject (readonly)

Returns the value of attribute store.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def store
  @store
end

#telemetry_reporterObject (readonly)

Returns the value of attribute telemetry_reporter.



46
47
48
# File 'lib/quonfig/client.rb', line 46

def telemetry_reporter
  @telemetry_reporter
end

Class Method Details

.each_instance(&block) ⇒ Object

Iterate live Client instances. Used by Quonfig::ForkSafety.



37
38
39
# File 'lib/quonfig/client.rb', line 37

def each_instance(&block)
  @instances_mutex.synchronize { @instances.keys }.each(&block)
end

.register_instance(client) ⇒ Object



41
42
43
# File 'lib/quonfig/client.rb', line 41

def register_instance(client)
  @instances_mutex.synchronize { @instances[client] = true }
end

Instance Method Details

#after_fork_in_childObject

qfg-ryov: post-fork (in child) hook. Re-establish whatever threaded components the client had pre-fork. No-op if the client was already stopped (the customer asked for it to be dead — do not resurrect), or if the client is in datadir mode (no threaded components to start).



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/quonfig/client.rb', line 338

def after_fork_in_child
  return if @stopped

  if @options.datadir
    start_datadir_watcher if @options.data_dir_auto_reload
    return
  end

  return if @config_loader.nil? # never finished network init (e.g. invalid key)

  # SSE state machine carries flags that no longer apply in the child
  # (the parent had connected, the parent had errored, etc.). Reset.
  @state_mutex.synchronize do
    @sse_state = :idle
    @sse_ever_connected = false
    @sse_terminal_failure = false
  end

  sse_started = @options.enable_sse && start_sse
  start_polling if @options.fallback_poll_enabled && !sse_started

  restart_telemetry_in_child
end

#before_fork_in_parentObject

qfg-ryov: pre-fork hook. Close the SSE worker, polling supervisor, telemetry reporter, and any fallback-engage timer. Idempotent — calling twice is safe. Does NOT set @stopped: the client is still expected to be usable post-fork via after_fork_in_child.

Why this matters: Ruby threads do not survive fork(2). If we let the child inherit a live Net::HTTP socket, both processes read from the same fd and corrupt each other's bytes. Closing in the parent before fork is the only safe shape.



328
329
330
331
332
# File 'lib/quonfig/client.rb', line 328

def before_fork_in_parent
  return if @stopped

  tear_down_threaded_components!
end

#config_install_countObject

Count of envelopes actually installed. Rejected-older and same-generation snapshots do NOT bump this, so o04 can assert "no flap" via a stable count.



444
445
446
# File 'lib/quonfig/client.rb', line 444

def config_install_count
  @config_loader&.install_count || 0
end

#connection_stateObject

Aggregate connection state. Returns one of:

  • :initializing — no envelope has been installed and SSE is not yet connected.
  • :connected — SSE is live, or the SDK is delivering configs from a loaded envelope (datadir mode or post-initial-fetch with no SSE).
  • :disconnectedstop was called, or SSE errored and no fallback poller is active.
  • :falling_back — the Layer 2 HTTP polling supervisor is alive and serving as the active update channel.

Diagnostic only. Do NOT wire this into a Kubernetes liveness probe — see the README "Diagnostic health signals" section.

Contract: integration-test-data/chaos/supervisor-test-contract.md (Test 6).



409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/quonfig/client.rb', line 409

def connection_state
  @state_mutex.synchronize do
    next :disconnected if @stopped
    next :falling_back if @poll_supervisor&.alive?
    next :connected if @sse_state == :connected
    next :disconnected if @sse_state == :error

    # No SSE state change yet: state is driven by whether any envelope
    # has been installed (datadir / initial fetch).
    @last_successful_refresh.nil? ? :initializing : :connected
  end
end

#defined?(key) ⇒ Boolean

Returns:

  • (Boolean)


188
189
190
# File 'lib/quonfig/client.rb', line 188

def defined?(key)
  !@store.get(key).nil?
end

#enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED) ⇒ Boolean

Returns:

  • (Boolean)


183
184
185
186
# File 'lib/quonfig/client.rb', line 183

def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
  value = get(feature_name, false, jit_context)
  [true, 'true'].include?(value)
end

#forkObject



464
465
466
# File 'lib/quonfig/client.rb', line 464

def fork
  self.class.new(@options.for_fork)
end

#get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED) ⇒ Object

---- Lookup --------------------------------------------------------



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/quonfig/client.rb', line 105

def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
  ctx = build_context(jit_context)
  record_context_for_telemetry(ctx)
  result =
    begin
      @resolver.get(key, ctx)
    rescue Quonfig::Errors::MissingDefaultError
      # The Resolver raises (matching Quonfig.get_or_raise semantics).
      # The Client's get applies the caller-provided default *or* the
      # configured on_no_default policy via handle_missing.
      nil
    end
  return handle_missing(key, default) if result.nil?

  record_evaluation_for_telemetry(result)
  result.unwrapped_value
end

#get_bool(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



135
136
137
# File 'lib/quonfig/client.rb', line 135

def get_bool(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, :bool, default: default, context: context)
end

#get_bool_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object

---- Details getters ----------------------------------------------

Mirrors the typed getters above but returns a Quonfig::EvaluationDetails carrying the OpenFeature-aligned resolution reason ("STATIC", "TARGETING_MATCH", "SPLIT", "DEFAULT", or "ERROR") plus an +error_code+/+error_message+ on the error path. These methods never raise — exceptions are caught and rendered as ERROR details.



159
160
161
# File 'lib/quonfig/client.rb', line 159

def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, :bool, context)
end

#get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



143
144
145
# File 'lib/quonfig/client.rb', line 143

def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, :duration, default: default, context: context)
end

#get_float(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



131
132
133
# File 'lib/quonfig/client.rb', line 131

def get_float(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, Float, default: default, context: context)
end

#get_float_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object



171
172
173
# File 'lib/quonfig/client.rb', line 171

def get_float_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, Float, context)
end

#get_int(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



127
128
129
# File 'lib/quonfig/client.rb', line 127

def get_int(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, Integer, default: default, context: context)
end

#get_int_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object



167
168
169
# File 'lib/quonfig/client.rb', line 167

def get_int_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, Integer, context)
end

#get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



147
148
149
# File 'lib/quonfig/client.rb', line 147

def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, :json, default: default, context: context)
end

#get_json_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object



179
180
181
# File 'lib/quonfig/client.rb', line 179

def get_json_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, :json, context)
end

#get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



123
124
125
# File 'lib/quonfig/client.rb', line 123

def get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, String, default: default, context: context)
end

#get_string_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object



163
164
165
# File 'lib/quonfig/client.rb', line 163

def get_string_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, String, context)
end

#get_string_list(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED) ⇒ Object



139
140
141
# File 'lib/quonfig/client.rb', line 139

def get_string_list(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
  typed_get(key, :string_list, default: default, context: context)
end

#get_string_list_details(key, context: NO_DEFAULT_PROVIDED) ⇒ Object



175
176
177
# File 'lib/quonfig/client.rb', line 175

def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
  evaluate_details(key, :string_list, context)
end

#held_generationObject

Meta.generation of the currently-held envelope (0 before the first install or when the backend does not emit a generation). Canonical ordering: an established client never regresses to a lower generation.



438
439
440
# File 'lib/quonfig/client.rb', line 438

def held_generation
  @config_loader&.held_generation || 0
end

#in_context(properties, &block) ⇒ Object

Deprecated.

Use #with_context instead.

Bind properties as a context. With a block, yields a BoundClient and returns the block's value. Without a block, returns the BoundClient directly.

qfg-e0kk: kept as a deprecated alias of #with_context. The two methods have always been runtime-identical; sdk-1.0 unifies on with_context across all SDKs. No runtime warning is emitted (Prefab-fork lineage, heavy customer usage). Slated for removal in 2.0.0.



208
209
210
# File 'lib/quonfig/client.rb', line 208

def in_context(properties, &block)
  with_context(properties, &block)
end

#inspectObject



468
469
470
# File 'lib/quonfig/client.rb', line 468

def inspect
  "#<Quonfig::Client:#{object_id} environment=#{@options.environment.inspect}>"
end

#keysObject



192
193
194
# File 'lib/quonfig/client.rb', line 192

def keys
  @store.keys
end

#last_successful_refreshObject

Wall-clock time of the last installed envelope (any source: datadir, initial HTTP fetch, SSE, or polling fallback). nil before the first install. Preserved after stop.

Diagnostic only. Do NOT wire this into a Kubernetes liveness probe — a transient network blip will trip any freshness threshold and cause a rolling restart cascade. See the README "Diagnostic health signals" section.

Contract: integration-test-data/chaos/supervisor-test-contract.md (Test 6).



390
391
392
# File 'lib/quonfig/client.rb', line 390

def last_successful_refresh
  @state_mutex.synchronize { @last_successful_refresh }
end

#logger_keyObject

The configured logger_key from Options — the Quonfig config key the higher-level should_log? helper evaluates per-logger. nil if the client was not configured for dynamic log levels.



254
255
256
# File 'lib/quonfig/client.rb', line 254

def logger_key
  @options.logger_key
end

#on_update(&block) ⇒ Object



310
311
312
# File 'lib/quonfig/client.rb', line 310

def on_update(&block)
  @on_update = block
end

#ready?Boolean

True once the SDK has installed at least one envelope (any source). The failover scenarios assert the client reaches readiness off the secondary leg inside the init budget even when the primary is refused/hung/slow.

Returns:

  • (Boolean)


431
432
433
# File 'lib/quonfig/client.rb', line 431

def ready?
  !last_successful_refresh.nil?
end

#resolved_fromObject

'primary' / 'secondary' / '' — which config_api_urls leg produced the currently-held config. Used to assert HTTP config-fetch failover (f01-f04).



450
451
452
# File 'lib/quonfig/client.rb', line 450

def resolved_from
  @config_loader&.resolved_from || ''
end

#semantic_logger_filter(config_key:) ⇒ Object

---- Filters & helpers --------------------------------------------



223
224
225
226
# File 'lib/quonfig/client.rb', line 223

def semantic_logger_filter(config_key:)
  @semantic_logger_filters[config_key] ||=
    Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
end

#should_log?(logger_path:, desired_level:, contexts: {}) ⇒ Boolean

Higher-level log-level check — a convenience on top of the primitive get. Evaluates the client's logger_key config and returns whether a message at desired_level should be emitted for logger_path.

The SDK injects logger_path under the quonfig-sdk-logging named context with property key so a single log-level config can drive per-logger overrides via the normal rule engine (e.g. PROP_STARTS_WITH_ONE_OF "MyApp::Services::").

logger_path is passed through verbatim — the SDK does not normalize it. Callers may pass any identifier shape their host language prefers (dotted, colon, slash, etc.) and author matching rules in the config against that exact shape.

Parallels sdk-node's shouldLog({loggerPath}) and sdk-go's ShouldLogPath.

Raises Quonfig::Error if logger_key was not set on the client — use semantic_logger_filter(config_key:) directly if you want to evaluate a specific key without declaring it at init time.

Parameters:

  • logger_path (String)

    native logger name (typically a class name).

  • desired_level (Symbol, String)

    the level the caller wants to emit at (:trace, :debug, :info, :warn, :error, :fatal).

  • contexts (Hash) (defaults to: {})

    optional extra context to merge with the injected logger context.

Returns:

  • (Boolean)

    true if the message should be emitted.



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/quonfig/client.rb', line 285

def should_log?(logger_path:, desired_level:, contexts: {})
  unless logger_key
    raise Quonfig::Error,
          'logger_key must be set at init to use should_log?(logger_path:, ...). ' \
          'Pass `logger_key:` to Quonfig::Options.new, or call ' \
          'semantic_logger_filter(config_key:) / get(config_key) directly.'
  end

  logger_context = {
    Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_NAME => {
      Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_KEY_PROP => logger_path
    }
  }
  merged = merge_contexts(normalize_context(contexts), logger_context)

  configured = get(logger_key, nil, merged)
  return true if configured.nil?

  desired_severity = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(desired_level)] ||
                     Quonfig::SemanticLoggerFilter::LEVELS[:debug]
  min_severity     = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(configured)] ||
                     Quonfig::SemanticLoggerFilter::LEVELS[:debug]
  desired_severity >= min_severity
end

#sse_failed_over_to_secondary?Boolean

True if the live SSE stream has ever repointed to a non-primary leg. The failover epic asserts this stays false (f05): SSE does not fail over.

Returns:

  • (Boolean)


456
457
458
459
460
461
462
# File 'lib/quonfig/client.rb', line 456

def sse_failed_over_to_secondary?
  sse = @sse_client
  return false if sse.nil?
  return false unless sse.respond_to?(:failed_over_to_secondary?)

  sse.failed_over_to_secondary?
end

#stdlib_formatter(logger_name: nil) ⇒ Proc

Build a formatter Proc for Ruby's built-in ::Logger. The returned proc honors dynamic log levels from the client's logger_key config: for each log call, it evaluates should_log? and either formats the record or returns an empty string (suppressing output).

Matches ReforgeHQ's stdlib_formatter API name (snake_case).

Usage:

logger = ::Logger.new($stdout)
logger.formatter = client.stdlib_formatter                       # uses progname
logger.formatter = client.stdlib_formatter(logger_name: 'MyApp') # fixed name

Raises Quonfig::Error if logger_key was not set at init — parallels +should_log?+'s behavior.

Parameters:

  • logger_name (String, nil) (defaults to: nil)

    fallback logger identifier used when progname isn't supplied by the Logger call site. If both are present, logger_name wins.

Returns:

  • (Proc)

    a (severity, datetime, progname, msg) -> String proc.



247
248
249
# File 'lib/quonfig/client.rb', line 247

def stdlib_formatter(logger_name: nil)
  Quonfig::StdlibFormatter.build(self, logger_name: logger_name)
end

#stopObject



314
315
316
317
# File 'lib/quonfig/client.rb', line 314

def stop
  @stopped = true
  tear_down_threaded_components!
end

#terminal_failure?Boolean

qfg-i5xv: true once the SSE layer has classified an HTTP response as terminal (401/403/404) — bad SDK key, revoked workspace permission, or wrong endpoint. The classification latches: the SDK will not auto-recover, and a customer-supplied retry must rebuild the client. Surfaced for operator alerting; connection_state still reports :disconnected to honor the documented connection_state vocabulary (supervisor-test-contract.md §"connectionState()" — values fixed).

Returns:

  • (Boolean)


609
610
611
# File 'lib/quonfig/client.rb', line 609

def terminal_failure?
  @state_mutex.synchronize { @sse_terminal_failure }
end

#with_context(properties) ⇒ Object

Bind properties as a context. With a block, yields a BoundClient and returns the block's value. Without a block, returns the BoundClient directly — useful for passing a context-bound handle down the stack.



216
217
218
219
# File 'lib/quonfig/client.rb', line 216

def with_context(properties)
  bound = Quonfig::BoundClient.new(self, properties)
  block_given? ? yield(bound) : bound
end

#worker_restart_total(layer: nil) ⇒ Object

quonfig_sdk_worker_restart_total counter (Tier 1 supervisor contract). Layer 1 (SSE) is tracked on Quonfig::SSEConfigClient#restart_total — incremented once per reconnect attempt by the SDK-owned reconnect loop (qfg-35sm). Layer 2 (HTTP polling fallback) is wired through Quonfig::WorkerSupervisor.

Pass layer: ('1' or '2') to read a single layer; default returns the sum across both layers so the chaos harness (and operators) can pull per-layer values explicitly while preserving the previous single-number diagnostic surface.



372
373
374
375
376
377
378
# File 'lib/quonfig/client.rb', line 372

def worker_restart_total(layer: nil)
  case layer&.to_s
  when '1' then sse_restart_total
  when '2' then poll_restart_total
  else          sse_restart_total + poll_restart_total
  end
end