quonfig 1.7.1 → 1.7.2
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 +13 -0
- data/README.md +35 -23
- data/lib/quonfig/client.rb +1 -1
- data/lib/quonfig/datadir.rb +5 -2
- data/lib/quonfig/dev_context.rb +2 -1
- data/lib/quonfig/errors/env_var_parse_error.rb +4 -2
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -1
- data/lib/quonfig/evaluator.rb +9 -1
- data/lib/quonfig/quonfig.rb +8 -0
- data/lib/quonfig/semantic_logger_filter.rb +6 -0
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07d679520810cf5814af80cd173f859976ce05ccd24f2c03bcbd417b57565a17
|
|
4
|
+
data.tar.gz: aebf8fecb3821bd58cfa1465028ddefadf977e1c4c30d11f18e486ef2c290e67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8823f04fc9b80149680bb384355309f2df1cca790932ede846c56f2a116f0aa9ae88858f956b43c11360aa546e979777652541e027a1865246335564c2d33b1a
|
|
7
|
+
data.tar.gz: 1f930fffeba37875820d6871d1b9ec83981579856d12e79a5c812919a1a41f9d262618450e986d138bd1bdd84d69eb9fd4a240bd678cecdfe3bf464c8cd25aa3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.2 - 2026-10-09
|
|
4
|
+
|
|
5
|
+
- **Fix (security): `TypeMismatchError` and `EnvVarParseError` messages no longer include the value (qfg-goi1.2.11).** Reading a `confidential` or `decryptWith` config through the wrong typed getter (for example `get_int` on a secret) put the decrypted plaintext into the exception message and into `*_details.error_message`, which end up in logs and error trackers. An unparseable ENV_VAR or a malformed stored duration put the raw value into `EnvVarParseError`. The messages now name the key, the expected type and the actual class (`TypeMismatchError`), or the key, the env var name (or `stored value`) and the target type (`EnvVarParseError`). The doubled word in `expected expected Integer` is fixed. Only the message text changes; the exception classes and when they are raised are unchanged.
|
|
6
|
+
- **Fix: datadir mode and the dev-context loader read their JSON files as UTF-8 whatever the process locale (qfg-goi1.2.11, qfg-xy92).** Under `LANG` unset or `LANG=C` (systemd units, cron, many minimal and distroless images) Ruby's default external encoding is US-ASCII, and any non-ASCII byte in a config file (for example an em dash in a `description`) or in `quonfig.json` made `Client.new` raise `Encoding::InvalidByteSequenceError`, so the app could not boot in datadir mode. The same applied to a non-ASCII email in `~/.quonfig/tokens.json`. These files are now always read as UTF-8.
|
|
7
|
+
- **Fix: `SemanticLoggerFilter` no longer raises out of the host app's log calls (qfg-goi1.2.11).** `Client#get` raises on a resolution error (for example `MissingEnvVarError` when the log level is provided by an env var that is unset on this host, or `DecryptionError`) even when a default is passed, and the filter let that escape, so every log call through SemanticLogger raised. The filter now treats any such error like a missing key: it returns `true` and SemanticLogger's own static level decides.
|
|
8
|
+
- **Docs: the README typed-getter section now matches the code (qfg-goi1.2.11).** It documented `get_string(key, contexts = nil)`, said a missing key or a type mismatch returns `nil`, and said `get_duration` returns `Float` seconds; the examples passed the context as `user: {...}`, which raises `ArgumentError: unknown keyword: :user`. The getters are `get_*(key, default:, context:)`; a missing key returns `default:` or follows `on_no_default` (`:raise` by default); a type mismatch raises `TypeMismatchError`; `get_duration` returns integer milliseconds. No code change.
|
|
9
|
+
- **Fix: concurrent `Quonfig.init` calls build one client (qfg-goi1.2.11).** The "already initialized" check ran outside the write lock, so two threads calling `Quonfig.init` at the same time could each build a `Client`, leaving one orphaned with a live SSE stream and telemetry thread that nothing stopped. The check is now repeated inside the lock; the second caller gets the existing client and the usual "already initialized" warning.
|
|
10
|
+
- **Tests: pin `minitest` below 6 in the Gemfile (qfg-goi1.2.11).** A fresh resolve without `Gemfile.lock` picked minitest 6.0.6, which drops `minitest/mock`, and the suite failed to load. The committed lock already held 5.27.0, so CI was unaffected. No library change.
|
|
11
|
+
- **Fix: `PROP_MATCHES` / `PROP_DOES_NOT_MATCH` regexes have a 0.1s match timeout on Ruby 3.2+ (qfg-goi1.2.11).** A pattern that backtracks catastrophically (backreferences and lookarounds defeat Ruby 3.2's regex memoization) could pin the evaluating thread for seconds to hours. On Ruby 3.2+ the match now stops after 0.1s and the criterion does not match, the same as an invalid regex. Ruby 3.0 and 3.1 have no regex timeout and are unchanged.
|
|
12
|
+
- **Chaos: `server_metric(...)` expectations are now reported as SKIPPED with a reason instead of silently evaluating to 0 and passing (qfg-goi1.1.3).** api-delivery exports its metrics only by OTLP push, so the rig has nothing to scrape; server-side lag is covered by the staging drill (qfg-47c2.19) and the `QuonfigSubscriberLagHigh` alert. Each skipped expectation prints a `SKIP` line with the reason, the scenario summary counts skips separately, and the run ends with a tally of every skipped expression. In a compound expression the skipped leaf is neutral: the other leaves are still enforced (scenario 02 still requires `connected`). No library change.
|
|
13
|
+
- **Chaos: an unknown `client.sdkMetric(...)` name now fails the expectation with `unknown sdkMetric "<name>"` instead of silently evaluating to 0 (qfg-goi1.2.21).** The chaos probe returned 0 for any metric it did not implement, so a typo such as `client.sdkMetric('typo_total') == 0` passed without checking anything. The probe now reports the name as unknown and the evaluator fails loudly, matching sdk-go. Every metric name used by integration-test-data `v2026.10.03` is implemented, so no current result changes. No library change.
|
|
14
|
+
- **CI: the Chaos and Failover Chaos workflows now run integration-test-data `v2026.10.03`, the same tag as the unit and contract tests (qfg-goi1.1.3).** They were on `v2026.05.13` and `v2026.06.19.1`. The new tag adds the continuous freshness hold on scenario 05-sse-down (qfg-e3ja). Both workflows still build api-delivery from `main` on purpose (they exist to catch api-delivery drift) and now write the resolved api-delivery SHA to the run summary. No library change.
|
|
15
|
+
|
|
3
16
|
## 1.7.1 - 2026-10-03
|
|
4
17
|
|
|
5
18
|
- **Behaviour change (bug fix): weighted rollouts now hash non-ASCII text the same way as every other Quonfig SDK (qfg-1mvb).** The MurmurHash3 used to pick a weighted variant counted characters instead of UTF-8 bytes, so a config key or hash value containing non-ASCII text (for example `josé`, `日本語`, `🚀`) hashed to a different number than sdk-java, sdk-python, sdk-go and sdk-node. It now hashes the UTF-8 bytes. Ruby users whose hash value (or the flag's key) contains non-ASCII characters may move to a different variant on upgrade; afterwards they get the same variant as in the other SDKs. ASCII-only values and keys are unchanged.
|
data/README.md
CHANGED
|
@@ -35,8 +35,8 @@ limit = client.get_int('rate-limit')
|
|
|
35
35
|
name = client.get_string('app.display-name')
|
|
36
36
|
regions = client.get_string_list('allowed-regions')
|
|
37
37
|
|
|
38
|
-
# Context-aware evaluation — pass a context hash
|
|
39
|
-
value = client.get_string('homepage-hero', user: { key: 'user-123', country: 'US' })
|
|
38
|
+
# Context-aware evaluation — pass a context hash with `context:`
|
|
39
|
+
value = client.get_string('homepage-hero', context: { user: { key: 'user-123', country: 'US' } })
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Context
|
|
@@ -47,7 +47,7 @@ attach a context in three ways:
|
|
|
47
47
|
### 1. Per-call context
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
|
-
client.get_bool('beta-feature', user: { key: 'user-123', plan: 'pro' })
|
|
50
|
+
client.get_bool('beta-feature', context: { user: { key: 'user-123', plan: 'pro' } })
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### 2. `with_context` block
|
|
@@ -333,31 +333,43 @@ model.
|
|
|
333
333
|
|
|
334
334
|
## Typed getters
|
|
335
335
|
|
|
336
|
-
Each typed getter takes a config key
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
|
349
|
-
|
|
336
|
+
Each typed getter takes a config key plus two optional keyword arguments:
|
|
337
|
+
`default:` (returned when the key is missing) and `context:` (a context hash
|
|
338
|
+
for this call). On a `BoundClient` (see `with_context` above) the getters take
|
|
339
|
+
only `default:`; the context is the bound one.
|
|
340
|
+
|
|
341
|
+
- **Missing key:** returns `default:` when you pass one. Without a default the
|
|
342
|
+
`on_no_default` option decides: `:raise` (the default) raises
|
|
343
|
+
`Quonfig::Errors::MissingDefaultError`, `:return_nil` returns `nil`.
|
|
344
|
+
- **Wrong type:** a value that does not match the getter's type (for example
|
|
345
|
+
`get_int` on a string config) raises `Quonfig::Errors::TypeMismatchError`,
|
|
346
|
+
with or without a `default:`.
|
|
347
|
+
|
|
348
|
+
| Method | Returns |
|
|
349
|
+
|-----------------------------------------------------|-------------------------------------------|
|
|
350
|
+
| `get_string(key, default:, context:)` | `String` |
|
|
351
|
+
| `get_int(key, default:, context:)` | `Integer` |
|
|
352
|
+
| `get_float(key, default:, context:)` | `Float` |
|
|
353
|
+
| `get_bool(key, default:, context:)` | `true` or `false` |
|
|
354
|
+
| `get_string_list(key, default:, context:)` | `Array<String>` |
|
|
355
|
+
| `get_duration(key, default:, context:)` | `Integer` (milliseconds) |
|
|
356
|
+
| `get_json(key, default:, context:)` | `Hash`, `Array`, or a scalar |
|
|
357
|
+
| `enabled?(feature_name, context = nil)` | `true` or `false` (`false` when missing) |
|
|
358
|
+
|
|
359
|
+
`get_duration` returns integer milliseconds (`PT1.5S` is `1500`). A duration
|
|
360
|
+
value that is not valid ISO-8601 returns `default:` and logs one warning per
|
|
361
|
+
key; with no default it follows `on_no_default`.
|
|
350
362
|
|
|
351
363
|
Example:
|
|
352
364
|
|
|
353
365
|
```ruby
|
|
354
366
|
client.get_string('app.display-name')
|
|
355
|
-
client.get_int('rate-limit', user: { key: 'user-123' })
|
|
356
|
-
client.get_float('pricing.multiplier')
|
|
357
|
-
client.get_bool('flags.new-checkout')
|
|
358
|
-
client.get_string_list('allowed-regions')
|
|
359
|
-
client.get_duration('request-timeout')
|
|
360
|
-
client.get_json('homepage.layout')
|
|
367
|
+
client.get_int('rate-limit', default: 100, context: { user: { key: 'user-123' } })
|
|
368
|
+
client.get_float('pricing.multiplier', default: 1.0)
|
|
369
|
+
client.get_bool('flags.new-checkout', default: false)
|
|
370
|
+
client.get_string_list('allowed-regions', default: [])
|
|
371
|
+
client.get_duration('request-timeout', default: 5_000) # milliseconds
|
|
372
|
+
client.get_json('homepage.layout', default: {})
|
|
361
373
|
client.enabled?('beta-feature', user: { key: 'user-123' })
|
|
362
374
|
```
|
|
363
375
|
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -1830,7 +1830,7 @@ module Quonfig
|
|
|
1830
1830
|
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
1831
1831
|
value
|
|
1832
1832
|
when Class
|
|
1833
|
-
raise Quonfig::Errors::TypeMismatchError.new(key,
|
|
1833
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, expected_type.to_s, value) unless value.is_a?(expected_type)
|
|
1834
1834
|
|
|
1835
1835
|
value
|
|
1836
1836
|
else
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -39,7 +39,10 @@ module Quonfig
|
|
|
39
39
|
.sort
|
|
40
40
|
.each do |filename|
|
|
41
41
|
path = File.join(dir, filename)
|
|
42
|
-
|
|
42
|
+
# Always read as UTF-8: under LANG unset / LANG=C the process
|
|
43
|
+
# default is US-ASCII and JSON.parse would raise on any non-ASCII
|
|
44
|
+
# byte (qfg-goi1.2.11 / qfg-xy92).
|
|
45
|
+
raw = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
|
43
46
|
raise ArgumentError, "[quonfig] config has empty key — file is not a Quonfig Config: #{path}" if raw['key'].nil? || raw['key'].to_s.empty?
|
|
44
47
|
|
|
45
48
|
coerce_numeric_values(raw)
|
|
@@ -68,7 +71,7 @@ module Quonfig
|
|
|
68
71
|
|
|
69
72
|
raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
|
|
70
73
|
|
|
71
|
-
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
74
|
+
environments = JSON.parse(File.read(quonfig_path, encoding: 'UTF-8')).fetch('environments', [])
|
|
72
75
|
|
|
73
76
|
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
|
|
74
77
|
|
data/lib/quonfig/dev_context.rb
CHANGED
|
@@ -21,7 +21,8 @@ module Quonfig
|
|
|
21
21
|
return nil unless File.exist?(path)
|
|
22
22
|
|
|
23
23
|
raw = begin
|
|
24
|
-
|
|
24
|
+
# UTF-8 regardless of the process locale (qfg-goi1.2.11 / qfg-xy92).
|
|
25
|
+
File.read(path, encoding: 'UTF-8')
|
|
25
26
|
rescue StandardError => e
|
|
26
27
|
LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
|
|
27
28
|
return nil
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
5
|
class EnvVarParseError < Quonfig::Error
|
|
6
|
-
def initialize(
|
|
6
|
+
def initialize(_env_var, config, env_var_name)
|
|
7
7
|
key, value_type =
|
|
8
8
|
if config.is_a?(Hash)
|
|
9
9
|
[config[:key] || config['key'],
|
|
@@ -11,7 +11,9 @@ module Quonfig
|
|
|
11
11
|
else
|
|
12
12
|
[config.key, config.value_type]
|
|
13
13
|
end
|
|
14
|
-
|
|
14
|
+
# +_env_var+ (the raw value) is deliberately left out of the message:
|
|
15
|
+
# an env var or stored value can be a secret (qfg-goi1.2.11).
|
|
16
|
+
super("Evaluating #{key} couldn't coerce #{env_var_name} to #{value_type}")
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
5
|
class TypeMismatchError < Quonfig::Error
|
|
6
|
+
# The message names the key, the expected type and the actual value's
|
|
7
|
+
# class only. The value itself is never interpolated: it can be a
|
|
8
|
+
# decrypted secret, and exception messages end up in logs and error
|
|
9
|
+
# trackers (qfg-goi1.2.11).
|
|
6
10
|
def initialize(key, expected, actual_value)
|
|
7
|
-
super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}
|
|
11
|
+
super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
|
|
8
12
|
end
|
|
9
13
|
end
|
|
10
14
|
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -56,6 +56,14 @@ module Quonfig
|
|
|
56
56
|
|
|
57
57
|
MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
|
|
58
58
|
|
|
59
|
+
# PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
|
|
60
|
+
# 3.2+ supports Regexp.new(src, timeout:); Regexp::TimeoutError is a
|
|
61
|
+
# RegexpError, so a pattern that runs away (backreferences and lookarounds
|
|
62
|
+
# defeat Onigmo's memoization) does not match instead of pinning a
|
|
63
|
+
# thread. Ruby 3.0/3.1 have no timeout and are unchanged.
|
|
64
|
+
REGEX_MATCH_TIMEOUT_SECONDS = 0.1
|
|
65
|
+
REGEX_TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
|
|
66
|
+
|
|
59
67
|
attr_reader :store
|
|
60
68
|
attr_accessor :project_env_id, :env_id
|
|
61
69
|
|
|
@@ -218,7 +226,7 @@ module Quonfig
|
|
|
218
226
|
mv = hget(match_value, :value)
|
|
219
227
|
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
220
228
|
begin
|
|
221
|
-
re = Regexp.new(mv)
|
|
229
|
+
re = REGEX_TIMEOUT_SUPPORTED ? Regexp.new(mv, timeout: REGEX_MATCH_TIMEOUT_SECONDS) : Regexp.new(mv)
|
|
222
230
|
matched = re.match?(context_value)
|
|
223
231
|
return matched == (operator == OP_PROP_MATCHES)
|
|
224
232
|
rescue RegexpError
|
data/lib/quonfig/quonfig.rb
CHANGED
|
@@ -11,6 +11,14 @@ module Quonfig
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
@@lock.with_write_lock do
|
|
14
|
+
# Re-check under the lock: two threads can both pass the check above,
|
|
15
|
+
# and the loser must not build (and orphan) a second Client with its
|
|
16
|
+
# own SSE stream and telemetry thread (qfg-goi1.2.11).
|
|
17
|
+
unless @singleton.nil?
|
|
18
|
+
LOG.warn 'Quonfig already initialized.'
|
|
19
|
+
next @singleton
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
@singleton = Quonfig::Client.new(options)
|
|
15
23
|
end
|
|
16
24
|
end
|
|
@@ -52,6 +52,10 @@ module Quonfig
|
|
|
52
52
|
|
|
53
53
|
# SemanticLogger filter contract: return true to emit, false to suppress.
|
|
54
54
|
# Missing config key → return true so SemanticLogger's static level decides.
|
|
55
|
+
# A resolution error (unset ENV_VAR, decryption failure, ...) is treated
|
|
56
|
+
# the same way: the filter runs inside the host app's log calls and must
|
|
57
|
+
# never raise out of them (qfg-goi1.2.11). Nothing is logged here, since
|
|
58
|
+
# logging from inside a log filter can recurse.
|
|
55
59
|
def call(log)
|
|
56
60
|
configured = @client.get(@config_key, nil, context_for(log))
|
|
57
61
|
return true if configured.nil?
|
|
@@ -59,6 +63,8 @@ module Quonfig
|
|
|
59
63
|
log_severity = LEVELS[log.level] || LEVELS[:debug]
|
|
60
64
|
min_severity = LEVELS[normalize_level(configured)] || LEVELS[:debug]
|
|
61
65
|
log_severity >= min_severity
|
|
66
|
+
rescue StandardError
|
|
67
|
+
true
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
private
|
data/lib/quonfig/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.7.
|
|
4
|
+
version: 1.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Dwyer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|