quonfig 0.0.3 → 0.0.5

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: eb6a88a2132fd0fe54fc656ffe1b81916e4487258c2a54dd5589d0bb56b1e300
4
+ data.tar.gz: e91e3cce0cc73793e2c0c1fb407192cbd5a8caad9c96bfbfae6d6bf2fa69ea78
5
5
  SHA512:
6
- metadata.gz: bae99227268f0fb192a398f1b3c7256a7d0a7d4c8aa35208ab6125569dfe95d4af05ee94048ac8dbc662415bd1a07b3ed5c6c86f5d6e75c6919d876538e726f4
7
- data.tar.gz: 720a3628d4bbdd75ee432a1ae0d62beccc42b79f95c9afd21ea22a28d815296275e1c2a921b22a45e713934e28fe5d7e2136fedee06f71cbfb41ec2255792033
6
+ metadata.gz: d33195dcf4fd9b52f8e245e102fbf21a405626aaa8e1157294a79c83cb8a1f91b78249782a9b7318728f31d342adc9f4565d082886d041d53a44995f8adfcb45
7
+ data.tar.gz: fa6b7dd2497f9d202e985619ccccf754162b538c1cd7ffc41d0c7f6679e83d55ac1739d3ed1dc76931b7a9a14be1db9e5f35514a9ff86c9997feecda9342159d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.5 - 2026-04-22
4
+
5
+ - **BREAKING — SemanticLoggerFilter context key renamed.** The filter
6
+ previously exposed the logger name under
7
+ `{ 'quonfig' => { 'logger-name' => '<normalized>' } }`. It now uses
8
+ `{ 'quonfig-sdk-logging' => { 'key' => '<verbatim name>' } }` so that
9
+ all SDKs (node, go, ruby, python) share one top-level context name.
10
+ Any customer rules that match on the old `quonfig.logger-name` property
11
+ must be rewritten to match `quonfig-sdk-logging.key`.
12
+ - **BREAKING — logger name normalization removed.** The filter no longer
13
+ converts `MyApp::Services::Auth` → `my_app.services.auth`. Native Ruby
14
+ class names are passed through verbatim. Rules should target the exact
15
+ class name (e.g. `PROP_STARTS_WITH_ONE_OF "MyApp::Services::"`).
16
+ - **New: `logger_key` client option** (snake_case) — pass to
17
+ `Quonfig::Options.new(logger_key: 'log-level.my-app')` or via
18
+ `Quonfig.init`. Declares the Quonfig config key the higher-level
19
+ `should_log?` helper evaluates for every log call.
20
+ - **New: `client.should_log?(logger_path:, desired_level:, contexts:)`** —
21
+ Reforge-style convenience on top of `get`. Evaluates `logger_key` with
22
+ `{ 'quonfig-sdk-logging' => { 'key' => logger_path } }` merged into the
23
+ caller's contexts, then compares the returned level to `desired_level`.
24
+ Raises `Quonfig::Error` if `logger_key` was not set at init. Parallels
25
+ sdk-node's `shouldLog({loggerPath})` and sdk-go's `ShouldLogPath`.
26
+ - Stage 1 of the per-SDK logger-path rollout (after sdk-node 0.0.14 and
27
+ sdk-go 0.0.10 shipped the same shape).
28
+
29
+ ## 0.0.4 - 2026-04-22
30
+
31
+ - **Fix (P0 from test-ruby friction log):** Network mode is now wired through
32
+ `Client`. Previously, `Quonfig.init` with just `QUONFIG_BACKEND_SDK_KEY`
33
+ succeeded silently against an empty store; `get` and `enabled?` returned
34
+ the default for every key because no HTTP fetch ever happened. Now:
35
+ - On `Client#initialize` (when neither `datadir:` nor `store:` is passed)
36
+ we do a synchronous HTTP GET against the first `api_urls[0]` (failing
37
+ over to secondaries), bounded by `initialization_timeout_sec` (default
38
+ 10s). `on_init_failure` decides raise vs continue with empty store.
39
+ - `enable_sse` (default `true`) subscribes to `{stream.*}/api/v2/sse/config`
40
+ and applies incremental envelopes to the live `ConfigStore`.
41
+ - `enable_polling` (default `true`) starts a background poller IFF SSE did
42
+ not start successfully. This avoids double-fetching when SSE is healthy
43
+ while still refreshing in proxied / SSE-blocked environments. Interval
44
+ comes from `Options#poll_interval` (default 60s).
45
+ - `Client#stop` now closes the SSE connection and kills the poll thread.
46
+ - Adds `Options#poll_interval` (default 60s); previously missing from the
47
+ Options surface despite being documented.
48
+ - `ConfigLoader` now populates the `ConfigStore` directly on each successful
49
+ fetch, so the Evaluator/Resolver see the new configs immediately (wire
50
+ path matches sdk-node/sdk-go — `ConfigResponse` envelope JSON). (qfg-s7h)
51
+
3
52
  ## 0.0.3 - 2026-04-22
4
53
 
5
54
  - **Release plumbing only** — no functional changes. Renames the release
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.3
1
+ 0.0.5
@@ -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,81 @@ module Quonfig
106
125
  Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
107
126
  end
108
127
 
128
+ # The configured +logger_key+ from Options — the Quonfig config key the
129
+ # higher-level +should_log?+ helper evaluates per-logger. +nil+ if the
130
+ # client was not configured for dynamic log levels.
131
+ def logger_key
132
+ @options.logger_key
133
+ end
134
+
135
+ # Higher-level log-level check — a convenience on top of the primitive
136
+ # +get+. Evaluates the client's +logger_key+ config and returns whether
137
+ # a message at +desired_level+ should be emitted for +logger_path+.
138
+ #
139
+ # The SDK injects +logger_path+ under the +quonfig-sdk-logging+ named
140
+ # context with property +key+ so a single log-level config can drive
141
+ # per-logger overrides via the normal rule engine (e.g.
142
+ # PROP_STARTS_WITH_ONE_OF "MyApp::Services::").
143
+ #
144
+ # +logger_path+ is passed through verbatim — the SDK does not normalize
145
+ # it. Callers may pass any identifier shape their host language prefers
146
+ # (dotted, colon, slash, etc.) and author matching rules in the config
147
+ # against that exact shape.
148
+ #
149
+ # Parallels sdk-node's +shouldLog({loggerPath})+ and sdk-go's
150
+ # +ShouldLogPath+.
151
+ #
152
+ # Raises +Quonfig::Error+ if +logger_key+ was not set on the client —
153
+ # use +semantic_logger_filter(config_key:)+ directly if you want to
154
+ # evaluate a specific key without declaring it at init time.
155
+ #
156
+ # @param logger_path [String] native logger name (typically a class name).
157
+ # @param desired_level [Symbol, String] the level the caller wants to
158
+ # emit at (:trace, :debug, :info, :warn, :error, :fatal).
159
+ # @param contexts [Hash] optional extra context to merge with the
160
+ # injected logger context.
161
+ # @return [Boolean] true if the message should be emitted.
162
+ def should_log?(logger_path:, desired_level:, contexts: {})
163
+ unless logger_key
164
+ raise Quonfig::Error,
165
+ 'logger_key must be set at init to use should_log?(logger_path:, ...). ' \
166
+ 'Pass `logger_key:` to Quonfig::Options.new, or call ' \
167
+ 'semantic_logger_filter(config_key:) / get(config_key) directly.'
168
+ end
169
+
170
+ logger_context = {
171
+ Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_NAME => {
172
+ Quonfig::SemanticLoggerFilter::LOGGER_CONTEXT_KEY_PROP => logger_path
173
+ }
174
+ }
175
+ merged = merge_contexts(normalize_context(contexts), logger_context)
176
+
177
+ configured = get(logger_key, nil, merged)
178
+ return true if configured.nil?
179
+
180
+ desired_severity = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(desired_level)] ||
181
+ Quonfig::SemanticLoggerFilter::LEVELS[:debug]
182
+ min_severity = Quonfig::SemanticLoggerFilter::LEVELS[normalize_log_level(configured)] ||
183
+ Quonfig::SemanticLoggerFilter::LEVELS[:debug]
184
+ desired_severity >= min_severity
185
+ end
186
+
109
187
  def on_update(&block)
110
188
  @on_update = block
111
189
  end
112
190
 
113
191
  def stop
114
- # No background threads in datadir mode; placeholder for the future
115
- # SSE/poll path so callers can use this method symmetrically.
192
+ @stopped = true
193
+ begin
194
+ @sse_client&.close
195
+ rescue StandardError => e
196
+ LOG.debug "Error closing SSE client: #{e.message}"
197
+ end
198
+ @sse_client = nil
199
+
200
+ thread = @poll_thread
201
+ @poll_thread = nil
202
+ thread&.kill
116
203
  end
117
204
 
118
205
  def fork
@@ -125,11 +212,97 @@ module Quonfig
125
212
 
126
213
  private
127
214
 
128
- def build_store
129
- if @options.datadir
130
- Quonfig::Datadir.load_store(@options.datadir, @options.environment)
131
- else
132
- Quonfig::ConfigStore.new
215
+ def load_datadir_into_store
216
+ envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
217
+ envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
218
+ end
219
+
220
+ # Initialize network mode: sync HTTP fetch (bounded by
221
+ # initialization_timeout_sec) then start SSE + polling as requested.
222
+ def initialize_network_mode
223
+ if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
224
+ raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key
225
+ end
226
+
227
+ @config_loader = Quonfig::ConfigLoader.new(@store, @options)
228
+
229
+ perform_initial_fetch
230
+
231
+ sse_started = @options.enable_sse && start_sse
232
+
233
+ # Polling is a fallback: if SSE is off or failed to start, poll. This
234
+ # avoids double-work when SSE is healthy but still refreshes the store
235
+ # in environments that block SSE (corporate proxies, Lambda, etc.).
236
+ start_polling if @options.enable_polling && !sse_started
237
+ end
238
+
239
+ def perform_initial_fetch
240
+ timeout = @options.initialization_timeout_sec || 10
241
+ result = :failed
242
+
243
+ begin
244
+ Timeout.timeout(timeout) do
245
+ result = @config_loader.fetch!
246
+ end
247
+ rescue Timeout::Error
248
+ handle_init_failure(
249
+ Quonfig::Errors::InitializationTimeoutError.new(timeout, nil)
250
+ )
251
+ return
252
+ end
253
+
254
+ handle_init_failure(RuntimeError.new('Config fetch failed against all api_urls')) if result == :failed
255
+ end
256
+
257
+ def handle_init_failure(err)
258
+ if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
259
+ LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
260
+ return
261
+ end
262
+
263
+ raise err
264
+ end
265
+
266
+ # Returns true if SSE started successfully, false otherwise. A false here
267
+ # signals the caller to fall back to polling.
268
+ def start_sse
269
+ return false if @options.sse_api_urls.nil? || @options.sse_api_urls.empty?
270
+
271
+ @sse_client = Quonfig::SSEConfigClient.new(@options, @config_loader)
272
+ @sse_client.start do |envelope, _event, _source|
273
+ next if @stopped
274
+ begin
275
+ @config_loader.apply_envelope(envelope)
276
+ @on_update&.call
277
+ rescue StandardError => e
278
+ LOG.warn "[quonfig] Error applying SSE envelope: #{e.message}"
279
+ end
280
+ end
281
+ true
282
+ rescue StandardError => e
283
+ LOG.warn "[quonfig] SSE start failed: #{e.message}"
284
+ @sse_client = nil
285
+ false
286
+ end
287
+
288
+ def start_polling
289
+ poll_interval = @options.respond_to?(:poll_interval) && @options.poll_interval ? @options.poll_interval : 60
290
+ return if poll_interval <= 0
291
+
292
+ @poll_thread = Thread.new do
293
+ Thread.current.name = 'quonfig-poller'
294
+ loop do
295
+ break if @stopped
296
+ sleep poll_interval
297
+ break if @stopped
298
+
299
+ begin
300
+ @config_loader.fetch!
301
+ @on_update&.call
302
+ rescue StandardError => e
303
+ LOG.warn "[quonfig] Polling error: #{e.message}"
304
+ end
305
+ end
133
306
  end
134
307
  end
135
308
 
@@ -164,6 +337,14 @@ module Quonfig
164
337
  merged
165
338
  end
166
339
 
340
+ def normalize_log_level(level)
341
+ case level
342
+ when Symbol then level.downcase
343
+ when String then level.downcase.to_sym
344
+ else level
345
+ end
346
+ end
347
+
167
348
  def handle_missing(key, default)
168
349
  return default if default != NO_DEFAULT_PROVIDED
169
350
 
@@ -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/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.5
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