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 +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +36 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +219 -15
- data/lib/quonfig/config_loader.rb +75 -13
- data/lib/quonfig/config_store.rb +4 -0
- data/lib/quonfig/options.rb +7 -1
- data/lib/quonfig/semantic_logger_filter.rb +20 -22
- data/lib/quonfig/sse_config_client.rb +23 -2
- data/lib/quonfig.rb +1 -0
- data/quonfig.gemspec +1 -0
- data/test/test_client_network_mode.rb +136 -0
- data/test/test_semantic_logger_filter.rb +37 -16
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 875b035905394220e72fec4fc1afc3bc62ad6e2975b745098fa4482e1dc348d0
|
|
4
|
+
data.tar.gz: bd957d0ec077f3a49daf719deb3ea4db64d1b23a69f6264c91385ad21ff06720
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
1
|
+
0.0.6
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
|
9
|
-
# Quonfig::Resolver.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# +
|
|
13
|
-
#
|
|
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 ||
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
24
|
-
# :not_modified
|
|
25
|
-
# :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
|
-
|
|
96
|
+
install_envelope(envelope, source: source)
|
|
63
97
|
@etag = new_etag
|
|
64
98
|
:updated
|
|
65
99
|
when 304
|
|
66
|
-
|
|
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
|
-
|
|
106
|
+
@logger.info "Config fetch failed: status #{response.status} from #{source}"
|
|
70
107
|
:failed
|
|
71
108
|
end
|
|
72
109
|
rescue Faraday::ConnectionFailed => e
|
|
73
|
-
|
|
110
|
+
@logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
74
111
|
:failed
|
|
75
112
|
rescue StandardError => e
|
|
76
|
-
|
|
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
|
|
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)
|
data/lib/quonfig/config_store.rb
CHANGED
data/lib/quonfig/options.rb
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# discriminate per-logger via PROP_STARTS_WITH_ONE_OF /
|
|
16
|
-
#
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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:
|
|
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
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
|
|
7
|
-
# The filter
|
|
8
|
-
#
|
|
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.
|
|
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' => { '
|
|
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
|
|
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'
|
|
79
|
-
'HTMLParser'
|
|
80
|
-
'foo'
|
|
81
|
-
'A::B::CDPath'
|
|
82
|
-
|
|
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
|
|
86
|
-
"
|
|
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.
|
|
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
|