quonfig 0.0.3 → 0.0.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c01dc43b0b300e6f30cd2fad53305feee0cd74229ade2073b44f86777c6c2096
4
- data.tar.gz: c7b2191829af4e7b7e72ea36da7a4af6a7a7fb176323ee59a1c1515c4a68c25a
3
+ metadata.gz: 875b035905394220e72fec4fc1afc3bc62ad6e2975b745098fa4482e1dc348d0
4
+ data.tar.gz: bd957d0ec077f3a49daf719deb3ea4db64d1b23a69f6264c91385ad21ff06720
5
5
  SHA512:
6
- metadata.gz: bae99227268f0fb192a398f1b3c7256a7d0a7d4c8aa35208ab6125569dfe95d4af05ee94048ac8dbc662415bd1a07b3ed5c6c86f5d6e75c6919d876538e726f4
7
- data.tar.gz: 720a3628d4bbdd75ee432a1ae0d62beccc42b79f95c9afd21ea22a28d815296275e1c2a921b22a45e713934e28fe5d7e2136fedee06f71cbfb41ec2255792033
6
+ metadata.gz: 9a592d367c361e42e57c42b0ca0929927808e834b5f876545e17436403b11eb5abe08d2996317e5ec5360a1df2228b48a247e22304d6fcda809c3ffee0e3f772
7
+ data.tar.gz: f3f9b9b1135ab775861164fb89701e6a3cf6b5cb23078d55de89604b13081ce71b7fe00bed192cd9d9134d64fd406fec954efa793e62a2fb558a8ca3cddbec4f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.6 - 2026-04-22
4
+
5
+ - **New: `Quonfig::StdlibFormatter` + `client.stdlib_formatter(logger_name:)`** —
6
+ Ruby's built-in `::Logger` now gets drop-in dynamic log-level gating,
7
+ on par with the existing SemanticLogger integration. The client helper
8
+ returns a Proc matching the stdlib `logger.formatter =` contract
9
+ (`(severity, datetime, progname, msg) -> String`). For each log call
10
+ the proc evaluates `should_log?(logger_path: logger_name || progname,
11
+ desired_level: severity)` and either formats the record or returns an
12
+ empty string (which `::Logger` writes as zero bytes, suppressing the
13
+ line). `logger_name` flows into `quonfig-sdk-logging.key` verbatim —
14
+ no normalization — so customer rules target exact class names.
15
+ Raises `Quonfig::Error` if `logger_key` was not set at init. Parallels
16
+ sdk-node's Winston formatter, sdk-python's `logging.Filter`, and
17
+ sdk-go's `slog.Handler`. Closes Stage 2 of the per-SDK logger-path
18
+ rollout.
19
+
20
+ ## 0.0.5 - 2026-04-22
21
+
22
+ - **BREAKING — SemanticLoggerFilter context key renamed.** The filter
23
+ previously exposed the logger name under
24
+ `{ 'quonfig' => { 'logger-name' => '<normalized>' } }`. It now uses
25
+ `{ 'quonfig-sdk-logging' => { 'key' => '<verbatim name>' } }` so that
26
+ all SDKs (node, go, ruby, python) share one top-level context name.
27
+ Any customer rules that match on the old `quonfig.logger-name` property
28
+ must be rewritten to match `quonfig-sdk-logging.key`.
29
+ - **BREAKING — logger name normalization removed.** The filter no longer
30
+ converts `MyApp::Services::Auth` → `my_app.services.auth`. Native Ruby
31
+ class names are passed through verbatim. Rules should target the exact
32
+ class name (e.g. `PROP_STARTS_WITH_ONE_OF "MyApp::Services::"`).
33
+ - **New: `logger_key` client option** (snake_case) — pass to
34
+ `Quonfig::Options.new(logger_key: 'log-level.my-app')` or via
35
+ `Quonfig.init`. Declares the Quonfig config key the higher-level
36
+ `should_log?` helper evaluates for every log call.
37
+ - **New: `client.should_log?(logger_path:, desired_level:, contexts:)`** —
38
+ Reforge-style convenience on top of `get`. Evaluates `logger_key` with
39
+ `{ 'quonfig-sdk-logging' => { 'key' => logger_path } }` merged into the
40
+ caller's contexts, then compares the returned level to `desired_level`.
41
+ Raises `Quonfig::Error` if `logger_key` was not set at init. Parallels
42
+ sdk-node's `shouldLog({loggerPath})` and sdk-go's `ShouldLogPath`.
43
+ - Stage 1 of the per-SDK logger-path rollout (after sdk-node 0.0.14 and
44
+ sdk-go 0.0.10 shipped the same shape).
45
+
46
+ ## 0.0.4 - 2026-04-22
47
+
48
+ - **Fix (P0 from test-ruby friction log):** Network mode is now wired through
49
+ `Client`. Previously, `Quonfig.init` with just `QUONFIG_BACKEND_SDK_KEY`
50
+ succeeded silently against an empty store; `get` and `enabled?` returned
51
+ the default for every key because no HTTP fetch ever happened. Now:
52
+ - On `Client#initialize` (when neither `datadir:` nor `store:` is passed)
53
+ we do a synchronous HTTP GET against the first `api_urls[0]` (failing
54
+ over to secondaries), bounded by `initialization_timeout_sec` (default
55
+ 10s). `on_init_failure` decides raise vs continue with empty store.
56
+ - `enable_sse` (default `true`) subscribes to `{stream.*}/api/v2/sse/config`
57
+ and applies incremental envelopes to the live `ConfigStore`.
58
+ - `enable_polling` (default `true`) starts a background poller IFF SSE did
59
+ not start successfully. This avoids double-fetching when SSE is healthy
60
+ while still refreshing in proxied / SSE-blocked environments. Interval
61
+ comes from `Options#poll_interval` (default 60s).
62
+ - `Client#stop` now closes the SSE connection and kills the poll thread.
63
+ - Adds `Options#poll_interval` (default 60s); previously missing from the
64
+ Options surface despite being documented.
65
+ - `ConfigLoader` now populates the `ConfigStore` directly on each successful
66
+ fetch, so the Evaluator/Resolver see the new configs immediately (wire
67
+ path matches sdk-node/sdk-go — `ConfigResponse` envelope JSON). (qfg-s7h)
68
+
3
69
  ## 0.0.3 - 2026-04-22
4
70
 
5
71
  - **Release plumbing only** — no functional changes. Renames the release
data/README.md CHANGED
@@ -203,6 +203,42 @@ Pass `key_prefix:` to use a prefix other than `log-levels.`:
203
203
  client.semantic_logger_filter(key_prefix: 'debug.')
204
204
  ```
205
205
 
206
+ ## Dynamic log levels with stdlib Logger
207
+
208
+ If you use Ruby's built-in `::Logger` instead of SemanticLogger, wire the
209
+ formatter returned by `client.stdlib_formatter` into your logger:
210
+
211
+ ```ruby
212
+ require 'quonfig'
213
+ require 'logger'
214
+
215
+ client = Quonfig::Client.new(
216
+ sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
217
+ logger_key: 'log-level.my-app'
218
+ )
219
+
220
+ logger = ::Logger.new($stdout)
221
+ logger.level = ::Logger::DEBUG
222
+ logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Services::Auth')
223
+ ```
224
+
225
+ The formatter asks the client `should_log?(logger_path:, desired_level:)`
226
+ for every call; lines below the configured level return an empty string
227
+ (which `::Logger` writes as zero bytes, suppressing the line). `logger_name`
228
+ is passed to Quonfig verbatim under `quonfig-sdk-logging.key` so a single
229
+ `log-level.my-app` config can drive per-class overrides via rules like
230
+ `PROP_STARTS_WITH_ONE_OF "MyApp::Services::"`.
231
+
232
+ Omit `logger_name:` to have the formatter fall through to the Logger's
233
+ `progname` at call time:
234
+
235
+ ```ruby
236
+ logger.formatter = client.stdlib_formatter
237
+ logger.progname = 'MyApp::Services::Auth'
238
+ ```
239
+
240
+ If both are supplied, the explicit `logger_name:` wins.
241
+
206
242
  ## Documentation
207
243
 
208
244
  Full documentation, including SPEC, SDK reference, and operational guides, is
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.3
1
+ 0.0.6
@@ -1,20 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'timeout'
4
5
 
5
6
  module Quonfig
6
7
  # Public Quonfig SDK client.
7
8
  #
8
- # Wires the new JSON stack: Quonfig::ConfigStore + Quonfig::Evaluator +
9
- # Quonfig::Resolver. The legacy protobuf-driven ConfigClient/ConfigResolver
10
- # path was removed in qfg-dk6.32. Network-mode (HTTP fetch + SSE updates) is
11
- # not yet wired through Client; today the supported entry points are
12
- # +datadir:+ (offline workspace) and +store:+ (caller-supplied
13
- # Quonfig::ConfigStore, used by tests).
9
+ # Wires the JSON stack: Quonfig::ConfigStore + Quonfig::Evaluator +
10
+ # Quonfig::Resolver. Three modes are supported:
11
+ #
12
+ # 1. +datadir:+ (offline) -- load a workspace from the local filesystem.
13
+ # 2. +store:+ (test harness) -- caller-supplied ConfigStore, no I/O.
14
+ # 3. network mode (default) -- HTTP fetch from +api_urls+ populates the
15
+ # ConfigStore, then (if enabled) an SSE subscription keeps it live.
16
+ #
17
+ # Network mode is the happy path for production SDK usage. The protobuf
18
+ # stack was retired in qfg-dk6.32; HTTP + SSE were wired back through Client
19
+ # in qfg-s7h.
14
20
  class Client
15
21
  LOG = Quonfig::InternalLogger.new(self)
16
22
 
17
- attr_reader :options, :resolver, :store, :evaluator, :instance_hash
23
+ attr_reader :options, :resolver, :store, :evaluator, :instance_hash,
24
+ :config_loader
18
25
 
19
26
  def initialize(options = nil, store: nil, **option_kwargs)
20
27
  @options =
@@ -27,10 +34,22 @@ module Quonfig
27
34
  end
28
35
  @global_context = normalize_context(@options.global_context)
29
36
  @instance_hash = SecureRandom.uuid
30
- @store = store || build_store
37
+ @store = store || Quonfig::ConfigStore.new
31
38
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
32
39
  @resolver = Quonfig::Resolver.new(@store, @evaluator)
33
40
  @semantic_logger_filters = {}
41
+ @sse_client = nil
42
+ @poll_thread = nil
43
+ @stopped = false
44
+
45
+ # If the caller injected a store, we're in test/bootstrap mode; skip I/O.
46
+ return if store
47
+
48
+ if @options.datadir
49
+ load_datadir_into_store
50
+ else
51
+ initialize_network_mode
52
+ end
34
53
  end
35
54
 
36
55
  # ---- Lookup --------------------------------------------------------
@@ -106,13 +125,104 @@ module Quonfig
106
125
  Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
107
126
  end
108
127
 
128
+ # Build a formatter Proc for Ruby's built-in +::Logger+. The returned
129
+ # proc honors dynamic log levels from the client's +logger_key+ config:
130
+ # for each log call, it evaluates +should_log?+ and either formats the
131
+ # record or returns an empty string (suppressing output).
132
+ #
133
+ # Matches ReforgeHQ's +stdlib_formatter+ API name (snake_case).
134
+ #
135
+ # Usage:
136
+ # logger = ::Logger.new($stdout)
137
+ # logger.formatter = client.stdlib_formatter # uses progname
138
+ # logger.formatter = client.stdlib_formatter(logger_name: 'MyApp') # fixed name
139
+ #
140
+ # Raises +Quonfig::Error+ if +logger_key+ was not set at init — parallels
141
+ # +should_log?+'s behavior.
142
+ #
143
+ # @param logger_name [String, nil] fallback logger identifier used when
144
+ # +progname+ isn't supplied by the Logger call site. If both are
145
+ # present, +logger_name+ wins.
146
+ # @return [Proc] a +(severity, datetime, progname, msg) -> String+ proc.
147
+ def stdlib_formatter(logger_name: nil)
148
+ Quonfig::StdlibFormatter.build(self, logger_name: logger_name)
149
+ end
150
+
151
+ # The configured +logger_key+ from Options — the Quonfig config key the
152
+ # higher-level +should_log?+ helper evaluates per-logger. +nil+ if the
153
+ # client was not configured for dynamic log levels.
154
+ def logger_key
155
+ @options.logger_key
156
+ end
157
+
158
+ # Higher-level log-level check — a convenience on top of the primitive
159
+ # +get+. Evaluates the client's +logger_key+ config and returns whether
160
+ # a message at +desired_level+ should be emitted for +logger_path+.
161
+ #
162
+ # The SDK injects +logger_path+ under the +quonfig-sdk-logging+ named
163
+ # context with property +key+ so a single log-level config can drive
164
+ # per-logger overrides via the normal rule engine (e.g.
165
+ # PROP_STARTS_WITH_ONE_OF "MyApp::Services::").
166
+ #
167
+ # +logger_path+ is passed through verbatim — the SDK does not normalize
168
+ # it. Callers may pass any identifier shape their host language prefers
169
+ # (dotted, colon, slash, etc.) and author matching rules in the config
170
+ # against that exact shape.
171
+ #
172
+ # Parallels sdk-node's +shouldLog({loggerPath})+ and sdk-go's
173
+ # +ShouldLogPath+.
174
+ #
175
+ # Raises +Quonfig::Error+ if +logger_key+ was not set on the client —
176
+ # use +semantic_logger_filter(config_key:)+ directly if you want to
177
+ # evaluate a specific key without declaring it at init time.
178
+ #
179
+ # @param logger_path [String] native logger name (typically a class name).
180
+ # @param desired_level [Symbol, String] the level the caller wants to
181
+ # emit at (:trace, :debug, :info, :warn, :error, :fatal).
182
+ # @param contexts [Hash] optional extra context to merge with the
183
+ # injected logger context.
184
+ # @return [Boolean] true if the message should be emitted.
185
+ def should_log?(logger_path:, desired_level:, contexts: {})
186
+ unless logger_key
187
+ raise Quonfig::Error,
188
+ 'logger_key must be set at init to use should_log?(logger_path:, ...). ' \
189
+ 'Pass `logger_key:` to Quonfig::Options.new, or call ' \
190
+ 'semantic_logger_filter(config_key:) / get(config_key) directly.'
191
+ end
192
+
193
+ logger_context = {
194
+ Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_NAME => {
195
+ Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_KEY_PROP => logger_path
196
+ }
197
+ }
198
+ merged = merge_contexts(normalize_context(contexts), logger_context)
199
+
200
+ configured = get(logger_key, nil, merged)
201
+ return true if configured.nil?
202
+
203
+ desired_severity = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(desired_level)] ||
204
+ Quonfig::SemanticLoggerFilter::LEVELS[:debug]
205
+ min_severity = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(configured)] ||
206
+ Quonfig::SemanticLoggerFilter::LEVELS[:debug]
207
+ desired_severity >= min_severity
208
+ end
209
+
109
210
  def on_update(&block)
110
211
  @on_update = block
111
212
  end
112
213
 
113
214
  def stop
114
- # No background threads in datadir mode; placeholder for the future
115
- # SSE/poll path so callers can use this method symmetrically.
215
+ @stopped = true
216
+ begin
217
+ @sse_client&.close
218
+ rescue StandardError => e
219
+ LOG.debug "Error closing SSE client: #{e.message}"
220
+ end
221
+ @sse_client = nil
222
+
223
+ thread = @poll_thread
224
+ @poll_thread = nil
225
+ thread&.kill
116
226
  end
117
227
 
118
228
  def fork
@@ -125,11 +235,97 @@ module Quonfig
125
235
 
126
236
  private
127
237
 
128
- def build_store
129
- if @options.datadir
130
- Quonfig::Datadir.load_store(@options.datadir, @options.environment)
131
- else
132
- Quonfig::ConfigStore.new
238
+ def load_datadir_into_store
239
+ envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
240
+ envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
241
+ end
242
+
243
+ # Initialize network mode: sync HTTP fetch (bounded by
244
+ # initialization_timeout_sec) then start SSE + polling as requested.
245
+ def initialize_network_mode
246
+ if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
247
+ raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key
248
+ end
249
+
250
+ @config_loader = Quonfig::ConfigLoader.new(@store, @options)
251
+
252
+ perform_initial_fetch
253
+
254
+ sse_started = @options.enable_sse && start_sse
255
+
256
+ # Polling is a fallback: if SSE is off or failed to start, poll. This
257
+ # avoids double-work when SSE is healthy but still refreshes the store
258
+ # in environments that block SSE (corporate proxies, Lambda, etc.).
259
+ start_polling if @options.enable_polling && !sse_started
260
+ end
261
+
262
+ def perform_initial_fetch
263
+ timeout = @options.initialization_timeout_sec || 10
264
+ result = :failed
265
+
266
+ begin
267
+ Timeout.timeout(timeout) do
268
+ result = @config_loader.fetch!
269
+ end
270
+ rescue Timeout::Error
271
+ handle_init_failure(
272
+ Quonfig::Errors::InitializationTimeoutError.new(timeout, nil)
273
+ )
274
+ return
275
+ end
276
+
277
+ handle_init_failure(RuntimeError.new('Config fetch failed against all api_urls')) if result == :failed
278
+ end
279
+
280
+ def handle_init_failure(err)
281
+ if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
282
+ LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
283
+ return
284
+ end
285
+
286
+ raise err
287
+ end
288
+
289
+ # Returns true if SSE started successfully, false otherwise. A false here
290
+ # signals the caller to fall back to polling.
291
+ def start_sse
292
+ return false if @options.sse_api_urls.nil? || @options.sse_api_urls.empty?
293
+
294
+ @sse_client = Quonfig::SSEConfigClient.new(@options, @config_loader)
295
+ @sse_client.start do |envelope, _event, _source|
296
+ next if @stopped
297
+ begin
298
+ @config_loader.apply_envelope(envelope)
299
+ @on_update&.call
300
+ rescue StandardError => e
301
+ LOG.warn "[quonfig] Error applying SSE envelope: #{e.message}"
302
+ end
303
+ end
304
+ true
305
+ rescue StandardError => e
306
+ LOG.warn "[quonfig] SSE start failed: #{e.message}"
307
+ @sse_client = nil
308
+ false
309
+ end
310
+
311
+ def start_polling
312
+ poll_interval = @options.respond_to?(:poll_interval) && @options.poll_interval ? @options.poll_interval : 60
313
+ return if poll_interval <= 0
314
+
315
+ @poll_thread = Thread.new do
316
+ Thread.current.name = 'quonfig-poller'
317
+ loop do
318
+ break if @stopped
319
+ sleep poll_interval
320
+ break if @stopped
321
+
322
+ begin
323
+ @config_loader.fetch!
324
+ @on_update&.call
325
+ rescue StandardError => e
326
+ LOG.warn "[quonfig] Polling error: #{e.message}"
327
+ end
328
+ end
133
329
  end
134
330
  end
135
331
 
@@ -164,6 +360,14 @@ module Quonfig
164
360
  merged
165
361
  end
166
362
 
363
+ def normalize_log_level(level)
364
+ case level
365
+ when Symbol then level.downcase
366
+ when String then level.downcase.to_sym
367
+ else level
368
+ end
369
+ end
370
+
167
371
  def handle_missing(key, default)
168
372
  return default if default != NO_DEFAULT_PROVIDED
169
373
 
@@ -3,26 +3,54 @@
3
3
  require 'json'
4
4
 
5
5
  module Quonfig
6
+ # Fetches config envelopes from the Quonfig delivery API and installs them
7
+ # into a ConfigStore.
8
+ #
9
+ # Wire format matches sdk-node's Transport + ConfigStore:
10
+ # GET /api/v2/configs
11
+ # -> 200 { "configs": [...], "meta": { "version": "...", "environment": "..." } }
12
+ # -> 304 Not Modified (ETag honored via If-None-Match)
13
+ #
14
+ # The fetch is synchronous; Client is responsible for timing out the initial
15
+ # fetch per `initialization_timeout_sec`.
6
16
  class ConfigLoader
7
17
  LOG = Quonfig::InternalLogger.new(self)
8
18
 
9
19
  CONFIGS_PATH = '/api/v2/configs'
10
20
 
11
- attr_reader :etag
21
+ attr_reader :etag, :version, :environment_id
22
+
23
+ # +store+: the Quonfig::ConfigStore to populate on successful fetch.
24
+ # +options+: a Quonfig::Options instance (supplies sdk_key + config_api_urls).
25
+ # +logger+: optional logger override (defaults to module LOG).
26
+ #
27
+ # Backward compat: callers that pass a single +base_client+ (mock client
28
+ # used by tests that expects `.options`) are still supported.
29
+ def initialize(store_or_base_client, options = nil, logger: nil)
30
+ if options.nil? && store_or_base_client.respond_to?(:options)
31
+ # Legacy shape: ConfigLoader.new(base_client)
32
+ @options = store_or_base_client.options
33
+ @store = nil
34
+ else
35
+ @store = store_or_base_client
36
+ @options = options
37
+ end
12
38
 
13
- def initialize(base_client)
14
- @base_client = base_client
15
- @options = base_client.options
16
39
  @api_config = Concurrent::Map.new
17
40
  @etag = nil
41
+ @version = nil
42
+ @environment_id = nil
43
+ @logger = logger || LOG
18
44
  end
19
45
 
20
46
  # Fetch configs from /api/v2/configs with ETag / If-None-Match caching.
47
+ # On 200 responses, installs the envelope into the attached ConfigStore
48
+ # (if one was provided).
21
49
  #
22
50
  # Returns one of:
23
- # :updated 200 response; @api_config and @etag replaced
24
- # :not_modified 304 response; cache still valid
25
- # :failed every configured source failed
51
+ # :updated -- 200 response; store replaced
52
+ # :not_modified -- 304 response; store untouched
53
+ # :failed -- every configured source failed
26
54
  def fetch!
27
55
  Array(@options.config_api_urls).each do |api_url|
28
56
  result = fetch_from(api_url)
@@ -31,6 +59,12 @@ module Quonfig
31
59
  :failed
32
60
  end
33
61
 
62
+ # Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
63
+ # when a new event arrives.
64
+ def apply_envelope(envelope)
65
+ install_envelope(envelope, source: :sse)
66
+ end
67
+
34
68
  def calc_config
35
69
  rtn = {}
36
70
  @api_config.each_key do |k|
@@ -59,21 +93,24 @@ module Quonfig
59
93
  when 200
60
94
  new_etag = response.headers['ETag'] || response.headers['etag']
61
95
  envelope = parse_envelope(response.body)
62
- replace_api_config(envelope, source)
96
+ install_envelope(envelope, source: source)
63
97
  @etag = new_etag
64
98
  :updated
65
99
  when 304
66
- LOG.debug "Configs not modified (304) from #{source}"
100
+ @logger.debug "Configs not modified (304) from #{source}"
67
101
  :not_modified
102
+ when 401, 403
103
+ @logger.warn "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}"
104
+ :failed
68
105
  else
69
- LOG.info "Config fetch failed: status #{response.status} from #{source}"
106
+ @logger.info "Config fetch failed: status #{response.status} from #{source}"
70
107
  :failed
71
108
  end
72
109
  rescue Faraday::ConnectionFailed => e
73
- LOG.debug "Connection failure fetching configs from #{source}: #{e.message}"
110
+ @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
74
111
  :failed
75
112
  rescue StandardError => e
76
- LOG.warn "Unexpected error fetching configs from #{source}: #{e.message}"
113
+ @logger.warn "Unexpected error fetching configs from #{source}: #{e.message}"
77
114
  :failed
78
115
  end
79
116
 
@@ -85,7 +122,14 @@ module Quonfig
85
122
  )
86
123
  end
87
124
 
88
- def replace_api_config(envelope, source)
125
+ def short_body(response)
126
+ return '' if response.body.nil?
127
+ str = response.body.to_s
128
+ str.length > 200 ? str[0, 200] + '...' : str
129
+ end
130
+
131
+ def install_envelope(envelope, source:)
132
+ # Update internal tracking map (for legacy callers / introspection).
89
133
  next_map = Concurrent::Map.new
90
134
  envelope.configs.each do |cfg|
91
135
  key = config_key(cfg)
@@ -93,6 +137,24 @@ module Quonfig
93
137
  next_map[key] = { source: source, config: cfg }
94
138
  end
95
139
  @api_config = next_map
140
+
141
+ meta = envelope.meta || {}
142
+ @version = meta['version'] || meta[:version] || @version
143
+ @environment_id = meta['environment'] || meta[:environment] || @environment_id
144
+
145
+ # Replace the live store atomically.
146
+ return if @store.nil?
147
+
148
+ new_keys = next_map.keys.to_set
149
+ old_keys = @store.keys.to_set
150
+ # Drop keys that disappeared server-side.
151
+ (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
152
+
153
+ envelope.configs.each do |cfg|
154
+ key = config_key(cfg)
155
+ next if key.nil?
156
+ @store.set(key, cfg)
157
+ end
96
158
  end
97
159
 
98
160
  def config_key(cfg)
@@ -23,6 +23,10 @@ module Quonfig
23
23
  @lock.with_write_lock { @configs[key] = config }
24
24
  end
25
25
 
26
+ def delete(key)
27
+ @lock.with_write_lock { @configs.delete(key) }
28
+ end
29
+
26
30
  def clear
27
31
  @lock.with_write_lock do
28
32
  @configs.keys.each { |k| @configs.delete(k) }
@@ -18,7 +18,9 @@ module Quonfig
18
18
  attr_reader :datadir
19
19
  attr_reader :enable_sse
20
20
  attr_reader :enable_polling
21
+ attr_reader :poll_interval
21
22
  attr_reader :global_context
23
+ attr_reader :logger_key
22
24
  attr_accessor :is_fork
23
25
 
24
26
  module ON_INITIALIZATION_FAILURE
@@ -60,6 +62,7 @@ module Quonfig
60
62
  datadir: ENV['QUONFIG_DIR'],
61
63
  enable_sse: true,
62
64
  enable_polling: true,
65
+ poll_interval: 60,
63
66
  on_no_default: ON_NO_DEFAULT::RAISE,
64
67
  initialization_timeout_sec: 10,
65
68
  on_init_failure: ON_INITIALIZATION_FAILURE::RAISE,
@@ -70,13 +73,15 @@ module Quonfig
70
73
  collect_evaluation_summaries: true,
71
74
  collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
72
75
  allow_telemetry_in_local_mode: false,
73
- global_context: {}
76
+ global_context: {},
77
+ logger_key: nil
74
78
  )
75
79
  @sdk_key = sdk_key
76
80
  @environment = environment
77
81
  @datadir = datadir
78
82
  @enable_sse = enable_sse
79
83
  @enable_polling = enable_polling
84
+ @poll_interval = poll_interval
80
85
  @on_no_default = on_no_default
81
86
  @initialization_timeout_sec = initialization_timeout_sec
82
87
  @on_init_failure = on_init_failure
@@ -88,6 +93,7 @@ module Quonfig
88
93
  @allow_telemetry_in_local_mode = allow_telemetry_in_local_mode
89
94
  @is_fork = false
90
95
  @global_context = global_context
96
+ @logger_key = logger_key
91
97
 
92
98
  # defaults that may be overridden by context_upload_mode
93
99
  @collect_shapes = false
@@ -2,18 +2,27 @@
2
2
 
3
3
  module Quonfig
4
4
  # SemanticLogger filter that gates log output by a single Quonfig config
5
- # whose rules target the logger via the +quonfig.logger-name+ context
6
- # property.
5
+ # whose rules target the logger via the
6
+ # +quonfig-sdk-logging.key+ context property.
7
7
  #
8
8
  # Usage:
9
- # filter = client.semantic_logger_filter(config_key: 'log-levels.my-app')
9
+ # filter = client.semantic_logger_filter(config_key: 'log-level.my-app')
10
10
  # SemanticLogger.add_appender(io: $stdout, filter: filter)
11
11
  #
12
- # The filter normalizes the SemanticLogger logger name to dotted snake_case
13
- # (e.g. +MyApp::Foo::Bar+ → +my_app.foo.bar+) and exposes it to the
14
- # evaluator under +quonfig.logger-name+ so the customer's Quonfig config can
15
- # discriminate per-logger via PROP_STARTS_WITH_ONE_OF / PROP_IS_ONE_OF
16
- # rules. Lookup is O(1): one +client.get+ call per log line.
12
+ # The filter exposes the SemanticLogger logger name (which is typically the
13
+ # native Ruby class name, e.g. +"MyApp::Services::Auth"+) under the
14
+ # +quonfig-sdk-logging+ named context with property +key+ so customer rules
15
+ # can discriminate per-logger via PROP_STARTS_WITH_ONE_OF /
16
+ # PROP_IS_ONE_OF etc. Lookup is O(1): one +client.get+ call per log line.
17
+ #
18
+ # Logger names are passed through verbatim — there is no snake_case
19
+ # normalization. Matching rules should target the exact class name
20
+ # (e.g. +MyApp::+, +MyApp::Services::Auth+).
21
+ #
22
+ # The constants +LOGGER_CONTEXT_NAME+ and +LOGGER_CONTEXT_KEY_PROP+ are
23
+ # load-bearing: they match +QUONFIG_SDK_LOGGING_CONTEXT_NAME+ in sdk-node
24
+ # and sdk-go, and are consumed by api-telemetry's example-context
25
+ # auto-capture. Do not rename in isolation.
17
26
  class SemanticLoggerFilter
18
27
  LEVELS = {
19
28
  trace: 0,
@@ -24,7 +33,8 @@ module Quonfig
24
33
  fatal: 5
25
34
  }.freeze
26
35
 
27
- LOGGER_NAME_CONTEXT_KEY = 'quonfig.logger-name'
36
+ LOGGER_CONTEXT_NAME = 'quonfig-sdk-logging'
37
+ LOGGER_CONTEXT_KEY_PROP = 'key'
28
38
 
29
39
  def self.semantic_logger_loaded?
30
40
  defined?(SemanticLogger)
@@ -50,22 +60,10 @@ module Quonfig
50
60
  log_severity >= min_severity
51
61
  end
52
62
 
53
- # Normalize a SemanticLogger logger name to the dotted snake_case form
54
- # the customer writes targeting rules against.
55
- # MyApp::Foo::Bar → my_app.foo.bar
56
- # HTMLParser → html_parser
57
- def normalize(name)
58
- name.to_s
59
- .gsub('::', '.')
60
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
61
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
62
- .downcase
63
- end
64
-
65
63
  private
66
64
 
67
65
  def context_for(log)
68
- { 'quonfig' => { 'logger-name' => normalize(log.name) } }
66
+ { LOGGER_CONTEXT_NAME => { LOGGER_CONTEXT_KEY_PROP => log.name.to_s } }
69
67
  end
70
68
 
71
69
  def normalize_level(level)
@@ -66,13 +66,14 @@ module Quonfig
66
66
 
67
67
  def connect(&load_configs)
68
68
  url = "#{source}/api/v2/sse/config"
69
- @logger.debug "SSE Streaming Connect to #{url} start_at #{@config_loader.highwater_mark}"
69
+ cursor = current_cursor
70
+ @logger.debug "SSE Streaming Connect to #{url} start_at #{cursor.inspect}"
70
71
 
71
72
  SSE::Client.new(url,
72
73
  headers: headers,
73
74
  read_timeout: @options.sse_read_timeout,
74
75
  reconnect_time: @options.sse_default_reconnect_time,
75
- last_event_id: (@config_loader.highwater_mark&.positive? ? @config_loader.highwater_mark.to_s : nil),
76
+ last_event_id: cursor,
76
77
  logger: Quonfig::InternalLogger.new(SSE::Client)) do |client|
77
78
  client.on_event do |event|
78
79
  if event.data.nil? || event.data.empty?
@@ -131,5 +132,25 @@ module Quonfig
131
132
 
132
133
  @prefab_options.sse_api_urls[@source_index]
133
134
  end
135
+
136
+ # Compute a Last-Event-ID to resume the stream from. Three sources, in
137
+ # priority order:
138
+ # 1. config_loader.version -- string ETag from last HTTP fetch (new path)
139
+ # 2. config_loader.highwater_mark -- legacy numeric cursor
140
+ # 3. nil -- no prior state; stream from HEAD
141
+ def current_cursor
142
+ if @config_loader.respond_to?(:version)
143
+ v = @config_loader.version
144
+ return v if v.is_a?(String) && !v.empty?
145
+ end
146
+
147
+ if @config_loader.respond_to?(:highwater_mark)
148
+ hw = @config_loader.highwater_mark
149
+ return hw.to_s if hw.is_a?(Numeric) && hw.positive?
150
+ return hw if hw.is_a?(String) && !hw.empty?
151
+ end
152
+
153
+ nil
154
+ end
134
155
  end
135
156
  end
data/lib/quonfig.rb CHANGED
@@ -51,6 +51,7 @@ require 'quonfig/context'
51
51
  require 'quonfig/client'
52
52
  require 'quonfig/bound_client'
53
53
  require 'quonfig/semantic_logger_filter'
54
+ require 'quonfig/stdlib_formatter'
54
55
  require 'quonfig/quonfig'
55
56
  require 'quonfig/murmer3'
56
57
  require 'quonfig/semver'
data/quonfig.gemspec CHANGED
@@ -104,6 +104,7 @@ Gem::Specification.new do |s|
104
104
  "test/test_bound_client.rb",
105
105
  "test/test_caching_http_connection.rb",
106
106
  "test/test_client.rb",
107
+ "test/test_client_network_mode.rb",
107
108
  "test/test_config_loader.rb",
108
109
  "test/test_context.rb",
109
110
  "test/test_datadir.rb",
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'webrick'
5
+ require 'json'
6
+
7
+ # Verifies Client#initialize (qfg-s7h) wires HTTP fetch + ConfigStore together
8
+ # so `Quonfig.get(...)` / `Quonfig.enabled?(...)` return real values — not the
9
+ # defaults — when only an `sdk_key:` + `api_urls:` are supplied. This is the
10
+ # regression test for the P0 documented in test-ruby/FRICTION.md where
11
+ # network-mode was accepted but silently ignored in v0.0.3.
12
+ class TestClientNetworkMode < Minitest::Test
13
+ PORT = 18_094
14
+
15
+ SAMPLE_CONFIG = {
16
+ 'id' => 'c1',
17
+ 'key' => 'log-levels.test-ruby',
18
+ 'type' => 'log_level',
19
+ 'valueType' => 'log_level',
20
+ 'sendToClientSdk' => false,
21
+ 'default' => {
22
+ 'rules' => [
23
+ {
24
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
25
+ 'value' => { 'type' => 'log_level', 'value' => 'WARN' }
26
+ }
27
+ ]
28
+ }
29
+ }.freeze
30
+
31
+ def setup
32
+ super
33
+ @server = nil
34
+ @fetch_count = 0
35
+ end
36
+
37
+ def teardown
38
+ @server&.shutdown
39
+ super
40
+ end
41
+
42
+ def start_server
43
+ log = WEBrick::Log.new(StringIO.new)
44
+ @server = WEBrick::HTTPServer.new(
45
+ Port: PORT, Logger: log, AccessLog: []
46
+ )
47
+ @server.mount_proc '/api/v2/configs' do |_req, res|
48
+ @fetch_count += 1
49
+ res.status = 200
50
+ res['Content-Type'] = 'application/json'
51
+ res['ETag'] = "v#{@fetch_count}"
52
+ res.body = JSON.generate(
53
+ 'configs' => [SAMPLE_CONFIG],
54
+ 'meta' => { 'version' => "v#{@fetch_count}", 'environment' => 'dev' }
55
+ )
56
+ end
57
+ Thread.new { @server.start }
58
+ # Wait for server to be ready.
59
+ 50.times do
60
+ break if tcp_open?
61
+ sleep 0.05
62
+ end
63
+ end
64
+
65
+ def tcp_open?
66
+ require 'socket'
67
+ TCPSocket.new('127.0.0.1', PORT).tap(&:close)
68
+ true
69
+ rescue StandardError
70
+ false
71
+ end
72
+
73
+ def test_initialize_fetches_configs_from_api_urls_and_populates_store
74
+ start_server
75
+
76
+ client = Quonfig::Client.new(
77
+ sdk_key: 'test-key',
78
+ api_urls: ["http://127.0.0.1:#{PORT}"],
79
+ enable_sse: false,
80
+ enable_polling: false
81
+ )
82
+
83
+ assert_equal 1, @fetch_count, 'expected exactly one HTTP fetch during init'
84
+ assert_includes client.keys, 'log-levels.test-ruby'
85
+ assert_equal 'WARN', client.get('log-levels.test-ruby', 'default')
86
+ ensure
87
+ client&.stop
88
+ end
89
+
90
+ def test_initialize_raises_on_fetch_failure_by_default
91
+ # No server started -> connection refused everywhere
92
+ assert_raises(RuntimeError, Quonfig::Errors::InitializationTimeoutError) do
93
+ Quonfig::Client.new(
94
+ sdk_key: 'test-key',
95
+ api_urls: ['http://127.0.0.1:1'], # almost certainly unreachable
96
+ enable_sse: false,
97
+ enable_polling: false,
98
+ initialization_timeout_sec: 2
99
+ )
100
+ end
101
+ end
102
+
103
+ def test_initialize_returns_empty_store_when_on_init_failure_is_return
104
+ client = Quonfig::Client.new(
105
+ sdk_key: 'test-key',
106
+ api_urls: ['http://127.0.0.1:1'],
107
+ enable_sse: false,
108
+ enable_polling: false,
109
+ initialization_timeout_sec: 2,
110
+ on_init_failure: Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
111
+ )
112
+
113
+ assert_empty client.keys
114
+ assert_logged [/Initialization did not complete cleanly/]
115
+ ensure
116
+ client&.stop
117
+ end
118
+
119
+ def test_initialize_skips_network_when_store_injected
120
+ # store: passed -> Client should not try any I/O. Unreachable URL must
121
+ # be fine when a store is injected.
122
+ store = Quonfig::ConfigStore.new
123
+ client = Quonfig::Client.new(
124
+ Quonfig::Options.new(
125
+ sdk_key: 'test-key',
126
+ api_urls: ['http://127.0.0.1:1'],
127
+ enable_sse: false,
128
+ enable_polling: false
129
+ ),
130
+ store: store
131
+ )
132
+ assert_same store, client.store
133
+ ensure
134
+ client&.stop
135
+ end
136
+ end
@@ -3,16 +3,20 @@
3
3
  require 'test_helper'
4
4
  require 'semantic_logger'
5
5
 
6
- # Verifies the corrected design: ONE Quonfig config gates many loggers.
7
- # The filter passes `quonfig.logger-name` as a context property so customer
8
- # rules can target `PROP_STARTS_WITH_ONE_OF my_app.db` etc.
6
+ # Verifies the SemanticLoggerFilter: ONE Quonfig config gates many loggers.
7
+ # The filter injects the native SemanticLogger logger name under the
8
+ # `quonfig-sdk-logging` context (keyed at `.key`) so customer rules can target
9
+ # `PROP_STARTS_WITH_ONE_OF MyApp::` etc.
10
+ #
11
+ # Breaking change in 0.0.5: context key was renamed from `quonfig.logger-name`
12
+ # (dotted snake_case, flat) to `quonfig-sdk-logging.key` (nested, verbatim
13
+ # class name). Normalization was removed — logger names are passed through
14
+ # as-is.
9
15
  class TestSemanticLoggerFilter < Minitest::Test
10
16
  CONFIG_KEY = 'log-levels.my-app'
11
17
 
12
18
  # FakeClient lets us assert the exact key + context the filter passes to
13
- # the SDK without standing up a full datadir. The single-key contract is
14
- # the *specific mechanism* this bead is verifying — if the filter ever
15
- # regresses to a per-logger key, this captured request goes wrong.
19
+ # the SDK without standing up a full datadir.
16
20
  class FakeClient
17
21
  attr_reader :calls
18
22
 
@@ -43,7 +47,7 @@ class TestSemanticLoggerFilter < Minitest::Test
43
47
  assert_equal 1, client.calls.size
44
48
  assert_equal CONFIG_KEY, client.calls.first[:key]
45
49
  ctx = client.calls.first[:context]
46
- assert_equal({ 'quonfig' => { 'logger-name' => 'my_app.foo.bar' } }, ctx)
50
+ assert_equal({ 'quonfig-sdk-logging' => { 'key' => 'MyApp::Foo::Bar' } }, ctx)
47
51
  end
48
52
 
49
53
  def test_passes_through_when_level_meets_configured_minimum
@@ -71,19 +75,22 @@ class TestSemanticLoggerFilter < Minitest::Test
71
75
  assert_equal true, filter.call(make_log('Anything', :debug))
72
76
  end
73
77
 
74
- def test_logger_name_normalization
78
+ def test_logger_name_passed_through_verbatim
79
+ # Normalization is gone. Native Ruby class names are preserved as-is,
80
+ # which matches how sdk-node and sdk-go pass the logger path.
75
81
  filter, client = filter_for(:debug)
76
82
 
77
- {
78
- 'MyApp::Foo::Bar' => 'my_app.foo.bar',
79
- 'HTMLParser' => 'html_parser',
80
- 'foo' => 'foo',
81
- 'A::B::CDPath' => 'a.b.cd_path'
82
- }.each do |raw, expected|
83
+ [
84
+ 'MyApp::Foo::Bar',
85
+ 'HTMLParser',
86
+ 'foo',
87
+ 'A::B::CDPath',
88
+ 'MyApp::Services::Auth'
89
+ ].each do |raw|
83
90
  client.calls.clear
84
91
  filter.call(make_log(raw, :info))
85
- assert_equal expected, client.calls.first[:context]['quonfig']['logger-name'],
86
- "normalize(#{raw.inspect}) should be #{expected.inspect}"
92
+ assert_equal raw, client.calls.first[:context]['quonfig-sdk-logging']['key'],
93
+ "logger name should be passed through verbatim: #{raw.inspect}"
87
94
  end
88
95
  end
89
96
 
@@ -100,6 +107,20 @@ class TestSemanticLoggerFilter < Minitest::Test
100
107
  'Filter should call exactly the configured key, never derived per-logger keys'
101
108
  end
102
109
 
110
+ def test_normalize_method_is_gone
111
+ # The old normalize() method converted "MyApp::Foo" → "my_app.foo".
112
+ # It is intentionally removed so callers see native Ruby class names in
113
+ # context telemetry and rule matching.
114
+ refute Quonfig::SemanticLoggerFilter.instance_methods.include?(:normalize),
115
+ 'normalize() should be removed — logger names are passed through as-is'
116
+ end
117
+
118
+ def test_context_key_constant_is_new_shape
119
+ # Sanity check that the context key constant exposes the new shape.
120
+ assert_equal 'quonfig-sdk-logging', Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_NAME
121
+ assert_equal 'key', Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_KEY_PROP
122
+ end
123
+
103
124
  def test_all_six_levels_mapped_correctly
104
125
  expected = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5 }
105
126
  assert_equal expected, Quonfig::SemanticLoggerFilter::LEVELS
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
@@ -263,6 +263,7 @@ files:
263
263
  - test/test_bound_client.rb
264
264
  - test/test_caching_http_connection.rb
265
265
  - test/test_client.rb
266
+ - test/test_client_network_mode.rb
266
267
  - test/test_config_loader.rb
267
268
  - test/test_context.rb
268
269
  - test/test_datadir.rb